samagotchi 0.19.0 → 0.20.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +14 -3
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +5 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bundle_command.rb +22 -4
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  14. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  15. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/commands/registry.rb +4 -3
  21. data/lib/samagotchi/config.rb +3 -0
  22. data/lib/samagotchi/context_usage.rb +3 -3
  23. data/lib/samagotchi/engine.rb +16 -2
  24. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  25. data/lib/samagotchi/guardrails/approval.rb +4 -3
  26. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  27. data/lib/samagotchi/guardrails/context.rb +16 -9
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  30. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  31. data/lib/samagotchi/guardrails/rules.rb +69 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  33. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  34. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  35. data/lib/samagotchi/guardrails/targets.rb +65 -4
  36. data/lib/samagotchi/guardrails.rb +2 -0
  37. data/lib/samagotchi/hooks/registry.rb +6 -2
  38. data/lib/samagotchi/idle_recap.rb +7 -2
  39. data/lib/samagotchi/llm/usage.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  41. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  42. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  43. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  44. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  45. data/lib/samagotchi/memory_paths.rb +6 -0
  46. data/lib/samagotchi/self_report.rb +38 -2
  47. data/lib/samagotchi/session.rb +35 -6
  48. data/lib/samagotchi/session_commands.rb +13 -2
  49. data/lib/samagotchi/session_metrics.rb +224 -19
  50. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  51. data/lib/samagotchi/token_usage.rb +57 -41
  52. data/lib/samagotchi/tool_runner.rb +21 -2
  53. data/lib/samagotchi/tools/execute.rb +9 -8
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +19 -0
  56. data/lib/samagotchi/web/public/app.js +34 -15
  57. data/lib/samagotchi/web/public/card.js +34 -2
  58. data/lib/samagotchi/web/public/ctx.js +59 -0
  59. data/lib/samagotchi/web/public/format.js +11 -0
  60. data/lib/samagotchi/web/public/index.html +26 -2
  61. data/lib/samagotchi/web/public/model_pick.js +6 -2
  62. data/lib/samagotchi/web/public/model_picker.js +2 -1
  63. data/lib/samagotchi/web/public/question_card.js +2 -1
  64. data/lib/samagotchi/web/public/timing.js +6 -2
  65. data/lib/samagotchi/web/public/turn_events.js +1 -1
  66. data/lib/samagotchi/web/session_summary.rb +6 -2
  67. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b6bb51634467b04d3519fc2c42a778be2db25558565e8e872bc2fbdc17f5c76
4
- data.tar.gz: 9f1e4b1bd71272126a3dea346b2b0f5125a608770a97832b17c42ec4a237156b
3
+ metadata.gz: c3b1a6860a8c22ed75259438c755633283385c12ffbc28a8fcb4c817f59114c5
4
+ data.tar.gz: 24f7c45ebe336077bc2d9a1e670d3313b364c48d5b28aef3624593705236f29e
5
5
  SHA512:
