samagotchi 0.96.0 → 0.98.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +12 -2
  5. data/docs/bundle-authoring.md +7 -4
  6. data/docs/cli.md +13 -4
  7. data/docs/configuration.md +13 -0
  8. data/docs/hooks.md +59 -0
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +2 -2
  11. data/docs/internals/engine.md +21 -1
  12. data/docs/memory.md +43 -3
  13. data/docs/plugins.md +8 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/answer_refs.rb +16 -9
  16. data/lib/samagotchi/bundle_command/check.rb +5 -3
  17. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +75 -40
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/llm/errors.rb +135 -3
  27. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  28. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  29. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  30. data/lib/samagotchi/memory_command.rb +237 -0
  31. data/lib/samagotchi/output_formatter.rb +8 -2
  32. data/lib/samagotchi/pending_question.rb +7 -3
  33. data/lib/samagotchi/plugin/api.rb +12 -3
  34. data/lib/samagotchi/question_asked.rb +83 -0
  35. data/lib/samagotchi/question_desk.rb +3 -2
  36. data/lib/samagotchi/send_command.rb +11 -11
  37. data/lib/samagotchi/session_inbox.rb +29 -0
  38. data/lib/samagotchi/session_manager.rb +11 -0
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  40. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  41. data/lib/samagotchi/terminal_ui.rb +1 -0
  42. data/lib/samagotchi/testing/bundle.rb +18 -1
  43. data/lib/samagotchi/tools/memory.rb +1 -1
  44. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +30 -9
  47. data/lib/samagotchi/web/public/app.js +44 -11
  48. data/lib/samagotchi/web/public/index.html +36 -13
  49. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  50. data/lib/samagotchi/web/public/lists_view.js +32 -8
  51. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  52. data/lib/samagotchi/web/public/question_cards.js +23 -6
  53. data/lib/samagotchi/web/public/session_rename.js +88 -15
  54. data/lib/samagotchi/web/public/session_title.js +6 -0
  55. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  56. data/lib/samagotchi/web/public/stage_model.js +4 -2
  57. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  58. data/lib/samagotchi/web/public/stage_view.js +149 -8
  59. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  60. data/lib/samagotchi/web/public/turn_model.js +20 -0
  61. data/lib/samagotchi/web/public/turn_view.js +22 -9
  62. data/lib/samagotchi/worker.rb +19 -5
  63. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  64. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 919ca9857f9debe0c23587e3061cc7d7723966067a236054d06acc68bb811b35
4
- data.tar.gz: 3b5588e7c7de075d1dc7a7d84d57a39222ddd63342931aef4764a5422cc1959b
3
+ metadata.gz: d683d11a3567996b7eb36801581dfa374309207bda22d8bc7d7e4e7f94d1cf2f
4
+ data.tar.gz: 850af6daff806f5c56e4c5b9cd2294452360c891fca2690367111ff960c721fd
5
5
  SHA512:
6
- metadata.gz: 2928c6af604c2e9e33728949ae701d814ca61c510bec264004c56b00986c46ec6b2a23dfc449e832eed18246b33c57bc04b3d422e02dc3fe21667fd890b29fce
7
- data.tar.gz: 4171544e495614b7f7ff23ef92b484d67dc9aa34c3624b2ac7459d8bf1e9fd93b36fc7ca329ca11e1dead80da44a032146c617900484b934fc11f2d3c4fbf159
6
+ metadata.gz: c2272a6357f9fe281f36078facc23678c044b1942213bd4e881881c67fbd1ad2332aaf7f55d82cbe354ab558661b6e2fa4883044d7e07897508d6518f51b3a36
7
+ data.tar.gz: d4e99b1672e12be69e3c36b1b5e29b9a6b3ed1e68d17a7f308e8a33d6e7a41d5790c5b2ea552c796d335e0fba94ff540d3788389b91b252d81b141ba5e38b869
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.98.0] - 2026-10-21
12
+
13
+ ### Added
14
+
15
+ - A long question from the model gets room in the web stage: when its reply doesn't fit, the stage grows (up to 85%
16
+ of the panel) for the turn, the reply reads from its start in one scroll, and an "Answer ↓" pill leads to the card
17
+ while it is out of view. Approvals, the step limit and hooks' questions keep their layout.
18
+ - A new hook event, `:question_asked`, lets a plugin attach refs to the reply that asks a question or an approval
19
+ (display only, never in the prompt); the web pairs them in the asking text, on the card and in the history step.
20
+ A bundle manifest's hook can list `events: [...]` to run one hook for several events. See
21
+ [docs/hooks.md](docs/hooks.md).
22
+ - source-links 0.6.0 links the issue keys and paths in a waiting question's reply too, not only in answers.
23
+
24
+ ### Changed
25
+
26
+ - The web's rename ✎ moved from the info bar to the session's title: on the active card and next to the title in
27
+ the top bar, with the input over the title.
28
+ - A model server's 404 that lists its models puts `did you mean …?` ahead of the (cut) list, and names up to three
29
+ ids: every nearest typo on a tie, or the listed ids that contain the name asked for (`Qwen3.6-35B` →
30
+ `mlx-community--Qwen3.6-35B-A3B-4bit` or `…-8bit`).
31
+
32
+ ### Fixed
33
+
34
+ - A Gemma 4 step on the native loop no longer shows its tool call's body (`call:ask_user_question{…}`) as text in
35
+ the web history after a reload.
36
+
37
+ Update with `chi update` (the system bundle 0.98.0, source-links 0.6.0, the dev profile 0.7.1), then restart `chi web`.
38
+
39
+ ## [0.97.0] - 2026-10-21
40
+
41
+ ### Added
42
+
43
+ - The terminal's status row (the REPL's and an attached one's) shows the session's title first when it has one, and
44
+ follows `/title` from any client without a restart. See [docs/cli.md](docs/cli.md#status-line).
45
+ - A model server's 404 that lists the models it serves (oMLX's "Model 'X' not found. Available models: …") ends with
46
+ `did you mean '<id>'?` when one listed id is close to the one asked for: the same name under a prefix
47
+ (`mlx-community--…`, `vendor/…`), another case, or a typo. The listed ids are read past the message's cut.
48
+ - `chi memory ls` lists the memories of both scopes (or `--scope project|system`; `--format json`) with their size,
49
+ date, bundle, description and model overlays, and `chi memory rm NAME [--scope …]` removes one as `memory_write`
50
+ `remove: true` does: the file and its overlays go to the bundle trash, the index line is dropped, a bundle's memory
51
+ is refused. A profile's "kept your own" line now points at `chi memory rm`. See
52
+ [docs/memory.md](docs/memory.md#listing-and-removing-from-the-shell).
53
+
54
+ ### Fixed
55
+
56
+ - `chi bundle try` exits 130 for a Ctrl-C in the child chi's first moments too (while its Ruby was still booting it
57
+ exited 1).
58
+ - `chi bundle upgrade core --dry-run` (or `dev`) names the memory files of a member it would install that you already
59
+ have, which the install keeps as yours (`model-notes: kept your own …`), as the real run does; it named none.
60
+
61
+ Update with `chi update` (the system bundle 0.97.0), then restart `chi web`.
62
+
11
63
  ## [0.96.0] - 2026-10-21
