samagotchi 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +21 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +48 -1
  13. data/docs/plugins.md +2 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +7 -5
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +140 -9
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session.rb +1 -1
  39. data/lib/samagotchi/session_chain.rb +2 -8
  40. data/lib/samagotchi/session_commands.rb +2 -0
  41. data/lib/samagotchi/session_manager.rb +1 -2
  42. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  43. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  44. data/lib/samagotchi/session_metrics.rb +118 -83
  45. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  46. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  47. data/lib/samagotchi/toolkit.rb +9 -8
  48. data/lib/samagotchi/toolkit_command.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +1 -5
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +3 -0
  52. data/lib/samagotchi/web/public/app.js +4 -3
  53. data/lib/samagotchi/web/public/chains.js +42 -21
  54. data/lib/samagotchi/web/public/composer.js +9 -5
  55. data/lib/samagotchi/web/public/ctx.js +73 -11
  56. data/lib/samagotchi/web/public/index.html +32 -35
  57. data/lib/samagotchi/web/public/info_bar.js +4 -4
  58. data/lib/samagotchi/web/public/info_bar_model.js +18 -8
  59. data/lib/samagotchi/web/public/lists_view.js +2 -2
  60. data/lib/samagotchi/web/public/question_cards.js +1 -1
  61. data/lib/samagotchi/web/public/session_state.js +7 -0
  62. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  63. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  64. data/lib/samagotchi/web/public/timing.js +11 -2
  65. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baa839500eb96519376830a675e787eab121aab4c8de33fa72ffa3f007469643
4
- data.tar.gz: 17e32518eaf7348ec4a08514a302a14139d71369904602815662186d9dfa584d
3
+ metadata.gz: c2bec0eb4ac3d46dcca5ca46f2496c4c419d1373ea595fcceae35d5a96098899
4
+ data.tar.gz: b9be22b2481f7489457eceb98eb1ace0cc3b58988ab34790d0c40197dc586ccc
5
5
  SHA512:
6
- metadata.gz: 4f52356f77fe80e1cb56f3d28c4c7844c08ca4291ee3090a89f18a13261b5ee8db5d2b77835da6b756a118e3a30000b5fe2160f3de255f90ee30294728b0edd9
7
- data.tar.gz: 88d10148534c0e9daaa6b5f97356b9b4f55311a25fa591ca48f70422f5c3cd128d9204b9bf7ea3f65e71c5fd26b7554de1571ddc650311ed1ab0af67eb4073fe
6
+ metadata.gz: f8d1c6f29de97f66b9ece157418f02628939f98c9a94926bbde5536fd2e1d126ca42cfce51497290f32e225d0ac169b81eda6e4aa1836c1d4d7bdd94d1d4c929
7
+ data.tar.gz: 9811070c6def3fe473d2e5fba2a044c25e1d843c12097b738a14854e1a9c6064e84f1b77ae56960166fceac101eb06aca6cbcb03c148e3017c034594cdb246a0
data/CHANGELOG.md CHANGED
@@ -8,6 +8,71 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.76.0] - 2026-10-17
12
+
13
+ ### Security
14
+
15
+ - A bundle can no longer replace an installed one by taking its name. `chi bundle install` over a bundle from another
16
+ source is refused (`--force` too): `chi bundle upgrade <new source>` switches the source and says
17
+ `Source changes: <old> → <new>`. A name chi ships (`guardrails`, `samagotchi-system`, …, in any letter case)
18
+ installs only from chi's own copy unless `--force`, and a name that differs from an installed one only in case is
19
+ refused.
20
+ - A bundle's symlinked memory is no longer installed: a tar, zip or git repo whose `notes.md` linked to another file
21
+ (`~/.ssh/…`, an API key) copied that file into the memories the model reads. It is skipped with
22
+ `notes.md is a symlink (not installed)`; a linked hook or guardrail rule is skipped too, and a linked plugin or
23
+ script fails the install.
24
+
25
+ ### Added
26
+
27
+ - `/stats`, `chi sessions stats` and the web's ctx tooltip split tokens, cost and speed per model after a `/model`
28
+ switch: a row per run of turns on one model, each switch's prefill under it, and a total.
29
+ - Docs: [chi-nah](https://github.com/dm1try/chi-nah), an external guard bundle, in docs/guardrails.md.
30
+
31
+ ### Changed
32
+
33
+ - A bundle installed from a git URL is named by its manifest's `name:` (it took the repo's name), so a URL install and
34
+ a local copy are one bundle. One installed by URL before this under a different repo name: `chi bundle uninstall
35
+ <repo name>`, then install it again (chi says so).
36
+ - The `core` and `dev` profiles require the chi their members need (0.60.0 and 0.72.0).
37
+
38
+ ### Fixed
39
+
40
+ - After `/model` the ctx % no longer shows the old model's window: it uses the new model's window at once, or shows
41
+ `ctx ?` (tooltip `Context window unknown until the next request`) until the window is known. The same for a session
42
+ resumed or woken on another model.
43
+ - `/stats` no longer counts a `/model` to the same model on another host as a re-prefill.
44
+ - Web: switching sessions while a command or a restored prompt re-reads no longer draws it in the session just
45
+ opened; a command's answer no longer clears another session's draft; such failures are a toast, not an alert.
46
+ - `chi bundle install|upgrade`: an invalid or missing `manifest.yml` fails with one clear line (no backtrace, no
47
+ "falling back to install"); a manifest `name:` that is a path is refused; a plain re-install keeps the record of
48
+ files the bundle stopped shipping, so the upgrade it suggests trashes them; `chi bundle upgrade` with no source
49
+ prints the full usage line.
50
+ - codex-peer: a send no longer hangs when `codex queue` exits but leaves a process holding its output; it stops
51
+ after 10 s and says the message may already be queued.
52
+
53
+ Update with `chi update` (the system bundle 0.76.0, `core` 0.3.1, `dev` 0.5.1, `codex-peer` 0.3.2), then restart
54
+ `chi web`.
55
+
56
+ ## [0.75.0] - 2026-10-17
57
+
58
+ ### Changed
59
+
60
+ - Web: a session chain's links are numbered `#N` instead of `day N` (the chip `↩ #3`, `#2 of 3`; its popover's rows),
61
+ since a chain may be continued several times a day. The info bar's ← / → show the neighbour's time (`← 09:12`) when
62
+ it started on the open link's day, else its date, with its full start in the tooltip; a popover row adds its time
63
+ when another link of the chain started the same day. The carry note names the previous link's start with its time
64
+ (`This session continues 3fa2c1d0 (2026-10-17 09:12), …`).
65
+
66
+ ### Fixed
67
+
68
+ - `chi bundle install` / `upgrade` no longer make a bundle repo's README, CHANGELOG, LICENSE, CONTRIBUTING,
69
+ CODE_OF_CONDUCT or SECURITY file a memory, and a manifest whose `files:` lists memories installs only those (an
70
+ unshipped name warns). An upgrade moves such a file an older install made a memory to the trash. Older chi still
71
+ installs every top-level `*.md` (`requires_chi` doesn't gate memories): a bundle for it too names its readme
72
+ `README.markdown`. `rake bundles:check` fails a shipped bundle's `*.md` that its `files:` doesn't list.
73
+
74
+ Update with `chi update` (the system bundle 0.75.0), then restart `chi web`.
75
+
11
76
  ## [0.74.0] - 2026-10-17
12
77
 
13
78
  ### Changed
@@ -3156,7 +3221,9 @@ and long-lived sessions.
3156
3221
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3157
3222
  hotkey panel that send selected text or the clipboard to your sessions.
3158
3223
 
3159
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.74.0...HEAD
3224
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.76.0...HEAD
3225
+ [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
3226
+ [0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
3160
3227
  [0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
3161
3228
  [0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
3162
3229
  [0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
data/docs/architecture.md CHANGED
@@ -327,10 +327,16 @@ which `SessionMetrics#persist` rewrites after each turn; the web server merges t
327
327
  The totals' `tokens` block adds the cached, cache-write and reasoning sums, the reported cost (`cost_sum`) and the
328
328
  estimated one (`cost_estimate_sum`, from `hosts.<name>.models` prices; 0 for none), the decode time and tokens
329
329
  behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
330
- `last_prefill_tps`, `tps_source`: `server` or `estimate`). Each `generation_completed` event
330
+ `last_prefill_tps`, `tps_source`: `server` or `estimate`). `model_segments` (always present) splits those sums
331
+ by model after a `/model` switch: one row per run of consecutive turns on one model (`SessionMetrics::ModelSegment`,
332
+ keyed on the turn records' `model_ref`), in order, with its turns, token and cost sums, speeds and the switch's prefill
333
+ (`prefill_tokens`, `prefill_cost_estimate`: the sums of its turn records' `switch_prefill_tokens` and
334
+ `switch_prefill_cost_estimate`, [sessions.md](sessions.md)); `[]` with one model, the oldest rows folded into one (`folded_segments`)
335
+ past 6; a running turn counts in its model's row (`ended_at` null). The rows add up to `tokens`. They are derived from
336
+ the records, never read back. Each `generation_completed` event
331
337
  carries the generation's `speed` (`{decode_tps, source}`, null without one), its own prompt-cache
332
338
  counts (`prompt_tokens`, `cached_tokens`, `cache_write_tokens`: null when the server reported no writes) and
333
- those running `tokens`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
339
+ those running `tokens` and `model_segments`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
334
340
  it, so the web's info bar updates per generation without parsing chunk payloads.
335
341
 
336
342
  The page's frequent reads of `GET /api/sessions/:id` stay light too:
@@ -504,6 +510,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
504
510
  |---|---|---|
505
511
  | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
506
512
  | Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
513
+ | Paths | `paths.rb` (`Paths`) | The XDG base dirs chi's files live under, read from the env on every call: `Paths.state_home` (`$XDG_STATE_HOME`, else `~/.local/state`) and `Paths.config_home` (`$XDG_CONFIG_HOME`, else `~/.config`), both defaults under `Dir.home`; a blank variable counts as unset, a relative one is made absolute from the current directory. `Paths.tilde` shows a path with `~` for the home folder. `SessionManager` hands a spawned worker the spawner's `XDG_STATE_HOME` / `XDG_CONFIG_HOME`, when set, made absolute, so a relative one isn't read from the session's folder. |
507
514
  | MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
508
515
  | Plugin API | `plugin/api.rb` (`Plugin::Api`), `plugin/tool_spec.rb` (`Plugin::ToolSpec`: a checked tool declaration, how it is registered), `plugin/loader.rb` | What a bundle's plugin registers: tools, commands, annotation actions, hooks, services, toolkits, peer sources ([plugins.md](plugins.md)). |
509
516
  | Peer bundles' base | `plugin/peer.rb` (`Plugin::Peer`) | What claude-peer and codex-peer share (the send tool, the command, the cards, payloads); each bundle's subclass sets its strings and transport ([plugins.md](plugins.md#the-claude-peer-bundle)). |
@@ -515,8 +522,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
515
522
  | Idle work | `idle_scheduler.rb` (`IdleScheduler`), `idle_recap.rb` (`IdleRecap`), `idle_reminders.rb` | One poller for an idle session's recap and reminders. |
516
523
  | Attached context | `context_sources.rb` (`ContextSources`), `context_poller.rb`, `context_absorber.rb`, `peer_sources.rb` (`PeerSources`: the sources peer bundles declare) | External sources a session keeps fresh, and the peer loop's wakes ([context.md](context.md)). |
517
524
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
518
- | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. |
519
- | Analytics | `analytics_file.rb` (`AnalyticsFile`) | A session's `analytics.json` (`SessionMetrics#persist`). |
525
+ | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
526
+ | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows). |
520
527
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
521
528
  | Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
522
529
  | Tool index | `tools/tool_index.rb` (`Tools::ToolIndex`) | `find_tools` and `call_tool`: the tools a session knows but doesn't declare one by one (an mcp bundle's servers through `chi.tool_source`, the built-ins its toolkit leaves out), searched and called by name ([plugins.md](plugins.md)). |
data/docs/cli.md CHANGED
@@ -469,7 +469,17 @@ prompt (last, server)`); for other servers it is estimated from the first
469
469
  streamed chunk to the end and marked `~` (`~64 tok/s out (last, estimate)`).
470
470
  Generations under 8 tokens or 100 ms get no speed of their own, nor does a
471
471
  reply without server token counts or a non-streamed one. A delegated child
472
- keeps its own numbers: a parent's tokens and cost leave them out. `/recap` is in
472
+ keeps its own numbers: a parent's tokens and cost leave them out.
473
+
474
+ After a `/model` switch (or a hook's) the tokens line becomes a `models:` block, a row per run of turns on one
475
+ model, in order (A → B → A is three rows): the model (`host:model` as asked), when (`10:43–now` while it runs; with
476
+ the day when the rows span several), turns, tokens in/out, its average speed and cost. Under the row of the model
477
+ switched to, `└ switch prefill 58.0k uncached prompt ≈$0.01` says what its first request prefilled on the new model
478
+ (the prompt less the cached reads; `≈` an estimate from the new model's configured price, left out without one). A
479
+ `total` row ends the block. The speed line names the model of its last speed, and the cost is from `each model's host
480
+ prices`. Past 6 rows the oldest fold into one `N earlier segments` row; a session's turns from before chi recorded
481
+ their model are one `earlier turns` row. One model: the output is unchanged. `chi sessions stats` prints the same,
482
+ also offline (from the saved records). `/recap` is in
473
483
  [Session recap](#session-recap), `/model` and `/models` in [Runtime Model
474
484
  Switch](#runtime-model-switch-assist-mode).
475
485
 
@@ -889,6 +899,8 @@ In interactive assist mode, you can switch the request model without restarting:
889
899
  Notes:
890
900
 
891
901
  - The switch updates the request `model` field, routes to the matching host (`HostRegistry#resolve`), and resolves the prompt profile again (config, the server's chat template, the name; see "Prompt profile" in configuration.md).
902
+ - The ctx % follows the switch at once: the last turn's count against the new model's window (the web's info bar, the attached TUI's status row, a client that joins later), recounted at the next generation. When the new window can't be resolved the web shows `ctx ?` and the attached TUI's status row drops its ctx until then ([context telemetry](internals/context-telemetry.md)).
903
+ - After a switch `/stats` (and the web's ctx tooltip) splits the session's tokens, cost and speed per model, with what each switch prefilled on the new model: the `models:` block ([Session commands](#session-commands)).
892
904
  - Without `--default` the command is session-scoped and does not rewrite config files.
893
905
  - With `--default` the new default is written to `~/.config/samagotchi/config.yml` (honoring `XDG_CONFIG_HOME`) and takes effect for all new sessions; the current session's effective model is also updated immediately. Bare aliases and `host:model` are both valid.
894
906
  - `--default` and `--alias` change only their one key's line in `config.yml` (`default.model`, or the alias under `model_aliases:`): comments and the rest of the file stay as written, a symlinked file is written through and keeps its mode. When the edited text wouldn't read back as the expected settings (a flow-style section, anchors), chi writes the whole file out from the parsed data instead, which drops its comments.
@@ -836,9 +836,10 @@ cache:
836
836
  ```
837
837
  Each request's own counts are in the debug log's `generation_completed` line:
838
838
  `prompt=` (its prompt tokens), `cached=` (read from the server's cache), `cache_write=` (written to it, when the
839
- server reports writes) and `reprefill=` (of the previous request's prompt on the same model, what the server
840
- prefilled again: an `llm_context` edit's cache break, or anything else that rewrote the prompt's middle; only when the
841
- server reports a cached count, and not on the first request after a warm-up); recap and side requests log theirs as `recap request_usage`.
839
+ server reports writes) and `reprefill=` (of the previous request's prompt on the same model through the same host,
840
+ what the server prefilled again: an `llm_context` edit's cache break, or anything else that rewrote the prompt's
841
+ middle; only when the server reports a cached count, and not on the first request after a warm-up; a `/model` to the
842
+ same model on another host is the switch's prefill, not a re-prefill); recap and side requests log theirs as `recap request_usage`.
842
843
 
843
844
  ## Llama Model Routing
844
845
 
data/docs/guardrails.md CHANGED
@@ -568,6 +568,15 @@ next start.
568
568
  The system prompt names the home directory once, with the advice to write
569
569
  it as `~` or `$HOME`, so the model rarely has to spell it.
570
570
 
571
+ ## An external guard: chi-nah
572
+
573
+ [chi-nah](https://github.com/dm1try/chi-nah) is a bundle kept in its own repo (`chi bundle install
574
+ https://github.com/dm1try/chi-nah`). Its `before_tool_call` hook asks
575
+ [nah](https://github.com/manuelschipper/nah)'s deterministic guards about every `execute`, `task_create`,
576
+ `read`, `write` and `edit`: a call nah blocks (secret exfiltration, `rm -rf ~`, `git reset --hard`,
577
+ `curl | sh`…) is denied with the guard's name and how the user pauses it (`nah nap <guard>`); everything
578
+ else goes to chi's own rules. It needs nah installed; see its README for the settings.
579
+
571
580
  ## Failing closed
572
581
 
573
582
  - A config hook with `required: true`, or a bundle `before_tool_call` hook with
@@ -38,7 +38,9 @@ Paths are under `lib/samagotchi/llm/`.
38
38
 
39
39
  `AnthropicMessages.for(entry)` takes the host's API root, key variable, `auth:`, `dialect:` and remoteness;
40
40
  `HostRegistry#adapter_for` keeps one per host for the worker's life, so what it learns from a 400 (below) lasts that
41
- long. Each request posts `{base}/v1/messages` with `anthropic-version: 2023-06-01`, the key in the header the host's
41
+ long. Each request posts `{base}/v1/messages` with `anthropic-version: 2023-06-01`, the key (`LLM::ApiKey`: the
42
+ variable `api_key_env:` names, else, on macOS, the login Keychain's item for that name, `HostKeys`; configuration.md,
43
+ "API keys from the Keychain") in the header the host's
42
44
  `auth:` names (else the dialect's: `x-api-key` on the standard one), and `Session-Id` (plus the dialect's
43
45
  `session_header`) when the loop has a session id.
44
46
 
@@ -22,9 +22,12 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
22
22
 
23
23
  | Piece | Role |
24
24
  |---|---|
25
- | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash`; runs the Installer, prints its summary, runs the conflict agent step |
25
+ | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
26
26
  | `SourceNormalizer` (source.rb) | source → local dir; a git/zip/tar extract is *owned* and removed after the install |
27
- | `Manifest` | parses/validates `manifest.yml` (files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
27
+ | `SourceIdentity` (source.rb) | what a source is, for comparing two installs of one name: `shipped:<dir>` (any gem or checkout), `git:<url>` (no `#ref`, trailing `/` or `.git`), `path:<realpath>`; nil when it can't place one |
28
+ | `PreparedSource` (source.rb) | one source normalized once (dir, owned, git commit): `BundleCommand` reads the bundle's name from its `manifest.yml` (the name for every kind of source, a git URL too) and hands it to the Installer (`prepared:`), so a source is cloned or extracted once per command |
29
+ | `MemoryFiles` | which top-level `*.md` files of a bundle are its memories (`.select`: the `files:` list, else all but README/LICENSE and the like), `.unshipped` (listed, not shipped) and `.symlinks` (skipped: a symlink is never a memory) |
30
+ | `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
28
31
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
29
32
  | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
30
33
  | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
@@ -47,19 +50,30 @@ and checks the recorded sha256 (and `requires_chi`) before loading.
47
50
  The order matters: warnings print in it, and a plugin/script the manifest names but the bundle lacks raises
48
51
  `InstallError` after the memories are already copied.
49
52
 
50
- 1. `load_source`: normalize, read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning).
51
- 2. `resolve_target`: scope = CLI > manifest > `system`.
52
- 3. `install_memories`, per `*.md`: an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
53
+ 1. `load_source`: normalize (or take the `PreparedSource` `BundleCommand` passed as `prepared:`), read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning).
54
+ 2. `resolve_target`: scope = CLI > manifest > `system`; then `check_source` against the existing record
55
+ (`SourceIdentity`, source.rb): a name differing from an installed one only in case raises (`force` too); a
56
+ shipped name (any case) from a source that isn't a dir in the Installer's `shipped_dir:` (default `SHIPPED_DIR`;
57
+ ShippedUpdate, Profile and SystemBundle pass theirs) raises unless `force` (a recorded source is matched by the
58
+ `SHIPPED_SOURCE` path pattern instead, for older gems); a plain install
59
+ over a record from another source raises (`force` too); an upgrade may switch sources and sets
60
+ `#source_change` (the summary's first line, `Source changes: old → new`). A source `SourceIdentity` can't place
61
+ (an older record's relative path) never refuses.
62
+ 3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
63
+ README, CHANGELOG, LICENSE and the like; a listed name not shipped warns; a symlink is skipped with a warning): an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
53
64
  classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
54
65
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
55
66
  (`<name>.<key>.md`) get no index line (`note_overlay`).
56
67
  4. `AssetInstaller#run`: hooks (manifest-listed, else every `hooks/*.rb`), prune dropped hooks, hook checksums,
57
- guardrails, plugin, scripts; each set replaces the previous version's. requires_chi warnings for hooks/rules.
68
+ guardrails, plugin, scripts; each set replaces the previous version's. requires_chi warnings for hooks/rules. A
69
+ file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`, a linked `hooks/` too) is
70
+ skipped with a warning (a hook or rule) or raises `InstallError` (the plugin, a script).
58
71
  5. An upgrade's `prune_dropped_files`: unchanged → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
59
72
  6. `verify_memory_checksums` (strict), `warn_missing_needs`.
60
73
  7. `write_provenance` (manifest, not a dry run): bases come from the incoming file, except where the user's edit was
61
74
  kept (kept/noop/conflict/kept_pruned, or a re-install's skipped edit): those keep the old base, so the next
62
- upgrade still sees the edit.
75
+ upgrade still sees the edit. A plain install also keeps the record (the old base) of a file an earlier install
76
+ owned and this version doesn't ship, while it is on disk (`dropped_owned_files`), so the next upgrade prunes it.
63
77
  8. `detect_placeholders`.
64
78
 
65
79
  Results are `@results` (file → `{status:, reason:}`), summarised by `#summary`. An upgrade with conflicts keeps an
@@ -34,6 +34,28 @@ Emission behavior:
34
34
  `context.status_thresholds` the top bucket reads critical, the one below it
35
35
  elevated, the next moderate, and the first bucket (and the rest below) healthy.
36
36
 
37
+ After `/model` (`SessionCommands#switch_model`), the old model's percentage goes at once (`Engine#switch_model!`
38
+ clears the kept status), and `Engine#announce_switched_context!` sends a `:context_status` of its own, outside any
39
+ turn: the last turn's count (the old model's tokenizer's; the next generation replaces it) against the new model's
40
+ window (`EffectiveModel#context_window`, the lookup below: the running server first, one `/props` GET that a remote
41
+ chat host is spared; else the host's cached model list; else the configured window) and the session's budget,
42
+ with `context_window_tokens` / `context_window_source` (the web takes the window
43
+ from them) and `usage.source: "switch"`. It is kept for a client that joins later (`session_state_snapshot`'s
44
+ `context_status`). A window that doesn't resolve sends `usage: null` and `context_window_tokens: null`: the web shows
45
+ `ctx ?` and drops the old model's window (a chunk's usage then counts no %, until a `generation_started` brings a
46
+ window), and the attached TUI's status row drops its ctx until the next generation (the web's ctx tooltip then says
47
+ `Context window unknown until the next request` over the session's tokens, `ctxBarTip`, until that request starts;
48
+ plain `Context window unknown` after it, and for a session whose window lookup failed). Nothing is sent before the
49
+ session's first counted turn. A
50
+ hook's switch (`before_turn`) sends nothing: its turn's first request counts on the new model anyway.
51
+
52
+ A process that loads the session on another model than its last turn ran on (`Engine#session=`:
53
+ `SessionMetrics#last_model_ref` differs from `effective_model_ref`; a `--resume`'d REPL with `--model`, a worker woken
54
+ after a `/model` with no turn since) drops the old window (`forget_model_reports!`) and calls
55
+ `announce_switched_context!(configured_only: true)`: the configured window only
56
+ (`EffectiveModel#configured_context_window`, no `/props` GET or model list, since a worker runs it before its Bridge
57
+ serves); without one, no percentage until the first generation reports the window.
58
+
37
59
  Under an LLM context strategy (`ContextStatus.new(llm_context:)`, the turn's `LLMContextStrategy::Resolved`, given by
38
60
  both loops):
39
61
 
@@ -131,6 +131,13 @@ The effective model doesn't resolve (a broken host config, HostRegistry#resolve
131
131
  → session_state_snapshot never raises (its model fields are nil); a turn, stats_snapshot,
132
132
  chat_model? and system_prompt raise, as before
133
133
 
134
+ A session's toolkit doesn't resolve (an unknown kit name: a typo, a bundle's kit that isn't installed; a base that
135
+ doesn't resolve, a cycle), at a turn's start (ToolkitBinding#apply!):
136
+ → that turn declares the full kit with the session's own added and removed tools on top (Toolkit#resolve's
137
+ fallback); the saved toolkit stays as it is, so a reinstalled bundle gets it back
138
+ → each warning (that fallback, a config.yml kit with a problem: Toolkit.kits) logged once per process as
139
+ toolkit_unresolved (WARN), with a :guardrail_warning (label "toolkit") to the turn and the observers
140
+
134
141
  Stopped before the model was asked (prepare_turn returned false):
135
142
  → stopped_before_model(turn) → end_turn(turn, "canceled")
136
143
  → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
@@ -460,11 +467,11 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
460
467
  |---|---|---|
461
468
  | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue) |
462
469
  | `:reminder_injected` | due reminders went into the turn | `{reminders}` |
463
- | `:context_status` | every request (unless `context.status` is off), the current estimate before it | `{iteration, status, usage, bucket, source}` (internals/context-telemetry.md) |
464
- | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price}` (`price`: the turn's `ModelPrice#to_h`, nil without one; native adds `profile`, `profile_source`) |
470
+ | `:context_status` | every request (unless `context.status` is off), the current estimate before it; and outside any turn after `/model` (or when a session is loaded on another model than its last turn ran on), to the observers only (`Engine#announce_switched_context!`) | `{iteration, status, usage, bucket, source}`; the switch's form has no `iteration`: `{usage, bucket, context_window_tokens, context_window_source}`, with `usage.source: "switch"`, and `usage` nil when the new window doesn't resolve (internals/context-telemetry.md) |
471
+ | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`price`: the turn's `ModelPrice#to_h`, nil without one; `model_ref`: the turn's model, asked `host:model`, added by the Engine's stream handler from the model `#generate` resolved after the `before_turn` hooks, so a hook's switch is the one named; native adds `profile`, `profile_source`) |
465
472
  | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
466
473
  | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
467
- | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
474
+ | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens`, `model_segments` |
468
475
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
469
476
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
470
477
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
@@ -526,6 +533,10 @@ Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
526
533
  thread finishes for the old model is neither kept nor applied: #store_profile)
527
534
  → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
528
535
  → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
536
+ → the old model's ctx dropped: @metrics.forget_model_reports! (its window and profile reports) and
537
+ @last_context_status = nil; /model (SessionCommands#switch_model) then calls #announce_switched_context!
538
+ (the ctx % on the new model's window, before its first turn; Engine#session= does the configured-window-only
539
+ variant for a session loaded on another model; internals/context-telemetry.md)
529
540
  → the kernel is never rebuilt; the next turn's backend follows the new host's api
530
541
 
531
542
  Model probes (per turn):
@@ -149,7 +149,8 @@ is prefilled once. The native and the chat loop use the same plan.
149
149
 
150
150
  - The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
151
151
  server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
152
- request's prompt on the same model, what was prefilled again). The `http` line of a
152
+ request's prompt on the same model through the same host, what was prefilled again; a `/model` to the same bare
153
+ model on another host is the switch's prefill, not a re-prefill). The `http` line of a
153
154
  remote Claude or Messages request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
154
155
  - The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
155
156
  - `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
data/docs/memory.md CHANGED
@@ -199,7 +199,7 @@ step instead of fixing the skill.
199
199
  ## What a bundle owns
200
200
 
201
201
  A bundle owns the memory files it wrote, nothing else. `chi bundle install`
202
- copies each of its `*.md` files into the scope dir; a file of the same name
202
+ copies each of its memories into the scope dir; a file of the same name
203
203
  that is already there is left as it is and reported `Skipped` (`--force`
204
204
  overwrites it, and then the bundle owns it). A skipped file stays yours: the
205
205
  bundle doesn't record it, so `chi bundle upgrade` never merges into it and
@@ -207,6 +207,53 @@ bundle doesn't record it, so `chi bundle upgrade` never merges into it and
207
207
  for `chi bundle build` then installing the result on the same machine, and
208
208
  for a file another bundle installed.
209
209
 
210
+ - **Which files are memories.** A manifest whose `files:` lists any: exactly
211
+ those (a listed name the bundle doesn't ship gets a warning). Without a
212
+ list (no `files:`, or `files: {}`): every top-level `*.md` except a repo's
213
+ meta files: README, CHANGELOG, LICENSE (or LICENCE), CONTRIBUTING,
214
+ CODE_OF_CONDUCT and SECURITY, in any case. So a bundle kept in its own git
215
+ repo can have a `README.md` without it becoming a memory. A listed file is
216
+ installed whatever its name (`chi bundle build` of your own `security.md`
217
+ lists it). Since chi 0.75.0. Older chi installs every top-level `*.md`
218
+ whatever `requires_chi` says (it gates hooks, rules and the plugin, not
219
+ memories), so a bundle for older chi too names its readme
220
+ `README.markdown` (or puts docs in a subdirectory).
221
+ `chi bundle upgrade` moves a README an older install made a memory to the
222
+ trash (unless you edited it, like any file a bundle stops shipping).
223
+ - **A symlink is never installed.** A memory that is a symlink is skipped
224
+ with `notes.md is a symlink (not installed)`, and so is a hook or guardrail
225
+ rule that is one or lives in a linked dir (`hooks/x.rb is a symlink (not
226
+ installed)`); a plugin or script that is one fails the install. A tar, zip
227
+ or git repo keeps links, and copying through one would install whatever
228
+ file it points at (`~/.ssh/…`, an API key) as a memory the model reads.
229
+ - **A bundle is its name and its source.** A bundle's name is its
230
+ manifest's `name:`, for every kind of source (a git URL too), and a
231
+ manifest can take any name, so the source decides who installs under it:
232
+ - `chi bundle install` of a source whose manifest names a bundle
233
+ installed from another source is refused, `--force` too (`btw is
234
+ installed from <source>; chi bundle upgrade <new source> switches its
235
+ source, or chi bundle uninstall btw first`);
236
+ - `chi bundle upgrade` may switch the source (a git URL and a local copy
237
+ of it are one bundle; an archive's file name carries its version), and
238
+ its output starts with `Source changes: <old> → <new>` (`--dry-run`
239
+ too);
240
+ - a name chi ships (`guardrails`, `btw`, `samagotchi-system`, …, in any
241
+ case) installs or upgrades only from chi's own copy (a dir in the
242
+ running chi's shipped bundles, not a fork's `lib/samagotchi/bundles/`),
243
+ unless `--force` (`guardrails is a bundle shipped with chi; <source>
244
+ isn't chi's copy of it`);
245
+ - a name that differs from an installed bundle's only in case is refused,
246
+ `--force` too (`BTW differs only in case from the installed btw`): on a
247
+ case-insensitive file system (macOS) it is that bundle's dir.
248
+
249
+ Sources compare as a git URL without its `#ref` (or a trailing `/` or
250
+ `.git`), a shipped bundle by its dir in any chi version, and a path with
251
+ symlinks resolved. A git URL bundle chi 0.75.0 or earlier installed under
252
+ the repo's name (`chi-foo` for `name: foo`) isn't installed again beside
253
+ it: `chi bundle install|upgrade <url>` says `<url> is installed as
254
+ chi-foo: chi bundle uninstall chi-foo, then install it again`. That match
255
+ is by the same source, so a record of `git@github.com:x/foo` isn't found
256
+ from `https://github.com/x/foo` (uninstall it by name).
210
257
  - **The index says where a memory came from.** The `index.md` line of a file
211
258
  a bundle installed ends in `· from <bundle>`, before the description:
212
259
  `- **memory_guide** · system · 2026-10-03 · 5120 · from samagotchi-system —
data/docs/plugins.md CHANGED
@@ -15,11 +15,12 @@ my-bundle/
15
15
  manifest.yml
16
16
  plugin.rb
17
17
  identity.md # optional, like any bundle's memories, hooks/ and guardrails/
18
+ README.md # never a memory (nor CHANGELOG, LICENSE, ...): see memory.md#what-a-bundle-owns
18
19
  ```
19
20
 
20
21
  ```yaml
21
22
  # manifest.yml
22
- name: my-bundle
23
+ name: my-bundle # the bundle's name for every source (a git URL too): a plain name, no / (not . or ..)
23
24
  version: 1.0.0
24
25
  plugin:
25
26
  file: plugin.rb
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
@@ -30,6 +30,10 @@ can run the whole release; the user approves the notes before the tag and the
30
30
  spec checks every bundle is in exactly one), and that profile's version
31
31
  goes up: `chi update` then installs only the new bundle for those who
32
32
  have the profile;
33
+ - a profile's `requires_chi` is its members' highest: a member's raised
34
+ floor (by hand, or by `release:bump` for a `requires_chi_next` marker)
35
+ raises its profile's too, with the profile's version (`bundles:check`
36
+ notes a profile below a member);
33
37
  - the release notes say "run `chi update`" and name the bundles whose
34
38
  version moved (what changed in each), not per-bundle upgrade steps.
35
39
  - Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
@@ -55,7 +59,7 @@ as the GitHub release body.
55
59
  | Task | What it does |
56
60
  | --- | --- |
57
61
  | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
58
- | `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`. The release workflow and `release:check` run it in full. |
62
+ | `rake bundles:check` | Sha lines match; every top-level `*.md` of a bundle is listed in its `files:` (an install takes only the listed ones); a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`, and a profile (`core`, `dev`) whose `requires_chi` is below a member's. The release workflow and `release:check` run it in full. |
59
63
  | `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
60
64
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
61
65
  | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped). No commit. |