6
- metadata.gz: b1b2eb7e4142061cce070830722bc88625cfc8b8301b97e836751ac9690d0e78984741afd747a6cc9ac42be6a0b59122bc49075273522629eda56f22327999d9
7
- data.tar.gz: 6277d5a520c55f5b620f79936321c2536739409ef98a5a7b530e41505a2ffb17bd0bf04987fc0bf027c09a24dbedbea29f61affca2bf7f3a74218bbcf8651df8
6
+ metadata.gz: 9f1d9dec24344b223170728f80629a76d1db18697f752b2ce7135cf3569428a902f1cc18589a928184f7809bcbe3a20289281cd4cae6324bdd3f5bb1192cf860
7
+ data.tar.gz: 8bf9a7cf6dbaac98a7583987be61d98e06ddd3ab6a0b6f36423ae41a6aa530ef42479fa28139ad89b8253e61edce1a57013dd27d0a9bcbf5b31c080cea6f8801
data/CHANGELOG.md CHANGED
@@ -8,6 +8,87 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.20.0] - 2026-10-03
12
+
13
+ ### Added
14
+
15
+ - `guardrails.mode` (config.yml only): `auto`, the new default, or `strict`. Rules can be tagged `modes: [strict]`
16
+ to vote only in strict mode; a rule without `modes:` (every rule you wrote in config.yml) votes in both.
17
+ `/guardrails` shows the mode and marks the rules it leaves out `(strict only)`. See docs/guardrails.md#modes.
18
+ - New guardrail rule keys: `skip_read_only: true` (a shell command that only reads, such as `ls`, `cat`, `rg`,
19
+ `sed -n` or `git log`, doesn't match), `touches: chi_dirs` (a shell command names a path in chi's real config,
20
+ hooks, approvals or bundles dir, or a `.git/hooks`) and `rm: outside_tmp` (an `rm -rf` reaches outside the tmp
21
+ dirs). A config.yml rule that uses one fails closed on an older chi.
22
+ - Model speed and token stats. `/stats` has a speed line (the last generation's decode speed and the
23
+ session's average: exact from llama.cpp, estimated and marked `~` elsewhere), cached and reasoning
24
+ tokens, and the cost when the provider reports it (OpenRouter). The web's info bar shows the speed
25
+ next to `ctx`, updated after each generation, and the `ctx` tooltip there and on session cards lists
26
+ tokens in / cached / out (+ reasoning) and the cost, for that session only.
27
+
28
+ ### Changed
29
+
30
+ - [BEHAVIOUR] Guardrails ask less by default (auto mode). The guardrails bundle (0.5.0) no longer asks before
31
+ `git rebase`, writes outside the session's repo (`write-outside-repo`) or git in another checkout
32
+ (`git-outside-repo`); `guardrails: { mode: strict }` in config.yml brings those back. Everything else still asks
33
+ in both modes, as do the small-model rules and chi's own protected paths.
34
+ - [BEHAVIOUR] `shell-touches-chi` matches chi's real folders instead of text, and lets read-only commands through:
35
+ `ls ~/.config/samagotchi`, `rg samagotchi/hooks lib` and a scratch copy under `/tmp` no longer ask; `echo x >>
36
+ <config dir>/config.yml` and `cp hook .git/hooks/pre-commit` still do. A word it can't resolve falls back to the
37
+ old text match.
38
+ - [BEHAVIOUR] `rm-rf-wide` no longer asks when every `rm -rf` target is inside a tmp dir (`rm -rf /tmp/x`);
39
+ `/tmp` itself, `/tmp/*`, a `$VAR` and a link out of tmp still ask.
40
+ - [BEHAVIOUR] "Allow … in this repo" approvals hold for the whole repository, in every worktree of it, and the
41
+ ask names the repository (`in this repo (samagotchi)`) instead of the worktree folder. Approvals stored
42
+ before keep matching their exact folder.
43
+ - `chi bundle status` shows a bundle whose recorded scope this chi doesn't know (one a newer chi wrote, or a hand
44
+ edit) as `scope=team (unknown)`, counted as one issue, instead of checking its files in the system memories;
45
+ the shipped-bundle update skips it (upgrade chi or reinstall), and `chi bundle diff` refuses it with a one-line
46
+ error instead of a stack trace.
47
+ - The web model picker shows a model's configured sampling (`temperature=0.6 (hosts.work)`) as its row's tooltip;
48
+ `GET /api/models` has it as `sampling` on the models that have some.
49
+ - The web shows a plugin card that is only a notice (info level, no buttons, one short line, such as check-in's
50
+ "Nudged the model at 105 tool calls.") as a one-line row, `▸ check-in: Nudged …`, that opens on a click, like the
51
+ tool rows and resolved questions; warn cards, cards with buttons and longer ones stay framed cards.
52
+ - On a phone (a window under 900 px wide) the sessions strip's `hide` is in the top bar, where the `▾ N sessions`
53
+ pill comes back, with a finger-sized target, instead of the last column of the sideways-scrolling strip.
54
+ - On a phone the web info bar shows `mem N` (the memories the session used; their names in the tooltip) after the
55
+ session's status, since the `mem: …` line is hidden there.
56
+ - The web info bar's chips (model, ctx, speed, session time) are tinted over the card's glass instead of solid, and
57
+ the empty-history cover is see-through like the session panel.
58
+
59
+ ### Fixed
60
+
61
+ - An MCP server that exits without a wait status no longer raises `NoMethodError`; its waiting calls fail with
62
+ "the server exited" instead of hanging.
63
+ - A hook that raises is now logged (`hook_failed`) instead of being swallowed silently; the turn still goes on.
64
+ - A hook's card (check-in, skills) now prints under its tool call's row: `tool_call_completed` is emitted before
65
+ `after_tool_call` fires.
66
+ - When a hook corrects a tool call (known-names), the model is told what actually ran with a leading
67
+ `ran as: <tool> <args>` line.
68
+ - MCP image content is described with a neutral `[image N: …]` line instead of claiming it was attached, which
69
+ was wrong for a model that can't see images.
70
+ - An MCP `resource` block carrying an image `blob` is now attached like an `image` block instead of being
71
+ dropped to its URI text.
72
+ - check-in's `nudge` mode no longer says "nudged" when the steer was dropped later; it says "nudge not sent" at
73
+ the turn's end.
74
+ - A session started with an annotation (a web quote) is previewed by the note typed under the quote, not
75
+ `From your thinking: > …`; a quote with no note previews by its words.
76
+ - A session that has only context notes so far (`chi note`, `send_note`) is previewed on its card and in the
77
+ lists as `note: …` instead of `—`, until a message is typed.
78
+ - A web turn of images alone names each image once in the transcript a recap is written from, not twice.
79
+ - `chi bootstrap` no longer says "loading the model?" for a slow test on a remote provider (OpenRouter and the
80
+ like); the hint is for a local server, where a slow answer is a model loading.
81
+ - `chi bootstrap` says why it saved a host as `name-2` when the derived name is already taken (or a model id
82
+ starts with it), instead of silently picking another name.
83
+ - `chi bundle upgrade` reports a hook or plugin file that is byte-identical to the installed one as "already up
84
+ to date" instead of "Updated".
85
+ - `chi bundle upgrade --agent` from a git/zip/tar source no longer hands the agent an `incoming:` path that was
86
+ already deleted: the extracted source is kept until the agent step is done, then cleaned up.
87
+ - `chi self` now checks a local `api: openai` host's reachability (a `GET <base>/models` with the same short
88
+ probe timeouts) and reports up/down with the ids it serves, instead of always saying "reported per turn".
89
+
90
+ Update with `chi update`: guardrails moves to 0.5.0 (auto mode and the new rule keys; it needs chi 0.20.0), mcp to 0.4.1 (exit status, image lines, image blobs) and check-in to 0.2.1 (a dropped nudge says so). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
91
+
11
92
  ## [0.19.0] - 2026-10-03