12
64
 
13
65
  ### Added
@@ -3703,7 +3755,9 @@ and long-lived sessions.
3703
3755
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3704
3756
  hotkey panel that send selected text or the clipboard to your sessions.
3705
3757
 
3706
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.96.0...HEAD
3758
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.98.0...HEAD
3759
+ [0.98.0]: https://github.com/dm1try/samagotchi/compare/v0.97.0...v0.98.0
3760
+ [0.97.0]: https://github.com/dm1try/samagotchi/compare/v0.96.0...v0.97.0
3707
3761
  [0.96.0]: https://github.com/dm1try/samagotchi/compare/v0.95.0...v0.96.0
3708
3762
  [0.95.0]: https://github.com/dm1try/samagotchi/compare/v0.94.0...v0.95.0
3709
3763
  [0.94.0]: https://github.com/dm1try/samagotchi/compare/v0.93.0...v0.94.0
data/bin/chi CHANGED
@@ -61,6 +61,8 @@ SUBCOMMANDS = {
61
61
  "models" => ["samagotchi/models_command", "ModelsCommand"],
62
62
  # the native "Send to chi" helper
63
63
  "desktop" => ["samagotchi/desktop_command", "DesktopCommand"],
64
+ # list and remove memories (ls, rm; --scope, --format)
65
+ "memory" => ["samagotchi/memory_command", "MemoryCommand"],
64
66
  # install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
65
67
  "bundle" => ["samagotchi/bundle_command", "BundleCommand"]
66
68
  }.freeze
@@ -106,6 +108,7 @@ parser = OptionParser.new do |opts|
106
108
  chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
107
109
  chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
108
110
  chi answer ID --question QID --option N answer a question a session waits on
111
+ chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
109
112
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
110
113
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
111
114
  chi models [--format json] [TEXT] the models --model takes, from every host
data/docs/architecture.md CHANGED
@@ -339,6 +339,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
339
339
  deletes it, and the Bridge announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
340
340
  (a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
341
341
  unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
342
+ A worker's start prunes the `input/removed/` and `input/settled/` tombstones older than a week
343
+ (`SessionInbox::TOMBSTONE_MAX_AGE`, log `tombstones_pruned`); otherwise they go with the session dir.
342
344
  - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
343
345
  (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
344
346
  pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
@@ -419,7 +421,14 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
419
421
  finishes the ended turn's own line by its `turn_id` (sent as `&turn_id=`, which also picks that
420
422
  turn's answer). A tail read's timing is always the newest turn's; `recent=1` is accepted and not needed.
421
423
  The page binds an answer's `display_refs` to its bubble (`answer_refs.js`: on a reload's render and on this
422
- read's re-render) and pairs them with the bubble's links and text before the `path:line` scan. A file ref outside the
424
+ read's re-render) and pairs them with the bubble's links and text before the `path:line` scan. A question's
425
+ refs (`pending_question.refs`, `:question_asked`) pair the same way (`markdown_place.js` `linkRefsIn`/`pairRefsIn`):
426
+ `question_requested` (or a snapshot's pending question) keeps them on the asking step's gen
427
+ (`turn_model.js` `questionRefs`) before the card draws, so the stage's asking slot (`stage_slots.js`
428
+ `drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
429
+ question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
430
+ (`turn_view.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
431
+ the turn's end and after a reload. A file ref outside the
423
432
  session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
424
433
  session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
425
434
  file ref is dropped rather than shown unchecked.
@@ -505,7 +514,8 @@ Bridge's stream above): the chat loop's thinking is the
505
514
  server's `reasoning_content`; the raw-prompt loop splits each generation's
506
515
  stream with a fresh `ThoughtStreamSplitter`, seeded with the prompt's prefill (Qwen's `<think>` and Gemma's
507
516
  `<|channel>thought … <channel|>` are thinking, tool-call bodies are dropped
508
- from `text:`). The chat loop's model turns keep their `tool_calls` and tool results their `tool_call_id`,
517
+ from `text:`; a saved step's text, as the web history and the attached terminal's join show it,
518
+ drops both too: `OutputFormatter.strip_markup`). The chat loop's model turns keep their `tool_calls` and tool results their `tool_call_id`,
509
519
  so later requests and resumed sessions pair them, and their reasoning (`thinking:`), which goes back as
510
520
  `reasoning_content` on the running turn's replies (`reasoning_history`; a step-limit Continue's
511
521
  turn goes on from the run it resumes, `reasoning_since:`). It has its own system prompt
