samagotchi 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +31 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +68 -22
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +33 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9d794c2749a0f1293cb16d7afeb7e023f38dd8798532e4dcb2d0e9fcb575af1
|
|
4
|
+
data.tar.gz: 8ade3692c344214cc8cdbe90c1fb8539242639f7cbf8707c751a8d2b02f9548a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3eb054e3b0dd3ded63ef8de4bfff37d8d36971dbe02107ac2e30114fadc12563807d4c7bdc4d3fdf8b801a7bd8250fcb5e73a547604b0feba7382abfdff0ee7
|
|
7
|
+
data.tar.gz: 168c3cfef2a37c4f5e3f6e2782b614a546f8307793e41af0efe200b2afa8d100b725889e5bb8fcf520fa7c2a0be18faa4cedb10a9ddf573bedac162a1404f133
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,133 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.21.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The debug log's generation_completed line names the OpenRouter provider that served it (provider=).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- loop-guard cuts thinking that loops in short sentences ("I'll write it. Go. OK. Writing."),
|
|
20
|
+
which it skipped before: such a loop ran for minutes to the provider's output cap. New settings
|
|
21
|
+
`thinking.short_run` (24) and `thinking.short_distinct` (6).
|
|
22
|
+
- loop-guard's thinking watch no longer goes blind for the rest of a generation after an empty thinking
|
|
23
|
+
chunk (a FrozenError, logged as `plugin_hook_failed`), so it now sees every generation's thinking.
|
|
24
|
+
- loop-guard no longer stops a whole turn for a thinking loop that comes long after the model recovered from
|
|
25
|
+
its first one: after `thinking.forget_after` (10) steps with no loop, the next loop is cut and retried again.
|
|
26
|
+
- A turn a plugin stopped now says who did it, `✕ turn stopped by loop-guard` (the web: `✕ stopped by
|
|
27
|
+
loop-guard`), instead of `✕ canceled (by a hook)`, also after a reload of the web page.
|
|
28
|
+
- An attached `/exit` the worker reads only after the TUI stopped waiting (a frozen or sleeping worker) no
|
|
29
|
+
longer stops it later: the request is dropped, as late turns and commands are.
|
|
30
|
+
- The attached `/exit` (and `/exit --delete`, `/archive`) in an empty session no longer claims the session is
|
|
31
|
+
already gone: a note arriving before the worker stops keeps it, so it says it will be discarded if nothing
|
|
32
|
+
arrives.
|
|
33
|
+
- Archiving a session (`/archive`, `chi sessions archive`, the web) with a prompt still queued or a step-limit
|
|
34
|
+
question open is refused with a message saying so, instead of stopping the worker and leaving the queued
|
|
35
|
+
prompt to run on the next resume.
|
|
36
|
+
- `/exit` right after a `/detach` and `chi --attach` no longer says "another UI is attached" and leaves the worker
|
|
37
|
+
up: the worker notices a closed stream at once instead of at its next 15-second heartbeat.
|
|
38
|
+
- Leaving chi's terminal UI (`/exit`, `/detach`, Ctrl-D) no longer can leave a stray cursor report such as
|
|
39
|
+
`^[[12;1R` at the shell prompt: input nobody read is dropped before the terminal is handed back.
|
|
40
|
+
- Joining a running turn (web or `chi --attach`) while the model is holding before its first token now shows
|
|
41
|
+
the step it's on, as a live view does, instead of the previous step still live.
|
|
42
|
+
- Web: sending into a session whose worker was already up (but the page didn't know) no longer draws
|
|
43
|
+
that worker's earlier turns a second time: the page re-reads the session instead.
|
|
44
|
+
- Web without the session events stream (fetched list): a re-fetched list now badges and notifies like the
|
|
45
|
+
live list does.
|
|
46
|
+
- Web: a session deleted while the page had lost the session events stream (or between two fetches of the
|
|
47
|
+
list) no longer keeps its question or card counted in the tab title's badge until the tab comes to front.
|
|
48
|
+
- Web on a phone: the session footer's archive, stop and delete buttons are icons (the words stay as their
|
|
49
|
+
accessible names and tooltips), so the status and `mem N` chips beside them are in sight again.
|
|
50
|
+
- Web: with several chi tabs in the background, a session that needs you shows one OS notification, not one
|
|
51
|
+
per tab (every tab still counts it in its title).
|
|
52
|
+
- chi web: a session stream whose worker took the connection and froze before answering no longer holds the
|
|
53
|
+
request forever; it gives up after 5 s per attempt.
|
|
54
|
+
|
|
55
|
+
Update with `chi update`: loop-guard moves to 0.3.1 (short-sentence loops, forget_after, the blind-watch fix). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
|
|
56
|
+
|
|
57
|
+
## [0.20.0] - 2026-10-03
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- `guardrails.mode` (config.yml only): `auto`, the new default, or `strict`. Rules can be tagged `modes: [strict]`
|
|
62
|
+
to vote only in strict mode; a rule without `modes:` (every rule you wrote in config.yml) votes in both.
|
|
63
|
+
`/guardrails` shows the mode and marks the rules it leaves out `(strict only)`. See docs/guardrails.md#modes.
|
|
64
|
+
- New guardrail rule keys: `skip_read_only: true` (a shell command that only reads, such as `ls`, `cat`, `rg`,
|
|
65
|
+
`sed -n` or `git log`, doesn't match), `touches: chi_dirs` (a shell command names a path in chi's real config,
|
|
66
|
+
hooks, approvals or bundles dir, or a `.git/hooks`) and `rm: outside_tmp` (an `rm -rf` reaches outside the tmp
|
|
67
|
+
dirs). A config.yml rule that uses one fails closed on an older chi.
|
|
68
|
+
- Model speed and token stats. `/stats` has a speed line (the last generation's decode speed and the
|
|
69
|
+
session's average: exact from llama.cpp, estimated and marked `~` elsewhere), cached and reasoning
|
|
70
|
+
tokens, and the cost when the provider reports it (OpenRouter). The web's info bar shows the speed
|
|
71
|
+
next to `ctx`, updated after each generation, and the `ctx` tooltip there and on session cards lists
|
|
72
|
+
tokens in / cached / out (+ reasoning) and the cost, for that session only.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- [BEHAVIOUR] Guardrails ask less by default (auto mode). The guardrails bundle (0.5.0) no longer asks before
|
|
77
|
+
`git rebase`, writes outside the session's repo (`write-outside-repo`) or git in another checkout
|
|
78
|
+
(`git-outside-repo`); `guardrails: { mode: strict }` in config.yml brings those back. Everything else still asks
|
|
79
|
+
in both modes, as do the small-model rules and chi's own protected paths.
|
|
80
|
+
- [BEHAVIOUR] `shell-touches-chi` matches chi's real folders instead of text, and lets read-only commands through:
|
|
81
|
+
`ls ~/.config/samagotchi`, `rg samagotchi/hooks lib` and a scratch copy under `/tmp` no longer ask; `echo x >>
|
|
82
|
+
<config dir>/config.yml` and `cp hook .git/hooks/pre-commit` still do. A word it can't resolve falls back to the
|
|
83
|
+
old text match.
|
|
84
|
+
- [BEHAVIOUR] `rm-rf-wide` no longer asks when every `rm -rf` target is inside a tmp dir (`rm -rf /tmp/x`);
|
|
85
|
+
`/tmp` itself, `/tmp/*`, a `$VAR` and a link out of tmp still ask.
|
|
86
|
+
- [BEHAVIOUR] "Allow … in this repo" approvals hold for the whole repository, in every worktree of it, and the
|
|
87
|
+
ask names the repository (`in this repo (samagotchi)`) instead of the worktree folder. Approvals stored
|
|
88
|
+
before keep matching their exact folder.
|
|
89
|
+
- `chi bundle status` shows a bundle whose recorded scope this chi doesn't know (one a newer chi wrote, or a hand
|
|
90
|
+
edit) as `scope=team (unknown)`, counted as one issue, instead of checking its files in the system memories;
|
|
91
|
+
the shipped-bundle update skips it (upgrade chi or reinstall), and `chi bundle diff` refuses it with a one-line
|
|
92
|
+
error instead of a stack trace.
|
|
93
|
+
- The web model picker shows a model's configured sampling (`temperature=0.6 (hosts.work)`) as its row's tooltip;
|
|
94
|
+
`GET /api/models` has it as `sampling` on the models that have some.
|
|
95
|
+
- The web shows a plugin card that is only a notice (info level, no buttons, one short line, such as check-in's
|
|
96
|
+
"Nudged the model at 105 tool calls.") as a one-line row, `▸ check-in: Nudged …`, that opens on a click, like the
|
|
97
|
+
tool rows and resolved questions; warn cards, cards with buttons and longer ones stay framed cards.
|
|
98
|
+
- On a phone (a window under 900 px wide) the sessions strip's `hide` is in the top bar, where the `▾ N sessions`
|
|
99
|
+
pill comes back, with a finger-sized target, instead of the last column of the sideways-scrolling strip.
|
|
100
|
+
- On a phone the web info bar shows `mem N` (the memories the session used; their names in the tooltip) after the
|
|
101
|
+
session's status, since the `mem: …` line is hidden there.
|
|
102
|
+
- The web info bar's chips (model, ctx, speed, session time) are tinted over the card's glass instead of solid, and
|
|
103
|
+
the empty-history cover is see-through like the session panel.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- An MCP server that exits without a wait status no longer raises `NoMethodError`; its waiting calls fail with
|
|
108
|
+
"the server exited" instead of hanging.
|
|
109
|
+
- A hook that raises is now logged (`hook_failed`) instead of being swallowed silently; the turn still goes on.
|
|
110
|
+
- A hook's card (check-in, skills) now prints under its tool call's row: `tool_call_completed` is emitted before
|
|
111
|
+
`after_tool_call` fires.
|
|
112
|
+
- When a hook corrects a tool call (known-names), the model is told what actually ran with a leading
|
|
113
|
+
`ran as: <tool> <args>` line.
|
|
114
|
+
- MCP image content is described with a neutral `[image N: …]` line instead of claiming it was attached, which
|
|
115
|
+
was wrong for a model that can't see images.
|
|
116
|
+
- An MCP `resource` block carrying an image `blob` is now attached like an `image` block instead of being
|
|
117
|
+
dropped to its URI text.
|
|
118
|
+
- check-in's `nudge` mode no longer says "nudged" when the steer was dropped later; it says "nudge not sent" at
|
|
119
|
+
the turn's end.
|
|
120
|
+
- A session started with an annotation (a web quote) is previewed by the note typed under the quote, not
|
|
121
|
+
`From your thinking: > …`; a quote with no note previews by its words.
|
|
122
|
+
- A session that has only context notes so far (`chi note`, `send_note`) is previewed on its card and in the
|
|
123
|
+
lists as `note: …` instead of `—`, until a message is typed.
|
|
124
|
+
- A web turn of images alone names each image once in the transcript a recap is written from, not twice.
|
|
125
|
+
- `chi bootstrap` no longer says "loading the model?" for a slow test on a remote provider (OpenRouter and the
|
|
126
|
+
like); the hint is for a local server, where a slow answer is a model loading.
|
|
127
|
+
- `chi bootstrap` says why it saved a host as `name-2` when the derived name is already taken (or a model id
|
|
128
|
+
starts with it), instead of silently picking another name.
|
|
129
|
+
- `chi bundle upgrade` reports a hook or plugin file that is byte-identical to the installed one as "already up
|
|
130
|
+
to date" instead of "Updated".
|
|
131
|
+
- `chi bundle upgrade --agent` from a git/zip/tar source no longer hands the agent an `incoming:` path that was
|
|
132
|
+
already deleted: the extracted source is kept until the agent step is done, then cleaned up.
|
|
133
|
+
- `chi self` now checks a local `api: openai` host's reachability (a `GET <base>/models` with the same short
|
|
134
|
+
probe timeouts) and reports up/down with the ids it serves, instead of always saying "reported per turn".
|
|
135
|
+
|
|
136
|
+
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`).
|
|
137
|
+
|
|
11
138
|
## [0.19.0] - 2026-10-03
|
|
12
139
|
|
|
13
140
|
### Changed
|
|
@@ -1118,7 +1245,9 @@ and long-lived sessions.
|
|
|
1118
1245
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1119
1246
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1120
1247
|
|
|
1121
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1248
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.21.0...HEAD
|
|
1249
|
+
[0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
|
|
1250
|
+
[0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
|
|
1122
1251
|
[0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
|
|
1123
1252
|
[0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
|
|
1124
1253
|
[0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
|
data/docs/architecture.md
CHANGED
|
@@ -170,7 +170,8 @@ higher-level events so UIs get clean turn boundaries without inferring them:
|
|
|
170
170
|
|
|
171
171
|
- `:turn_started` — `{ session_id:, prompt: }`
|
|
172
172
|
- `:turn_completed` — `{ result: }` (the final `LLM::ModelResult`)
|
|
173
|
-
- `:turn_canceled` — `{ cancellation_reason: }`
|
|
173
|
+
- `:turn_canceled` — `{ cancellation_reason:, cancelled_by: }` (`cancelled_by`: the bundle or hook label that
|
|
174
|
+
stopped it, for `cancellation_reason: :hook`; else nil)
|
|
174
175
|
|
|
175
176
|
Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
|
|
176
177
|
rescues sink errors). A new UI (web, API) supplies its own `on_event` and
|
|
@@ -274,6 +275,12 @@ The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` fr
|
|
|
274
275
|
newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
|
|
275
276
|
calls and the running turn's finished ones. The full history is the session's `analytics.json`,
|
|
276
277
|
which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
|
|
278
|
+
The totals' `tokens` block adds the cached and reasoning sums, the cost, the decode time and tokens
|
|
279
|
+
behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
|
|
280
|
+
`last_prefill_tps`, `tps_source`: `server` or `estimate`). Each `generation_completed` event
|
|
281
|
+
carries the generation's `speed` (`{decode_tps, source}`, null without one) and those running
|
|
282
|
+
`tokens`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
|
|
283
|
+
it, so the web's info bar updates per generation without parsing chunk payloads.
|
|
277
284
|
|
|
278
285
|
The page's frequent reads of `GET /api/sessions/:id` stay light too:
|
|
279
286
|
- `?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.
|
|
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
|
|
417
|
-
the
|
|
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
|
|
|
@@ -833,7 +844,8 @@ Behavior notes:
|
|
|
833
844
|
`partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
|
|
834
845
|
started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
|
|
835
846
|
REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
|
|
836
|
-
`by a hook` for a hook's).
|
|
847
|
+
`by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
|
|
848
|
+
`✕ stopped by loop-guard`).
|
|
837
849
|
- Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
|
|
838
850
|
message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
|
|
839
851
|
tool call are dropped.
|
data/docs/configuration.md
CHANGED
|
@@ -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).
|
|
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 `
|
|
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.
|
|
314
|
-
`
|
|
315
|
-
`$HOME` or `..` paths
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
@@ -826,16 +831,22 @@ minutes, until the provider's output cap ends it with no answer. loop-guard
|
|
|
826
831
|
watches the thinking while it streams ([Watching the
|
|
827
832
|
stream](hooks.md#watching-the-stream)) and cuts it:
|
|
828
833
|
|
|
829
|
-
- The thinking is cut into sentences
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
834
|
+
- The thinking is cut into sentences. Two sentences of `min_words` or more
|
|
835
|
+
count as the same when their words and word pairs mostly match
|
|
836
|
+
(`similarity`), so a reworded round ("Hmm, …", a synonym, a swapped
|
|
837
|
+
clause) still counts.
|
|
833
838
|
- A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
|
|
834
839
|
row, over at least `min_span_sentences` sentences and `min_span_chars`
|
|
835
840
|
chars; or one sentence `max_same` times anywhere in the generation. One
|
|
836
841
|
sentence over and over must be near-identical (`similarity` + 0.4), and a
|
|
837
842
|
longer cycle must hold different sentences: a list of templated sentences
|
|
838
843
|
("Now I need to open the file <path> and …" for 14 paths) is no loop.
|
|
844
|
+
- Short sentences (under `min_words`) are a loop of their own when
|
|
845
|
+
`short_run` of them come in a row with at most `short_distinct` different
|
|
846
|
+
ones among them ("I'll write it. Go. OK. Writing. Go. OK."). Normal
|
|
847
|
+
thinking has short sentences too ("Hmm.", "Fine."), but spread out or all
|
|
848
|
+
different (code lines); a longer sentence ends the run, and lines inside a
|
|
849
|
+
```` ``` ```` code block don't count.
|
|
839
850
|
- Nothing triggers before `min_chars` of thinking. The watch sees a loop
|
|
840
851
|
within one batch (2000 chars, or a second) of its third cycle.
|
|
841
852
|
|
|
@@ -846,11 +857,16 @@ What happens (`action: retry`, the default):
|
|
|
846
857
|
`loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
|
|
847
858
|
and `↻ cut by loop-guard, asking again (1/1)`.
|
|
848
859
|
2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
|
|
849
|
-
turn
|
|
860
|
+
turn stopped by loop-guard") with a card that quotes the repeated
|
|
861
|
+
sentences.
|
|
862
|
+
3. A loop is forgotten after `forget_after` good steps in a row (generations
|
|
863
|
+
with no loop): a model that recovered and made progress gets its next
|
|
864
|
+
loop cut and retried again, not the turn stopped. `forget_after: 0`
|
|
865
|
+
never forgets: the turn's second loop stops it.
|
|
850
866
|
|
|
851
867
|
The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
|
|
852
|
-
the first loop ends the turn as
|
|
853
|
-
card. `action: stop` stops the turn at the first loop; `action: notify` only
|
|
868
|
+
the first loop ends the turn as stopped by loop-guard, with the notice and
|
|
869
|
+
no card. `action: stop` stops the turn at the first loop; `action: notify` only
|
|
854
870
|
warns, once per generation.
|
|
855
871
|
|
|
856
872
|
```yaml
|
|
@@ -866,7 +882,10 @@ bundles:
|
|
|
866
882
|
min_span_sentences: 6 # a loop spans at least this many sentences…
|
|
867
883
|
min_span_chars: 600 # …and this many chars
|
|
868
884
|
max_same: 8 # one sentence this many times in one generation is a loop
|
|
869
|
-
min_words: 5 # shorter sentences
|
|
885
|
+
min_words: 5 # shorter sentences only count toward a short run
|
|
886
|
+
short_run: 24 # this many short sentences in a row…
|
|
887
|
+
short_distinct: 6 # …with at most this many different ones is a loop
|
|
888
|
+
forget_after: 10 # good steps in a row that forget a loop; 0: never
|
|
870
889
|
```
|
|
871
890
|
|
|
872
891
|
Not watched:
|
data/docs/sessions.md
CHANGED
|
@@ -15,7 +15,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
15
15
|
|
|
16
16
|
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
|
|
17
17
|
|
|
18
|
-
**Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session
|
|
18
|
+
**Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
|
|
19
19
|
|
|
20
20
|
**Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
|
|
21
21
|
|
|
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
25
25
|
|
|
26
26
|
**Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
|
|
27
27
|
|
|
28
|
-
<a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
|
|
28
|
+
<a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
|
|
29
29
|
|
|
30
30
|
**Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
|
|
31
31
|
|
|
@@ -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
|
-
|
|
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,
|