12
93
 
13
94
  ### Changed
@@ -1118,7 +1199,8 @@ and long-lived sessions.
1118
1199
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1119
1200
  hotkey panel that send selected text or the clipboard to your sessions.
1120
1201
 
1121
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.19.0...HEAD
1202
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.20.0...HEAD
1203
+ [0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
1122
1204
  [0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
1123
1205
  [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
1124
1206
  [0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
data/docs/architecture.md CHANGED
@@ -274,6 +274,12 @@ The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` fr
274
274
  newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
275
275
  calls and the running turn's finished ones. The full history is the session's `analytics.json`,
276
276
  which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
277
+ The totals' `tokens` block adds the cached and reasoning sums, the cost, the decode time and tokens
278
+ behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
279
+ `last_prefill_tps`, `tps_source`: `server` or `estimate`). Each `generation_completed` event
280
+ carries the generation's `speed` (`{decode_tps, source}`, null without one) and those running
281
+ `tokens`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
282
+ it, so the web's info bar updates per generation without parsing chunk payloads.
277
283
 
278
284
  The page's frequent reads of `GET /api/sessions/:id` stay light too:
279
285
  - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
data/docs/cli.md CHANGED
@@ -53,7 +53,9 @@ chi bootstrap # try localhost 8080, 11434, 1234, 8000
53
53
  - **The file.** With no config.yml it writes a small commented one:
54
54
  `default.model` as `<host>:<model>` and one `hosts:` entry named `local`
55
55
  (localhost), `lan` (an IP) or after the domain (`openrouter`); `--name`
56
- sets it. An existing file is left as it is apart from the new entry, added
56
+ sets it. When that name is taken (or a model id starts with it), chi picks
57
+ `name-2` and says why, with `--name` to choose another. An existing file is
58
+ left as it is apart from the new entry, added
57
59
  at the end of its `hosts:` block (it gets a `hosts:` block, with a
58
60
  `default` entry for its `server:` first, when it has none), after a backup
59
61
  to `config.yml.bak-<time>`. `default.model` is set only when the file has
@@ -413,8 +415,17 @@ file and index line stay as they are; only this session doesn't see it.
413
415
  `/help` lists the commands this session takes, the installed bundles' too, each
414
416
  with a line on what it does. `/stats` shows the session's numbers: turns, tool
415
417
  calls (by tool, with errors), iterations, tokens in/out summed over every
416
- request, generation latency, cancellations, retries, context used and window,
417
- the prompt profile and the model the server says it ran. `/recap` is in
418
+ request (with the cached and reasoning tokens when the server reports them),
419
+ the decode speed, generation latency, cancellations, retries, context used
420
+ and window, the prompt profile and the model the server says it ran, and the
421
+ cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
422
+ guessed). The speed line is the last generation's and the session's average
423
+ (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
424
+ prompt (last, server)`); for other servers it is estimated from the first
425
+ streamed chunk to the end and marked `~` (`~64 tok/s out (last, estimate)`).
426
+ Generations under 8 tokens or 100 ms get no speed of their own, nor does a
427
+ reply without server token counts or a non-streamed one. A delegated child
428
+ keeps its own numbers: a parent's tokens and cost leave them out. `/recap` is in
418
429
  [Session recap](#session-recap), `/model` and `/models` in [Runtime Model
419
430
  Switch](#runtime-model-switch-assist-mode).
420
431
 
@@ -243,7 +243,9 @@ or ending in `localhost`, `.local`, `.lan`, `.home`, `.home.arpa`, `.internal`,
243
243
  or `.ts.net` (Tailscale) is local: chi doesn't look names up. A remote
244
244
  host keeps its model list for 10 minutes, gets a 120-second first-token limit,
245
245
  and isn't asked for llama.cpp's `/props` (its context window and served model
246
- come from its model list and each turn). `remote: true` or `remote: false` on a
246
+ come from its model list and each turn). A local `api: openai` host has no
247
+ `/props`, so `chi self` GETs its `/models` with the same short probe timeouts
248
+ and reports up/down with the ids it serves. `remote: true` or `remote: false` on a
247
249
  host decides it instead, e.g. for a llama.cpp behind an https proxy on the LAN:
248
250
 
249
251
  ```yaml
@@ -852,6 +854,7 @@ described in their own sections.
852
854
  | `image.max_bytes` | `3750000` | | See "Images". |
853
855
  | `image.max_per_request` | `20` | | See "Images". |
854
856
  | `guardrails.enabled` | `true` | | `false`: no rules, and hooks' asks are dropped (a deny still applies). config.yml only: a worker unsets any `SAMAGOTCHI_GUARDRAILS_*` it inherits. See [Guardrails](guardrails.md). |
857
+ | `guardrails.mode` | `auto` | | Which rules vote: `auto` leaves out the rules tagged `modes: [strict]` (in the guardrails bundle: `git rebase`, writes and git outside the session's repo); `strict` runs them all. An unknown value warns and is `auto`. config.yml only. See [Guardrails](guardrails.md#modes). |
855
858
  | `guardrails.small_models` | `auto` | | Which models get the `models: small` rules: `auto` (32B or less by the size in the name, an MoE's active size), a list of globs, or `[]`. config.yml only. See [Guardrails](guardrails.md#rules-for-some-models). |
856
859
  | `guardrails.parent_approvals` | `off` | | What `chi answer` lets a parent agent allow on an approval: `off` (deny only) or `once` ("Allow once", never a wider scope). config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
857
860
  | `log.file` | state dir | yes | See "Debug Log File". |
data/docs/guardrails.md CHANGED
@@ -81,6 +81,13 @@ Allowing beyond "once" is stored in `$XDG_STATE_HOME/samagotchi/guardrails/appro
81
81
  | repo | this exact call in this repo (the cwd outside a repo), any session |
82
82
  | rule | anything this rule asks about in this repo |
83
83
 
84
+ "This repo" is the git repository, not the folder: an approval given in one
85
+ worktree holds in the main checkout and every other worktree of it (it is
86
+ stored with the repository's common git dir as `repo`, and the worktree as
87
+ `repo_root`). The ask names the repository (`Allow this call in this repo
88
+ (samagotchi)`). An entry stored before chi 0.20 has no `repo` and still only
89
+ matches its exact folder.
90
+
84
91
  A stored approval only relaxes an ask; a deny rule is never approvable.
85
92
  A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
86
93
  with one warning, and chi starts with no stored approvals (more asks, nothing lost).
@@ -137,7 +144,7 @@ grants no permission, so a parent may answer it either way by default.
137
144
  `turn.parent_continue: false` (config.yml only, like `parent_approvals`)
138
145
  makes parents stop-only. See [chi as a sub-agent](sub-agent.md#the-step-limit).
139
146
 
140
- The guardrails settings (`enabled`, `small_models`, `parent_approvals`) are
147
+ The guardrails settings (`enabled`, `mode`, `small_models`, `parent_approvals`) are
141
148
  read from config.yml only: they have no environment variable, and a worker
142
149
  chi starts unsets every `SAMAGOTCHI_GUARDRAILS_*` it would inherit. What a
143
150
  parent's environment does choose is the config dir: `XDG_CONFIG_HOME` picks
@@ -182,6 +189,7 @@ commands that name them.
182
189
  ```yaml
183
190
  guardrails:
184
191
  enabled: true # false: no rules, and hooks' asks are dropped (a deny still applies); config.yml only
192
+ mode: auto # or strict: also the rules tagged modes: [strict]; config.yml only
185
193
  rules:
186
194
  - id: git-push
187
195
  tool: shell # execute + task_create; or a tool name, a glob, or a list
@@ -201,7 +209,41 @@ guardrails:
201
209
  scopes: [once, session, rule]
202
210
  ```
203
211
 
204
- A rule gives at least one of `tool`, `command`, `path` and `git`.
212
+ A rule gives at least one of `tool`, `command`, `path`, `git`, `touches` and `rm`.
213
+
214
+ `rm: outside_tmp` (shell tools) matches a command whose `rm -r -f` reaches
215
+ outside the tmp folders (`$TMPDIR`, `/tmp`, `/var/tmp`): every such `rm` in a
216
+ chain must name only paths inside one, resolved like `touches` (so
217
+ `/tmp/../etc` and a link from `/tmp` to `/` are outside). The tmp folder
218
+ itself, a glob (`/tmp/*`), a `$VAR` other than `$HOME`/`$TMPDIR`, an `rm`
219
+ behind `sudo`/`xargs`/`sh -c` or in `$(…)`, and the tmp folder the session's
220
+ repo is in count as outside.
221
+
222
+ `touches: chi_dirs` (shell tools) matches a command that names a path in chi's
223
+ own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
224
+ the approval store, installed bundles (`memories/.bundles`), the session repo's
225
+ git hooks (`core.hooksPath` honoured) or any `.git/hooks` outside a tmp folder.
226
+ Each word is read as a path against the folder the command is in by then
227
+ (`cd`, `pushd`/`popd` and `( … )` followed), with `~`, `$HOME` and
228
+ `$XDG_CONFIG_HOME`/`$XDG_STATE_HOME` expanded and symlinks resolved. So
229
+ `/tmp/x/config/samagotchi/config.yml` and chi's own source
230
+ (`lib/samagotchi/hooks/…`) don't match. A word it can't resolve (another
231
+ `$VAR`, `$(…)`, a glob, a relative path after `cd $X`) or one that reads as a
232
+ script (`sh -c '…'`, `ruby -e '…'`) is matched as text instead (`.config/samagotchi`,
233
+ `samagotchi/config.yml`, `samagotchi/hooks`, `samagotchi/guardrails`,
234
+ `memories/.bundles`, `.git/hooks`).
235
+
236
+ `skip_read_only: true` lets a shell command through when it only reads:
237
+ every command in it (split at `;`, `&&`, `||`, `|`) is `ls`, `cat`, `head`,
238
+ `tail`, `wc`, `stat`, `du`, `grep`, `rg`, `find`, `tree`, `file`, `sort`,
239
+ `uniq`, `cut`, `diff`, `echo`, `cd`, `sed`, `awk` or a reading `git`
240
+ (`log`, `show`, `diff`, `status`, `branch` listing, …), with no option that
241
+ writes or runs something (`sed -i`, `sed 's/a/b/w f'`, `find -delete`/`-exec`,
242
+ `rg --pre`, `sort -o`, `awk` with `>`/`|`/`system(`, `git -c`, `git diff
243
+ --ext-diff`, …) and no redirection other than to `/dev/null` or another fd.
244
+ `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
245
+ at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
246
+ like make a command not read-only. It errs towards asking.
205
247
 
206
248
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
207
249
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
@@ -303,6 +345,38 @@ once per bundle version to restart the session (`chi sessions stop ID`, then
303
345
  older than the bundle) denies every tool call, as for a rule that doesn't
304
346
  parse, until chi is updated or the bundle uninstalled.
305
347
 
348
+ ### Modes
349
+
350
+ `guardrails.mode` (config.yml only) picks how much is asked:
351
+
352
+ - `auto`, the default: the rules tagged `modes: [strict]` don't vote. Asks
353
+ are kept for what is hard to undo or reaches chi itself.
354
+ - `strict`: every rule votes.
355
+
356
+ ```yaml
357
+ guardrails:
358
+ mode: strict
359
+ rules:
360
+ - id: no-docker
361
+ tool: shell
362
+ command: '\bdocker\b'
363
+ modes: [strict] # or auto, or [auto, strict] (the same as leaving it out)
364
+ verdict: ask
365
+ ```
366
+
367
+ A rule without `modes:` votes in every mode, so a rule you add to config.yml
368
+ always applies unless you tag it. The core checks (protected paths, hooks)
369
+ vote in both modes. In the guardrails bundle, `git-rebase`,
370
+ `write-outside-repo` and `git-outside-repo` are strict only; its small-model
371
+ rules (`models: small`) are untagged, so they ask a small model in both modes.
372
+ `/guardrails` shows the mode on its first lines and marks the rules this mode
373
+ leaves out `(strict only)`.
374
+
375
+ A rule in config.yml that uses a key this chi doesn't know yet (`modes:`,
376
+ `touches:`, `rm:`, `skip_read_only:` before 0.20) doesn't parse, and a rule
377
+ that doesn't parse denies every tool call (see Failing closed). Bundles say
378
+ which chi they need (`requires_chi`), so their rules don't hit this.
379
+
306
380
  ## The guardrails bundle
307
381
 
308
382
  ```sh
@@ -310,18 +384,22 @@ chi bundle install guardrails # chi bootstrap installs it, with the core prof
310
384
  ```
311
385
 
312
386
  installs a default rule set plus a short memory telling the model not to
313
- route around a deny. It asks before `git push`, `reset --hard`, `clean -f`,
314
- `branch -D`, `rebase`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
315
- `$HOME` or `..` paths; `curl … | sh` and `base64 -d … | sh`; writes outside the
316
- session's repo (`write-outside-repo`) and git that changes another checkout
317
- (`git-outside-repo`: `cd ../main && git commit`, `git -C ../main add .`; it
318
- offers once, this session and "rule in this repo", the last stored for later
319
- sessions in that repo); shell commands that name chi's config, hooks or guardrails or
320
- `.git/hooks`; and answering chi's questions around `chi answer`: `chi --attach`
321
- or `chi -p` with stdin from a pipe, a here-string or a file
322
- (`chi-answer-piped`), and `curl`/`wget` to a session's `/answer` route
323
- (`chi-answer-http`), both once or for the session. `chi answer` itself isn't
324
- asked about. It denies writes into `.git/hooks`. The rules are in
387
+ route around a deny. In both modes it asks before `git push`, `reset --hard`,
388
+ `clean -f`, `branch -D`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
389
+ `$HOME` or `..` paths, unless every target is inside a tmp folder; `curl … |
390
+ sh` and `base64 -d … | sh`; shell commands that name a path in chi's config,
391
+ hooks, approvals or bundles or a `.git/hooks` (`shell-touches-chi`, `touches:
392
+ chi_dirs`), unless they only read (`skip_read_only`); and answering chi's
393
+ questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
394
+ pipe, a here-string or a file (`chi-answer-piped`), and `curl`/`wget` to a
395
+ session's `/answer` route (`chi-answer-http`), both once or for the session.
396
+ `chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
397
+
398
+ In strict mode (`guardrails: { mode: strict }`) it also asks before `git
399
+ rebase`, writes outside the session's repo (`write-outside-repo`) and git that
400
+ changes another checkout (`git-outside-repo`: `cd ../main && git commit`, `git
401
+ -C ../main add .`; it offers once, this session and "rule in this repo", the
402
+ last stored for later sessions in that repo). The rules are in
325
403
  `lib/samagotchi/bundles/guardrails/guardrails/rules.yml`.
326
404
 
327
405
  Small models (`models: small`, see above) get two more asks, in
data/docs/plugins.md CHANGED
@@ -524,6 +524,11 @@ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces
524
524
  its line; the web shows a bubble only when the command answers with
525
525
  text or fails.
526
526
  - `level:` is `:info` or `:warn` (the warning colour).
527
+ - An `:info` card with no actions and a body of one short line (up to 160
528
+ characters) is a notice: the web shows it as a one-line row,
529
+ `▸ check-in: Nudged the model at 105 tool calls.`, that opens on a click,
530
+ like a resolved question. A warn card, one with actions and one with more
531
+ to read stay framed cards.
527
532
  - `id:` names an earlier card to replace. Without one a new id is made. The
528
533
  web updates the card in place; the terminal prints it again, marked
529
534
  `(updated)`. A card that waits for something (a model's answer) shows
data/docs/sessions.md CHANGED
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
68
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
68
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
69
69
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
70
70
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
@@ -95,20 +95,31 @@ module Samagotchi
95
95
  # +base+, with -2, -3… when the name is taken or is the prefix of a
96
96
  # model id (chi would read "qwen3:8b" as host qwen3, model 8b).
97
97
  def host_name(base, requested: nil, model_ids: [])
98
+ host_name_with_reason(base, requested: requested, model_ids: model_ids).first
99
+ end
100
+
101
+ # [name, reason]: reason is :taken when the name was suffixed because
102
+ # config.yml already has it, :model_prefix when a model id's prefix
103
+ # would read as a host, nil when the name is free as given.
104
+ def host_name_with_reason(base, requested: nil, model_ids: [])
98
105
  taken = current_hosts.keys
99
106
  if requested
100
107
  name = requested.to_s.strip
101
108
  raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
102
109
  raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
103
110
 
104
- return name
111
+ return [name, nil]
105
112
  end
106
113
 
107
114
  prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
108
115
  name = base
109
116
  n = 1
110
- name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
111
- name
117
+ reason = nil
118
+ while taken.include?(name) || prefixes.include?(name)
119
+ reason ||= taken.include?(name) ? :taken : :model_prefix
120
+ name = "#{base}-#{n += 1}"
121
+ end
122
+ [name, reason]
112
123
  end
113
124
 
114
125
  # Write the entry. @param fields [Hash] the hosts entry (string keys,
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
+ require_relative "host_registry"
6
7
  require_relative "bootstrap/probe"
7
8
  require_relative "bootstrap/config_writer"
8
9
  require_relative "bootstrap/bundles"
@@ -235,9 +236,11 @@ module Samagotchi
235
236
 
236
237
  def test(result, model, key_env)
237
238
  done = Queue.new
238
- waiting = Thread.new do
239
- @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
240
- end
239
+ waiting = if loading_hint?(result)
240
+ Thread.new do
241
+ @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
242
+ end
243
+ end
241
244
  seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
242
245
  @stdout.puts(format("test: answered in %.1f s", seconds))
243
246
  true
@@ -249,17 +252,25 @@ module Samagotchi
249
252
  waiting&.join
250
253
  end
251
254
 
255
+ # The "loading the model?" hint fits a local server (a llama.cpp loading
256
+ # weights); a remote provider's slow answer is the network, not a load.
257
+ def loading_hint?(result)
258
+ !HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
259
+ end
260
+
252
261
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
253
262
  def write(result, model, key_env, options)
254
263
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
255
264
  fields = host_fields(result.candidate, result.native?, key_env)
256
265
  ids = result.models.map(&:id)
257
- name = begin
258
- writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
266
+ base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
267
+ name, reason = begin
268
+ writer.host_name_with_reason(base, requested: options[:name], model_ids: ids)
259
269
  rescue Bootstrap::ConfigWriter::Error => e
260
270
  @exit = usage_error(e.message)
261
271
  return nil
262
272
  end
273
+ explain_name(base, name, reason)
263
274
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
264
275
  code = report(outcome, model)
265
276
  return code unless INSTALLS_BUNDLES.include?(outcome.kind)
@@ -269,6 +280,16 @@ module Samagotchi
269
280
  code
270
281
  end
271
282
 
283
+ # One line when the name wasn't free as derived: why chi picked another.
284
+ def explain_name(base, name, reason)
285
+ case reason
286
+ when :taken
287
+ @stdout.puts("a host named #{base} already exists; saved as #{name}, use --name to choose")
288
+ when :model_prefix
289
+ @stdout.puts("a model id starts with #{base} (chi would read it as a host); saved as #{name}, use --name to choose")
290
+ end
291
+ end
292
+
272
293
  # The system bundle, core, and dev when a terminal says yes: one line
273
294
  # each. 1 when something failed.
274
295
  def install_bundles(dry_run: false)
@@ -263,6 +263,10 @@ module Samagotchi
263
263
  @stdout.puts "Provenance updated after interactive resolution."
264
264
  @stdout.puts "Upgrade resolved interactively."
265
265
  0
266
+ ensure
267
+ # An owned source (git/zip/tar) was kept for the incoming files above;
268
+ # the agent step is done with them now.
269
+ installer.cleanup_source!
266
270
  end
267
271
 
268
272
  def uninstall(rest)
@@ -353,9 +357,15 @@ module Samagotchi
353
357
  @stdout.puts "Bundle '#{name}' not installed."
354
358
  return 0
355
359
  end
356
- @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
357
- @stdout.puts "Target: #{st[:target_dir]}"
360
+ @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{status_scope(st)} installed=#{st[:provenance][:installed_at]}"
361
+ if st[:scope_error]
362
+ @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
363
+ else
364
+ @stdout.puts "Target: #{st[:target_dir]}"
365
+ end
358
366
  st[:files].each do |k, info|
367
+ next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
368
+
359
369
  mods = []
360
370
  mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
361
371
  mods << "modified" if info[:modified]
@@ -409,15 +419,19 @@ module Samagotchi
409
419
  plugin = st[:plugin]
410
420
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
411
421
  mods += 1 if st[:hooks_requires_failure]
422
+ mods += 1 if st[:scope_error]
412
423
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
413
424
  includes = st[:provenance][:includes]
414
425
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
415
- @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
426
+ @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
416
427
  end
417
428
  end
418
429
  return 0
419
430
  end
420
431
 
432
+ # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
433
+ def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
434
+
421
435
  def diff(rest)
422
436
  args = rest.reject { |a| a.start_with?("--") }
423
437
  bname = args[0]
@@ -431,7 +445,11 @@ module Samagotchi
431
445
  @stderr.puts "Bundle '#{bname}' not installed"; return 1
432
446
  end
433
447
  scope = data[:scope]&.to_s || "system"
434
- target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
448
+ begin
449
+ target_dir = Samagotchi::MemoryPaths.scope_dir!(scope)
450
+ rescue ArgumentError => e
451
+ @stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
452
+ end
435
453
  files = data[:files] || {}
436
454
  hooks = data[:hooks] || {}
437
455
  plugin_path = prov.plugin_path(data)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.0
3
+ version: 0.2.1
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
9
+ sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
10
10
  requires_chi: ">= 0.3.0"
@@ -103,7 +103,11 @@ class Plugin
103
103
  def check_in(ctx, count, mode)
104
104
  case mode
105
105
  when "nudge"
106
- ctx.notify("nudged the model after #{count} tool calls") if ctx.steer(message(count))
106
+ text = message(count)
107
+ if ctx.steer(text)
108
+ @mutex.synchronize { @nudge = { text: text, count: count } }
109
+ ctx.notify("nudged the model after #{count} tool calls")
110
+ end
107
111
  when "notify"
108
112
  ctx.notify("#{count} tool calls in this turn, no answer yet")
109
113
  else
@@ -117,15 +121,20 @@ class Plugin
117
121
  nudge_not_sent(event, ctx)
118
122
  end
119
123
 
120
- # The card said "Nudged": if the turn ended before the nudge joined it
121
- # (the model answered first, or the turn was stopped), the card says so.
124
+ # A nudge that was accepted but dropped before it joined the turn (the
125
+ # model answered first, or the turn was stopped): the card says so, or,
126
+ # for a mode nudge (no card), a notice does.
122
127
  def nudge_not_sent(event, ctx)
123
128
  id, nudge = @mutex.synchronize { [@card_id, @nudge] }
124
- return unless id && nudge
129
+ return unless nudge
125
130
  return if steered?(event[:messages], nudge[:text])
126
131
 
127
132
  first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
128
- ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
133
+ if id
134
+ ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
135
+ else
136
+ ctx.notify("#{first}; nudge not sent.", level: :warn)
137
+ end
129
138
  end
130
139
 
131
140
  # Whether this turn's messages (after its last prompt) have the steer.