@@ -51,7 +51,8 @@ plugin:
51
51
  sha256: sha256:<hex> # `chi bundle check --fix` fills it; never compute it by hand
52
52
  # files: # memories it installs (top-level *.md); without files: every *.md but README & co.
53
53
  # my_notes.md: sha256:<hex>
54
- # hooks: # hooks/*.rb files (a plugin's chi.on is usually simpler)
54
+ # hooks: # hooks/*.rb files (a plugin's chi.on is usually simpler), each with event: NAME
55
+ # # or events: [a, b] (one instance for both; chi newer than 0.97.0)
55
56
  # needs: [gh] # outside commands it relies on
56
57
  ```
57
58
 
@@ -64,7 +65,7 @@ time. `chi bundle check --set KEY=VALUE` gives the sandboxed load a setting.
64
65
  ## What a hook sees
65
66
 
66
67
  `chi.on(event) { |event, ctx| … }` gets a Hash. The events, in a turn's order: `session_start`, `before_turn`,
67
- `before_generation`, `generation_progress`, `after_generation`, `before_tool_call`, `after_tool_call`,
68
+ `before_generation`, `generation_progress`, `after_generation`, `before_tool_call`, `question_asked`, `after_tool_call`,
68
69
  `after_turn`, `session_end` (the fields of each: [hooks.md, Hook Events](hooks.md#hook-events)). A symbol not in
69
70
  that list is never fired (`check` warns). The two a bundle wants most, for an `execute`:
70
71
 
@@ -116,7 +117,8 @@ end
116
117
 
117
118
  `bundle.tool_call` runs guarded hooks; `bundle.tool` calls a registered tool. `bundle.steers`, `cards`, `stops` and
118
119
  `questions` record effects; `bundle.answer` / `model_answers` provide `ask_user` / `ask_model` answers.
119
- `bundle.http.stub` stubs `ctx.http`; `sandbox.config`, `sandbox.fake_executable` and `bundle.install` cover setup.
120
+ `bundle.http.stub` stubs `ctx.http`; `bundle.question_asked(text, question:)` fires `:question_asked` and returns
121
+ the refs; `sandbox.config`, `sandbox.fake_executable` and `bundle.install` cover setup.
120
122
  The full list is in [plugins.md, Testing a bundle](plugins.md#testing-a-bundle-experimental). The kit is
121
123
  experimental (it may change before chi 1.0). If a test needs a fake of chi's own (a stub of a `Samagotchi::`
122
124
  class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather than reaching into chi.
@@ -127,7 +129,8 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
127
129
  of a checksum mismatch, and a line with no sha installs unchecked. `chi bundle check --fix` rewrites them; run it
128
130
  after the last edit.
129
131
  - **requires_chi.** Use the chi the user runs (`chi --version`). `check` warns when the plugin calls something
130
- newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0).
132
+ newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0, `ctx.sessions.children` 0.34.0). A keyword added
133
+ later it can't see: `ctx.ask_model(model:)` needs 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0.
131
134
  - **trust_level.** Leave it out. `reviewed` / `trusted` are for someone who reviewed the bundle, not its author.
132
135
  - **Restart.** A session loads bundles when it starts: after an install or a settings change, the user starts a
133
136
  new session (or `chi sessions restart ID`).
data/docs/cli.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
21
21
  - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
22
22
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). What a parent may pick follows `guardrails.parent_answers`, or the session's own `parent_answers` when stricter (`soft`, the default: a deny, a plain answer, Continue and "Allow once"; `standard`: no allow; `hard`: no Continue either), never a wider scope or an allow on chi's own config; a refused pick exits 1 with what to answer instead (a convention, not a security boundary; see [Guardrails](guardrails.md#answers-from-a-parent-agent))
23
+ - `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1 (see [Memory: Listing and removing from the shell](memory.md#listing-and-removing-from-the-shell))
23
24
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
24
25
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
25
26
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
@@ -658,8 +659,8 @@ the first message: …)`); `/title TEXT` names the session (whitespace collapsed
658
659
  cut at 80 characters), `/title --clear` drops the name (anything after `--clear`
659
660
  is refused with the usage line). It is saved at once,
660
661
  mid-turn too, and the lists, cards, top bar and tab show it in place of the first
661
- message; a continue's next link keeps it. The web's info bar has `✎` for it. See
662
- [Session titles](sessions.md#session-titles).
662
+ message; a continue's next link keeps it. In the web, `✎` next to the session's
663
+ title (its strip card, or the top bar) does it. See [Session titles](sessions.md#session-titles).
663
664
 
664
665
  A `/thinking`, `/reasoning-history` or `/toolkit` change that restarts the prompt cache
665
666
  asks first: the reply says what it costs, changes nothing, and names the line
@@ -894,7 +895,12 @@ nudged the model") show ahead of those calls for about 4 s, as their rows
894
895
  wait in the closed block. A card that needs you (a question, an approval, check-in)
895
896
  sits in the stage too. While a question or approval waits, the stage shows
896
897
  the whole text of the reply that asked it (in place of its newest
897
- sentence) above the card, until you answer. Once answered, the model's
898
+ sentence) above the card, until you answer. A long reply that asks the
899
+ model's own question (one that doesn't fit that slot) gets more room: the
900
+ stage grows to about 85% of the page for the rest of the turn (never above
901
+ the session panel), the reply reads from its start in the stage's one
902
+ scroll, and while the card is out of view a `❓ Answer ↓` pill at the
903
+ stage's bottom edge takes you to it. Once answered, the model's
898
904
  question or its own approval keeps that reply (a delegate's relayed
899
905
  approval, a hook's question and the step limit don't): two muted lines
900
906
  under its `question → answer` line, the whole text when you open the card,
@@ -1132,10 +1138,13 @@ The REPL and attached mode show one status row under the prompt, drawn when what
1132
1138
  changes:
1133
1139
 
1134
1140
  ```
1135
- status> model=Qwen3.6-35B | ↳ 3f2a1c9e | ctx=12.3% (under20) | mem: notes, cli_usage | muted: gh-helper
1141
+ status> Coordinator | model=Qwen3.6-35B | ↳ 3f2a1c9e | ctx=12.3% (under20) | mem: notes, cli_usage | muted: gh-helper
1136
1142
  status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | mem: skill_coordinator
1137
1143
  ```
1138
1144
 
1145
+ - The session's title first, when it has one (`/title`; not the first message a session without one is named by),
1146
+ cut to 32 characters and to a third of a narrow row. It follows `/title` from any client (this terminal, another
1147
+ one, the web's `✎`, `chi send`) without a restart: the worker's `command_ran` for it carries the new title.
1139
1148
  - `model=`: the model in use, `(default: …)` beside it when it isn't the config's default, and
1140
1149
  `model=<served> (served; asked <name>)` when the server said it served another model (not for one its host's
1141
1150
  `hosts.<name>.models.<id>.served` names; see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)).
@@ -187,6 +187,19 @@ An environment name used as a top-level key (`SAMAGOTCHI_DEFAULT_MODEL: my-model
187
187
  the old flat form) is not read: it warns as an unknown key with the nested one
188
188
  to use (`did you mean 'default.model'?`).
189
189
 
190
+ A few environment variables are not settings (no config.yml key, no flag):
191
+
192
+ - `SAMAGOTCHI_SKIP_SYSTEM_BUNDLE=1` (or `true`): don't install or upgrade the system bundle
193
+ (`samagotchi-system`) into the system memories; chi uses whatever is installed there. For isolated runs and
194
+ specs. `chi bundle try` drops it from its sandboxed child, so the sandbox gets the system bundle installed.
195
+ - `SAMAGOTCHI_BOOT_FALLBACK`: internal. A worker's boot line sets it when the newest installed chi failed to
196
+ load and the worker fell back to its starter's version; the worker reads and unsets it, and logs
197
+ `boot_fallback` with the error. See [Sessions](sessions.md) (Which chi a worker runs).
198
+ - `SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI=1`: internal. Set beside `SAMAGOTCHI_DEFAULT_MODEL` for a worker a
199
+ `chi --model X` spawned: X is that run's model, which the chi its commands run must not inherit.
200
+ - `SAMAGOTCHI_LOG_STRICT=1`: an unknown log tag or a malformed log event raises instead of being dropped. The spec
201
+ suite sets it, and a bundle's tests keep it (`Samagotchi::Testing::Sandbox`).
202
+
190
203
  ## Model Server API
191
204
 
192
205
  Chi talks to a model server over HTTP in one of two ways, set by the host's `api:`:
data/docs/hooks.md CHANGED
@@ -56,6 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
59
+ | `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the asking slot shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
59
60
  | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars; a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
60
61
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
62
 
@@ -366,6 +367,58 @@ end
366
367
 
367
368
  A plugin gets the same from `chi.on(:after_turn) { |event, ctx| event[:refs].call(…) }`.
368
369
 
370
+ ## Refs on a question (display only)
371
+
372
+ `:question_asked` carries `refs:` too, for the reply that asks the question: a
373
+ ticket the model names while it asks is a ref while the question waits, when
374
+ the user needs it.
375
+
376
+ ```ruby
377
+ class JiraQuestionRefs
378
+ def call(event)
379
+ return unless event[:type] == :question_asked
380
+
381
+ event[:text].scan(/\b[A-Z][A-Z0-9]+-\d+\b/).uniq.each do |key|
382
+ event[:refs].call(match: key, kind: "issue", target: { id: key }, label: key,
383
+ actions: [{ id: "open", label: "Open in Jira", url: "https://jira.example.com/browse/#{key}" }])
384
+ end
385
+ end
386
+ end
387
+ ```
388
+
389
+ - The call, its return values, the checks and the `refs_rejected` log are
390
+ [Refs on the answer](#refs-on-the-answer-display-only)'s, with lower caps:
391
+ 50 refs and 32 KiB per step. `nil`: the turn has no model step to attach to,
392
+ or the call came after the hooks ran.
393
+ - They are kept as `display_refs` on the step's model message (the one that
394
+ asks; in the session file after the turn, a cancelled one too) and go on the
395
+ pending question as its `refs` (the `question_requested` event, the session
396
+ file while it waits, the saved question card). A second question in the same
397
+ step adds to the step's list, and its pending question has the whole list.
398
+ The model never sees them.
399
+ - The web checks them again wherever a saved one reaches it (the pending
400
+ question, a saved card, the history), as an answer's, and pairs them as an
401
+ answer's (the three passes above) in: the stage's asking slot while the
402
+ question waits (its raw text, then again once its markdown is in), the
403
+ card's question text, the answered card's kept reply (raw, then its
404
+ markdown when opened; never its one-line summary copy), and that step's
405
+ text in the history, live (at the turn's end) and after a reload. A ref
406
+ whose `match` is only in the question's text pairs there alone; the step's
407
+ text drops it. **Terminals** ignore them.
408
+ - **Its time is the question's wait**: the hooks run before the question
409
+ opens, so a slow one delays it (and counts in `ask_user_question`'s
410
+ `elapsed_ms` and an approval's `waited_ms`). A hook that raises is logged and
411
+ the question opens with the other hooks' refs. With no hook on the event
412
+ nothing is built.
413
+ - Don't ask from it: `event[:ask_user]` (and `ctx.ask_user` in a `chi.on`
414
+ block) returns nil at once here.
415
+ - Needs chi newer than 0.97.0: set `requires_chi`.
416
+
417
+ A plugin gets the same from `chi.on(:question_asked) { |event, ctx| event[:refs].call(…) }`. The bundle test
418
+ kit fires it as chi does: `bundle.question_asked(text, kind:, question:)` returns the refs the step gets
419
+ ([plugins.md](plugins.md#testing-a-bundle-experimental)). The shipped source-links bundle subscribes (its
420
+ sources' refs in the reply that asks, and in the question's text and options).
421
+
369
422
  ## Settings
370
423
 
371
424
  A hook class whose `initialize` takes an argument gets its settings: **one
@@ -599,6 +652,12 @@ Notes:
599
652
 
600
653
  - Hook key = basename (flat under `hooks/`). No subdirs in v1.
601
654
  - `event` is required for auto-registration; a hook with no event is skipped.
655
+ - `events: [after_turn, question_asked]` (a list, in place of `event:`) registers the hook for each event it
656
+ names: **one** instance serves them all (its state is shared), and `#call` tells them apart by
657
+ `event[:type]`. `event:` and `events:` together, or an `events:` that isn't a non-empty list of event names,
658
+ is refused at install (`hooks: <file>: …`). `on_error: fail_closed` applies to its `before_tool_call`
659
+ registration only. Needs chi newer than 0.97.0 (an older chi installs the hook with no event and never loads
660
+ it): set `requires_chi`.
602
661
  - `sha256` is integrity (not authenticity). No signing in v1. Install records the sha256 of the copied file; at `Engine.new` a hook whose file differs is not loaded (reinstall the bundle after editing one by hand).
603
662
  - Hook code is the bundle author's source of truth: on upgrade, hooks are overwritten; if the installed file was locally modified, a warning is emitted (`was locally modified; overwriting`).
604
663
  - `on_error` defaults to `skip` for every event, `:before_tool_call` included: a raising hook is silent and the call goes ahead. `log` warns. `fail_closed` (only on `:before_tool_call`; on any other event it acts as `skip`) denies the call the hook raised on. Choose `fail_closed` for a hook that is a guardrail, whose failure should stop tool calls rather than let them through.
@@ -56,7 +56,7 @@ A task the model leaves running (it ends its turn: "I'll check when it's finishe
56
56
  - **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
57
57
  - **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
58
58
  - **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
59
- - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
59
+ - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
60
60
  - **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
61
61
 
62
62
  Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
@@ -37,9 +37,9 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
37
37
  | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
38
38
  | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
39
39
  | `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases, `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
40
- | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty) |
40
+ | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `BundleHook#events` is the manifest's `event:` or its `events:` list (written back as `event:` for one, as an older chi reads it); `Hooks::BundleLoader#load_hook_file` builds the hook once and registers it under each |
41
41
  | `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
42
- | `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`) |
42
+ | `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had, with other bytes (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`); a dry run runs each member's Installer as a dry run too, so it names them as well |
43
43
  | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
44
44
  | `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
45
45
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
@@ -527,10 +527,12 @@ Engine initialization (in this order, among other things):
527
527
  @event_routes (EventRoutes: where a card or a notice goes: held while the plugins load, an anytime command's, an init task's, a turn event, else announced or held)
528
528
  @hook_routes (HookRoutes: notices (through @event_routes), hook questions, stop_turn/stop_generation, steer attribution)
529
529
  @guardrail_wiring, @question_desk, @relay_desk (RelayDesk: a delegate's approval relay)
530
+ @thinking_notices (ThinkingNotices)
531
+ build_tools_and_plugins:
530
532
  @extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
531
533
  @tools = Tools::Builtins.registry; @command_registry (SessionCommands' built-ins); @plugin_tasks
532
534
  @toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
533
- @thinking_notices (ThinkingNotices); @tool_index = Tools::ToolIndex.new; @peer_sources = PeerSources.new
535
+ @tool_index = Tools::ToolIndex.new; @peer_sources = PeerSources.new
534
536
  the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins; each plugin's ctx is a
535
537
  Plugin::Context over plugin_host, whose ctx.sessions it builds itself: the test kit passes its own, sessions:),
536
538
  then (plugins or not) @tool_index.declare(@tools): find_tools + call_tool (source "index", not core),
@@ -592,6 +594,23 @@ logged). Both are display only: `AnswerDisplay::DISPLAY_ONLY` lists `display`, `
592
594
  after_generation), while `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps
593
595
  them across turns. `Web::App#messages_for_display` sends `display_refs` re-validated (`AnswerRefs.for_display`).
594
596
 
597
+ `:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
598
+ model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
599
+ `AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
600
+ (`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
601
+ relayed approval (`EnginePeers#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
602
+ fire it. `QuestionAsked.fire` is a no-op without a subscriber (`Registry#any?`); else it finds the live
603
+ conversation's last `role: "model"` entry (`Engine#live_conversation`, one lookup for both loops: a ChatLoop batch may
604
+ end in a tool result), builds an `AnswerRefs` on that step alone with the question caps (50 / 32 KiB), fires with the
605
+ step's text (`Engine#question_step_text`: the parser's thought and tool-call strip, then
606
+ `OutputFormatter.strip_markup`), a frozen copy of the fields and a no-op `ask_user`, then sets `display_refs` on
607
+ the step **in place** under the event lock (the loops and the forget layer hold entries by identity) and returns the
608
+ fields with `refs:`. `QuestionDesk#request` forwards `refs` past its slice; `PendingQuestion#refs` carries them to
609
+ `:question_requested`, the session file and the cards. `Web::App#with_question_refs` re-validates them (the pending
610
+ question, `cards_for_display`) with the same caps; the page pairs them in the asking slot, on the card and on the
611
+ step's text ([architecture.md](../architecture.md), the `display_refs` paragraph). The kit's
612
+ `Testing::Bundle#question_asked` calls `QuestionAsked.fire` with a one-step conversation.
613
+
595
614
  ### Hook lifecycle
596
615
 
597
616
  - **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`, from config.yml)
@@ -754,6 +773,7 @@ Paths are under `lib/samagotchi/`.
754
773
  | `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
755
774
  | `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
756
775
  | `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
776
+ | `question_asked.rb` | `QuestionAsked`: the `:question_asked` fire, the asking step's refs (`display_refs` in place, the pending question's `refs`) |
757
777
  | `question_desk.rb`, `pending_question.rb`, `question_answer.rb` | `QuestionDesk`: the one open question (ask, post, answer, cancel, relay mark); `PendingQuestion` and `QuestionAnswer`, its values (Hashes only in events, Bridge JSON, the session file and the hook/plugin `ask_user` result) |
758
778
  | `hook_question.rb` | `HookQuestion`: a hook's or plugin's `ask_user` question as one value, from the runtime/host edge to `QuestionDesk#ask_for_hook` |
759
779
  | `event_routes.rb` | `EventRoutes`: where a card (`Engine#show_card`), a notice (`HookRoutes#notify`) and a guardrails line (`#guardrail_notify`, turn or between turns only) go: held while the plugins load, an anytime command's, an init task's, a running turn's event, else announced or held for `holding_announcements` |
data/docs/memory.md CHANGED
@@ -18,8 +18,46 @@ Tool behavior:
18
18
  - `memory_write`'s `description` is the managed `index.md` line's text: one line (whitespace and newlines collapse to single spaces), at most 200 characters (longer is refused, since every session's prompt for the repo carries it). A missing or blank one keeps the line's description.
19
19
  - `memory_write` with `name`, `scope` and `description` but no `content` changes only that entry's index line (its size and date are refreshed; the date becomes today) and leaves the file as it is: a skill that keeps its status in a memory's description changes it without resending the body. The memory must exist in that scope; the form is refused for `index` and with `current_model_only`.
20
20
  - `memory_write` with `name`, `scope` and `remove: true` (no content, description or `current_model_only`) removes a memory: its file and its model overlays (`<name>.<key>.md`, not a dotted memory with an index line of its own) move into one dir of the bundle trash, `memory-<name>-<time>` under `memories/.bundles/.trash/`, which `chi bundle trash` lists and empties, and its index line is dropped. When the file is already gone (deleted by hand), the dangling line is dropped alone. A memory a bundle owns (an installed bundle's record lists it, or its line says `· from <bundle>`) is refused: `chi bundle uninstall` removes it. `index` can't be removed. The guardrails bundle's `memory-remove` rule asks before every removal, once at a time (see [Guardrails](guardrails.md#the-guardrails-bundle)).
21
+ - `chi memory ls` and `chi memory rm NAME` are the user's side of the same (see [Listing and removing from the shell](#listing-and-removing-from-the-shell)).
21
22
  - `write`/`edit` that change a `*.md` right in a scope dir refresh its managed `index.md` line (date, bytes; the description is kept), as `memory_write` does. A memory created with `write` gets a line with no description; any other `.md` written there gets a line too. Model overlays (`<name>.<key>.md` next to `<name>.md`), files in another project's folder and files changed by `execute` are left out.
22
23
 
24
+ ## Listing and removing from the shell
25
+
26
+ `chi memory ls [--scope project|system] [--format text|json]` lists the
27
+ memories of both scopes (this folder's project first, then system; `--scope`
28
+ one), by name: scope, name, size in bytes (right-aligned), date, `from <bundle>` for a
29
+ bundle's memory, and the index description (cut at 60 characters; `--format
30
+ json` has it all, with `path`, `source`, `overlays`, `indexed` and `file`).
31
+ It reads what `memory_read` does: each scope's `index.md` lines and its
32
+ `*.md` files. A model overlay isn't a row of its own: its memory's row ends
33
+ in `[overlays: <key>, …]`. A file with no index line says `[no index line]`
34
+ (its date is the file's), a line whose file was deleted by hand `[no file]`.
35
+
36
+ ```
37
+ project handoff 812 2026-10-21 Coordinator hand-off: where the work stands [overlays: qwen3-6-35b-a3b]
38
+ system identity 5120 2026-10-03 from samagotchi-system Who chi is and how it works with you
39
+ ```
40
+
41
+ `chi memory rm NAME [--scope project|system]` removes a memory exactly as
42
+ `memory_write` with `remove: true` does, and prints that line (the trash dir
43
+ it went to):
44
+
45
+ - the file and its model overlays (`NAME.<key>.md`) move into one dir of the
46
+ bundle trash, `memory-NAME-<time>`, which `chi bundle trash` lists and
47
+ empties; its index line is dropped (a dangling line alone, when the file is
48
+ already gone);
49
+ - without `--scope`, NAME must be in one scope: in both it exits 2 with
50
+ `chi memory rm: NAME is in both scopes: chi memory rm NAME --scope project, or --scope system`;
51
+ in neither, 1;
52
+ - a bundle's memory is refused (exit 1, `memory 'NAME' came from bundle B;
53
+ chi bundle uninstall B removes it`), as for `memory_write`: the bundle
54
+ owns it, and an upgrade would bring it back or merge into nothing;
55
+ - `NAME.<key>` removes that one model overlay and leaves its memory (a
56
+ bundle's overlay is refused like its memory);
57
+ - `index` can't be removed.
58
+
59
+ It isn't asked first: it is the user's command.
60
+
23
61
  ## Model-Specific Memory Overlays
24
62
 
25
63
  Each memory entry may have a companion file named `<name>.<model-key>.md` in the same scope directory. When the entry is read under a matching model, the overlay body is appended automatically, separated by the standard `---` separator with a `Model-specific guidance (<key>):` header.
@@ -118,10 +156,12 @@ Working habits for this model:
118
156
  (already exists; use --force to overwrite)`; through the `dev` profile
119
157
  (`chi bundle install dev`, `chi bundle upgrade dev`, `chi update`) it is
120
158
  `model-notes: kept your own model_notes_deepseek.md (not the bundle's; to
121
- take the bundle's, remove yours with memory_write remove: true, then chi
122
- bundle install model-notes)`.
159
+ take the bundle's, remove yours with chi memory rm model_notes_deepseek
160
+ --scope system, then chi bundle install model-notes)` (a dry run,
161
+ `chi bundle upgrade dev --dry-run`, names it too).
123
162
  - **To take the bundle's version instead,** remove your note
124
- (`memory_write` with `remove: true` moves it to the trash) and run
163
+ (`chi memory rm model_notes_deepseek --scope system`, or `memory_write`
164
+ with `remove: true`, moves it to the trash) and run
125
165
  `chi bundle install model-notes` again: the install writes the missing
126
166
  file and owns it from then on. Don't use `--force`: it overwrites every
127
167
  file of the bundle with no trash copy.
data/docs/plugins.md CHANGED
@@ -433,7 +433,9 @@ This is a bundle hook, the same as a `hooks/*.rb` file. See
433
433
  [hooks.md](hooks.md#hook-events) for the events; on `:after_turn`,
434
434
  `event[:present]` sets how the answer is shown in the web
435
435
  ([Presenting the answer](hooks.md#presenting-the-answer-display-only)) and `event[:refs]`
436
- attaches refs to it ([Refs on the answer](hooks.md#refs-on-the-answer-display-only)). Its label is
436
+ attaches refs to it ([Refs on the answer](hooks.md#refs-on-the-answer-display-only)); on
437
+ `:question_asked`, `event[:refs]` attaches refs to the reply that asks a question
438
+ ([Refs on a question](hooks.md#refs-on-a-question-display-only)). Its label is
437
439
  `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
438
440
  raises, the error is logged and the hook is skipped.
439
441
 
@@ -1159,6 +1161,7 @@ require "samagotchi/testing"
1159
1161
  bundle = Samagotchi::Testing::Bundle.load("path/to/my-bundle", settings: { "every" => 3 },
1160
1162
  host: { session_id: "s1", cwd: Dir.pwd })
1161
1163
  bundle.fire(:after_turn, status: "completed") # → the event, after the hooks
1164
+ bundle.question_asked("Fix JIRA-1?", question: { question: "Go?" }) # → the refs, as chi's seams fire it
1162
1165
  event = bundle.before_tool_call_event({ name: "read", path: "a.txt" }, params: "read a.txt")
1163
1166
  bundle.command("/mine stop") # → what the command returned
1164
1167
  bundle.tool("my_tool", path: "a.txt") # → the text the model reads
@@ -1212,6 +1215,10 @@ bundle.shutdown
1212
1215
  lays them over `settings:` through chi's own `Context#settings`; the plugin's `initialize(settings)` gets
1213
1216
  `settings:` only. `bundle.set(session_settings:)` moves the session to a new id (`test-session-2`, or the
1214
1217
  `session_id:` given beside it): chi never changes them within a session, and builds `ctx.settings` once per id.
1218
+ - **`question_asked(text, kind: "question", question: {}, cwd: nil)`** fires `:question_asked` through
1219
+ `QuestionAsked.fire`, as chi's two seams do: a step whose text is `text` asks a question (or `kind: "approval"`)
1220
+ with the `question` fields, and the refs the hooks attach come back checked (the question caps, a file ref under
1221
+ `cwd:`, default the session's folder), as the step and its pending question keep them; `[]` for none.
1215
1222
  - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1216
1223
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1217
1224
  - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
data/docs/sessions.md CHANGED
@@ -219,9 +219,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
219
219
 
220
220
  ## Session titles
221
221
 
222
- A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send ID "/title Coordinator"`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it (`title: none (shows the first message: hey chi …)` without one); `/title --clear` drops it, and the first message names the session again. A session whose first line was the command itself (`chi send --new "/title X"`) takes its preview from its first real message.
222
+ A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send -m "/title Coordinator" ID`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it (`title: none (shows the first message: hey chi …)` without one); `/title --clear` drops it, and the first message names the session again. A session whose first line was the command itself (`chi send --new -m "/title X"`) takes its preview from its first real message.
223
223
 
224
- The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search. The web's info bar has `✎` (Rename this session): a small input prefilled with the title; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent. A session a chi REPL owns has no `✎`. A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
224
+ The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search; the terminal's status row too (the REPL's and an attached one's, first, see [Status Line](cli.md#status-line)). In the web, `✎` (Rename this session) sits next to the session's title, on its active strip card or after the top bar's title: a small input over the title, prefilled with it; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent, and so does the title leaving the screen (All sessions opened, the card swiped out of a phone's strip). A session a chi REPL owns has no `✎`, nor one that shows no title yet (`/title` still works from the composer). A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
225
225
 
226
226
  ## Session chains
227
227
 
@@ -52,7 +52,9 @@ module Samagotchi
52
52
  #
53
53
  # The target is AnswerDisplay's: the stored conversation's last message
54
54
  # when it is the model's. The list starts from the refs it already has
55
- # (re-validated), so a later hook run never loses them.
55
+ # (re-validated), so a later hook run never loses them. QuestionAsked
56
+ # uses it too, on the step that asks a question (+messages+ that one
57
+ # step, lower caps).
56
58
  class AnswerRefs
57
59
  KEY = :display_refs
58
60
  MAX_REFS = 200
@@ -63,12 +65,13 @@ module Samagotchi
63
65
  # rebuilt and checked again, the caps applied; what isn't valid any
64
66
  # more is dropped. Never raises: a reload must show the conversation.
65
67
  # A relative file path is dropped (it was stored absolute), and with
66
- # +cwd+ (the session's folder) a file outside it.
68
+ # +cwd+ (the session's folder) a file outside it. +max_refs+ and
69
+ # +max_bytes+: the caps (a question's are lower, QuestionAsked).
67
70
  # @return [Array<Hash>] the refs to send ({match:, kind:, target:, …})
68
- def self.for_display(raw, cwd: nil)
71
+ def self.for_display(raw, cwd: nil, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
69
72
  return [] if raw.nil?
70
73
 
71
- kept = new([{ role: "model", KEY => raw }], cwd: cwd).to_a
74
+ kept = new([{ role: "model", KEY => raw }], cwd: cwd, max_refs: max_refs, max_bytes: max_bytes).to_a
72
75
  dropped = raw.is_a?(Array) ? raw.length - kept.length : 1
73
76
  Log.debug(:web, "display_refs_dropped", count: dropped) if dropped.positive?
74
77
  kept
@@ -81,10 +84,14 @@ module Samagotchi
81
84
 
82
85
  # @param messages [Array<Hash>] the conversation the turn stored
83
86
  # @param cwd [String, nil] the session's folder, for relative file paths
84
- def initialize(messages, cwd:)
87
+ # @param max_refs [Integer] the list's cap in refs
88
+ # @param max_bytes [Integer] the list's cap in JSON bytes
89
+ def initialize(messages, cwd:, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
85
90
  last = Array(messages).last
86
91
  @target = last if last.is_a?(Hash) && (last[:role] || last["role"]).to_s == "model"
87
92
  @roots = RowRefs.roots(cwd)
93
+ @max_refs = max_refs
94
+ @max_bytes = max_bytes
88
95
  @refs = []
89
96
  @seen = {}
90
97
  @bytes = 2 # the list's []
@@ -143,10 +150,10 @@ module Samagotchi
143
150
 
144
151
  entry = AnswerRef.from(value, roots: @roots).to_h
145
152
  return true if @seen.key?(entry)
146
- return over_cap(hook, "#{MAX_REFS} refs") if @refs.length >= MAX_REFS
153
+ return over_cap(hook, "#{@max_refs} refs") if @refs.length >= @max_refs
147
154
 
148
155
  size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
149
- return over_cap(hook, "#{MAX_BYTES / 1024} KiB") if @bytes + size > MAX_BYTES
156
+ return over_cap(hook, "#{@max_bytes / 1024} KiB") if @bytes + size > @max_bytes
150
157
 
151
158
  push(entry, size)
152
159
  @added = true
@@ -160,10 +167,10 @@ module Samagotchi
160
167
  # @return [Boolean] kept
161
168
  def keep(raw)
162
169
  entry = AnswerRef.from(raw, roots: @roots, relative: false).to_h
163
- return false if @seen.key?(entry) || @refs.length >= MAX_REFS
170
+ return false if @seen.key?(entry) || @refs.length >= @max_refs
164
171
 
165
172
  size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
166
- return false if @bytes + size > MAX_BYTES
173
+ return false if @bytes + size > @max_bytes
167
174
 
168
175
  push(entry, size)
169
176
  true