samagotchi 0.89.0 → 0.91.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 +39 -1
- data/bin/chi +28 -3
- data/docs/architecture.md +27 -5
- data/docs/bundle-authoring.md +36 -36
- data/docs/cli.md +4 -1
- data/docs/configuration.md +5 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +2 -2
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/client_event.rb +15 -7
- data/lib/samagotchi/bridge.rb +7 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +25 -8
- data/lib/samagotchi/bundle_command.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +9 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/llm/model_result.rb +2 -2
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +185 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tool_runner.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/turn_events.js +16 -8
- data/lib/samagotchi/worker.rb +177 -7
- data/lib/samagotchi/worker_idle_exit.rb +11 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_wakes.rb +14 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4a20c3555c1f3e592919853f9dc59a5067e7e42251d11dad2f3be844f118f90
|
|
4
|
+
data.tar.gz: 817bb797b870985aeb21789377eae7770225265d03c527bb9218a1ecd3c2376e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74b69c624e9e43882940e48bdf00d0bedb1172fd53f4094c30ef4250e15a4e7ae9fedba4fc9704b923d3b622bcad47950432d450c1c3c219f42a54186dacad58
|
|
7
|
+
data.tar.gz: ac112286f3eaa6364bd68cee410e4b580f8246215222a17ecd3781f2980d828eb12d47cc3b64c800e201f9a81662a0c2ee61627a1c485e4249f409c771b65744
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,42 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.91.0] - 2026-10-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A background task the model left running (`task_create`, an `execute` handed over at its limit) that finishes
|
|
16
|
+
unseen wakes the idle session for a short turn opened by a `TASK FINISHED` note (exit code, duration, log tail).
|
|
17
|
+
`session.task_wakes`: `wake` (default), `queue` (a note for the next turn) or `off`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The bundle-authoring doc says `chi bundle try` is the live check; its `chi send` recipe for checking the installed
|
|
22
|
+
copy is gone.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `chi bundle try --json` reports always carry `timed_out`; a run cut by `--timeout` lists the tool calls it made
|
|
27
|
+
(name, status and duration; their arguments aren't saved) instead of an empty `tools`.
|
|
28
|
+
|
|
29
|
+
Update with `chi update` (the system bundle 0.91.0), then restart `chi web`.
|
|
30
|
+
|
|
31
|
+
## [0.90.0] - 2026-10-19
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- SSE `tool_call_started` carries no `call`, `turn_completed` no `result`; read `tool`/`params`/`view` and
|
|
36
|
+
`turn_summary.output`. `?raw=1` on the worker's stream shows them.
|
|
37
|
+
- docs/bundle-authoring.md (the guide chi reads to write a bundle) makes edit → `check --fix` → `test` → `try` the
|
|
38
|
+
loop, with `install --dev .` while iterating and `try` as the live check; its test example runs as written.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- `chi -p` followed by one of chi's own flags (`chi -p --non-interactive "text"`, `-p -v`) now exits 2 with a usage
|
|
43
|
+
error instead of taking the flag as the prompt.
|
|
44
|
+
|
|
45
|
+
Update with `chi update` (the system bundle 0.90.0), then restart `chi web`.
|
|
46
|
+
|
|
11
47
|
## [0.89.0] - 2026-10-19
|
|
12
48
|
|
|
13
49
|
### Added
|
|
@@ -3513,7 +3549,9 @@ and long-lived sessions.
|
|
|
3513
3549
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3514
3550
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3515
3551
|
|
|
3516
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3552
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.91.0...HEAD
|
|
3553
|
+
[0.91.0]: https://github.com/dm1try/samagotchi/compare/v0.90.0...v0.91.0
|
|
3554
|
+
[0.90.0]: https://github.com/dm1try/samagotchi/compare/v0.89.0...v0.90.0
|
|
3517
3555
|
[0.89.0]: https://github.com/dm1try/samagotchi/compare/v0.88.0...v0.89.0
|
|
3518
3556
|
[0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
|
|
3519
3557
|
[0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
|
data/bin/chi
CHANGED
|
@@ -74,6 +74,7 @@ options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt")
|
|
|
74
74
|
cli_overrides = {}
|
|
75
75
|
|
|
76
76
|
# Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
|
|
77
|
+
known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
|
|
77
78
|
ARGV.each do |arg|
|
|
78
79
|
next unless arg.start_with?("--") && arg.include?("_")
|
|
79
80
|
|
|
@@ -82,7 +83,6 @@ ARGV.each do |arg|
|
|
|
82
83
|
base = arg.split("=", 2).first
|
|
83
84
|
# If dashed version is a known flag (registry or hardcoded), reject underscore variant
|
|
84
85
|
dashed = base.tr("_", "-")
|
|
85
|
-
known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
|
|
86
86
|
if known.include?(dashed) || known.include?(base)
|
|
87
87
|
warn "Unknown option: #{arg} (did you mean #{dashed}?)"
|
|
88
88
|
exit 1
|
|
@@ -191,8 +191,33 @@ end
|
|
|
191
191
|
# value (-p, --model …: the parser's own list) keeps its next word, so
|
|
192
192
|
# `chi -p "--no-tool=x is broken"` is a prompt. A tool name never starts
|
|
193
193
|
# with "-": `--no-tool --` or `--no-tool --model` is a missing argument.
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
switches = parser.instance_variable_get(:@stack).flat_map { |list| list.list }
|
|
195
|
+
.select { |switch| switch.is_a?(OptionParser::Switch) }
|
|
196
|
+
switch_flags = switches.flat_map { |switch| switch.long + switch.short }
|
|
197
|
+
known_flags = (known + switch_flags).uniq
|
|
198
|
+
takes_value = switches.select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
|
|
199
|
+
.flat_map { |switch| switch.long + switch.short }
|
|
200
|
+
reject_prompt_flag = lambda do |flag, value|
|
|
201
|
+
if known_flags.include?(value)
|
|
202
|
+
warn "Error: #{flag} needs the prompt text right after it (got the flag #{value}) (see chi --help)"
|
|
203
|
+
exit 2
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
index = 0
|
|
207
|
+
while index < ARGV.size && ARGV[index] != "--"
|
|
208
|
+
arg = ARGV[index]
|
|
209
|
+
if %w[-p --prompt].include?(arg)
|
|
210
|
+
reject_prompt_flag.call(arg, ARGV[index + 1])
|
|
211
|
+
index += 2
|
|
212
|
+
elsif arg.start_with?("--prompt=")
|
|
213
|
+
reject_prompt_flag.call("--prompt", arg.delete_prefix("--prompt="))
|
|
214
|
+
index += 1
|
|
215
|
+
elsif arg.start_with?("-p") && arg != "-p"
|
|
216
|
+
reject_prompt_flag.call("-p", arg.delete_prefix("-p").delete_prefix("="))
|
|
217
|
+
index += 1
|
|
218
|
+
else
|
|
219
|
+
index += takes_value.include?(arg) ? 2 : 1
|
|
220
|
+
end
|
|
196
221
|
end
|
|
197
222
|
missing_no_tool = lambda do
|
|
198
223
|
warn "Error: missing argument: --no-tool (see chi --help)"
|
data/docs/architecture.md
CHANGED
|
@@ -187,10 +187,15 @@ higher-level events so UIs get clean turn boundaries without inferring them:
|
|
|
187
187
|
model's thinking, `0` = never; plus `continue: true` for a continue turn, `resumed: true` for a step-limit Continue,
|
|
188
188
|
`images:` with the prompt's image refs)
|
|
189
189
|
- `:turn_completed` — `{ result:, turn_summary:, display_pending: }` (`result`: the final
|
|
190
|
-
`LLM::ModelResult`; `display_pending`: `after_turn` hooks may still
|
|
190
|
+
`LLM::ModelResult`; Engine event (clients get it without `result`); `display_pending`: `after_turn` hooks may still
|
|
191
|
+
present the answer)
|
|
191
192
|
- `:turn_canceled` — `{ cancellation_reason:, cancelled_by:, duration_ms: }` (`cancelled_by`: the bundle or hook label that
|
|
192
193
|
stopped it, for `cancellation_reason: :hook`; else nil)
|
|
193
194
|
|
|
195
|
+
These are the Engine's raw events. The worker's SSE projection omits `call` from
|
|
196
|
+
`:tool_call_started` and `result` from `:turn_completed`; the answer remains in
|
|
197
|
+
`turn_summary.output`.
|
|
198
|
+
|
|
194
199
|
A turn started with `origin:` carries it on each of these (and `:turn_failed`).
|
|
195
200
|
|
|
196
201
|
Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
|
|
@@ -246,7 +251,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
246
251
|
`session_state_snapshot`. `?snapshot=1` joins with a snapshot frame instead of a replay;
|
|
247
252
|
`?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
|
|
248
253
|
Each event goes out as clients get it (`Bridge::ClientEvent.project`, in `SSEWriter#write_event`, live
|
|
249
|
-
and replayed alike);
|
|
254
|
+
and replayed alike); `generation_chunk`, `tool_call_started` and `turn_completed` are projected to the
|
|
255
|
+
contracts below. The ring retains raw events for replay and `?raw=1`.
|
|
250
256
|
`?raw=1` writes events as the engine emitted them, for debugging on the worker's port: `chi web`'s
|
|
251
257
|
`/api/sessions/:id/stream` never passes it on (`Web::App#stream_query`).
|
|
252
258
|
An attached terminal's stream (`EventStream` with `rediscover:`) looks for the session's live
|
|
@@ -265,6 +271,22 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
265
271
|
"event_seq":812}
|
|
266
272
|
```
|
|
267
273
|
|
|
274
|
+
A `tool_call_started` frame keeps its row fields but omits the model's `call`:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
event: tool_call_started
|
|
278
|
+
data: {"type":"tool_call_started","iteration":2,"call_index":1,"tool":"write",
|
|
279
|
+
"params":"path=notes.txt","event_seq":812}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
A `turn_completed` frame omits `result`; `turn_summary.output` carries the answer:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
event: turn_completed
|
|
286
|
+
data: {"type":"turn_completed","turn_summary":{"output":"Done."},
|
|
287
|
+
"display_pending":false,"event_seq":813}
|
|
288
|
+
```
|
|
289
|
+
|
|
268
290
|
`counts` is `ContextUsage.normalize` of the provider's payload without `ctx_pct` (normalize computes it
|
|
269
291
|
without the session's LLM context budget; a client counts `total_tokens` against the window or budget, as
|
|
270
292
|
`ContextStatus#generation_done` does for the TUI) and with its nil keys left out: cumulative for the
|
|
@@ -337,8 +359,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
337
359
|
- `POST /session/:id/exit` — ask the worker to exit now (`{client_id:}`). The worker checks with
|
|
338
360
|
the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
|
|
339
361
|
like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
|
|
340
|
-
`continue_offered`, `client_connected` (a stream not named by the asker), `reminders
|
|
341
|
-
`starting`.
|
|
362
|
+
`continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
|
|
363
|
+
`background_tasks` (with `tasks: [{id, command}]`: tasks a finish wake watches) or `starting`.
|
|
342
364
|
- `GET /session/:id/state` — `session_state_snapshot` (JSON).
|
|
343
365
|
- `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
|
|
344
366
|
- `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
|
|
@@ -559,7 +581,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
559
581
|
|
|
560
582
|
| Component | Main file(s) | What it is |
|
|
561
583
|
|---|---|---|
|
|
562
|
-
| Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
|
|
584
|
+
| Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`, `worker_wakes.rb`, `task_watch.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns and the wake turns nobody typed (delegate reports, a background task that finished unseen: [Finish wake](internals/background-tasks.md#finish-wake), attached context), exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
|
|
563
585
|
| Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
|
|
564
586
|
| Paths | `paths.rb` (`Paths`) | The XDG base dirs chi's files live under, read from the env on every call: `Paths.state_home` (`$XDG_STATE_HOME`, else `~/.local/state`) and `Paths.config_home` (`$XDG_CONFIG_HOME`, else `~/.config`), both defaults under `Dir.home`; a blank variable counts as unset, a relative one is made absolute from the current directory. `Paths.tilde` shows a path with `~` for the home folder. `SessionManager` hands a spawned worker the spawner's `XDG_STATE_HOME` / `XDG_CONFIG_HOME`, when set, made absolute, so a relative one isn't read from the session's folder. |
|
|
565
587
|
| MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
|
data/docs/bundle-authoring.md
CHANGED
|
@@ -9,23 +9,22 @@ files, what a hook sees, how to test, and what goes wrong. The full references a
|
|
|
9
9
|
```
|
|
10
10
|
chi bundle new my-bundle # ./my-bundle: manifest.yml, plugin.rb, test/my_bundle_test.rb, README.md
|
|
11
11
|
cd my-bundle # edit plugin.rb and the test
|
|
12
|
-
chi bundle
|
|
12
|
+
chi bundle install --dev . # once while iterating: link this folder; edits load in the next session
|
|
13
13
|
chi bundle check --fix # manifest, files, shas (--fix rewrites them), requires_chi, install + load in a sandbox
|
|
14
|
+
chi bundle test # tests on chi's own Ruby and lib (exit status: the tests')
|
|
14
15
|
chi bundle try . -p "Say hello" # one real model turn; trace the bundle in an isolated install sandbox
|
|
15
|
-
chi bundle install .
|
|
16
|
-
chi bundle install --dev . # or as a link to this folder: edits apply at the next session, no re-install
|
|
16
|
+
chi bundle uninstall my-bundle && chi bundle install . # at the end: leave dev mode, copy it in
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
loads
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
The loop is **edit → `check --fix` → `test` → `try`**. `install --dev .` links the folder once, so a new session
|
|
20
|
+
loads your edits with no reinstall (a running one doesn't; `try` doesn't need the link: it installs into its own
|
|
21
|
+
sandbox). Memories are copied: re-install with `--force` from the same PATH to pick up memory edits. At the end,
|
|
22
|
+
`uninstall NAME` leaves dev mode and a plain `install .` copies the bundle in (chi refuses it over a dev link).
|
|
23
|
+
Dev-linked scripts aren't chmodded: chi warns with a `chmod +x PATH/scripts/x` hint; set the bit on the source.
|
|
24
24
|
|
|
25
|
-
`check` prints one finding per line
|
|
26
|
-
|
|
27
|
-
the
|
|
28
|
-
written: run them on code you'd install.
|
|
25
|
+
`check` prints one finding per line (`error|warn|info CODE file[:line]: message (fix: hint)`), exits 1 on any error
|
|
26
|
+
and installs into a temp sandbox, not the user's config or state; run it after every edit. `check` and `test` run
|
|
27
|
+
the bundle's plugin and hooks in their own process, as written: run them on code you'd install.
|
|
29
28
|
|
|
30
29
|
## Trying a bundle
|
|
31
30
|
|
|
@@ -87,29 +86,36 @@ that list is never fired (`check` warns). The two a bundle wants most, for an `e
|
|
|
87
86
|
Match on `result:`, not `output:`. An execute's result is its `stdout:` and `stderr:` blocks (each left out when
|
|
88
87
|
empty), then `exit: N` (`exit: 0 (no output)` when both were empty); a non-zero exit makes `status:` `"error"`.
|
|
89
88
|
Act through `ctx`: `ctx.notify(text, level: :warn)`, `ctx.steer(text)` (a line the model reads before its next
|
|
90
|
-
step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's
|
|
89
|
+
step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's state folder),
|
|
91
90
|
`ctx.cwd`, `ctx.log`. In a test, each recorded notice, steer or stop names its hook as `"plugin.rb (bundle
|
|
92
91
|
my-bundle)"`, the plugin's label (a `hooks/x.rb` file's is `"x.rb (bundle my-bundle)"`).
|
|
93
92
|
|
|
94
93
|
## Testing it
|
|
95
94
|
|
|
96
|
-
`chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`)
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
`chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`) loads
|
|
96
|
+
the bundle's source through chi's own loaders in a sandbox (temp HOME, config and state) and records what it did:
|
|
97
|
+
`load_bundle`, then `bundle.command` / `bundle.fire`, and the effects in `bundle.notices`. `bundle.ctx.data_dir` and
|
|
98
|
+
`sandbox.state_dir` are that test's temporary state, never the user's (the scaffold's plugin, tested):
|
|
99
99
|
|
|
100
100
|
```ruby
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
bundle
|
|
107
|
-
|
|
108
|
-
bundle.
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
require "minitest/autorun"
|
|
102
|
+
require "samagotchi/testing/minitest"
|
|
103
|
+
|
|
104
|
+
class MyBundleTest < Samagotchi::Testing::Case
|
|
105
|
+
def test_command_and_failed_execute_notice
|
|
106
|
+
bundle = load_bundle(File.expand_path("..", __dir__), settings: { "greeting" => "hi" })
|
|
107
|
+
assert_equal "hi, there", bundle.command("/my-bundle")
|
|
108
|
+
bundle.fire(:after_tool_call, tool: "execute", status: "error", call: { name: "execute", content: "false" },
|
|
109
|
+
result: "exit: 1 (no output)", output: "[execute]\nexit: 1 (no output)")
|
|
110
|
+
assert_equal "the command exited 1", bundle.notices.last.text
|
|
111
|
+
assert_equal File.join(sandbox.state_dir, "plugins", bundle.name), bundle.ctx.data_dir
|
|
112
|
+
end
|
|
113
|
+
end
|
|
111
114
|
```
|
|
112
115
|
|
|
116
|
+
`bundle.tool_call` runs guarded hooks; `bundle.tool` calls a registered tool. `bundle.steers`, `cards`, `stops` and
|
|
117
|
+
`questions` record effects; `bundle.answer` / `model_answers` provide `ask_user` / `ask_model` answers.
|
|
118
|
+
`bundle.http.stub` stubs `ctx.http`; `sandbox.config`, `sandbox.fake_executable` and `bundle.install` cover setup.
|
|
113
119
|
The full list is in [plugins.md, Testing a bundle](plugins.md#testing-a-bundle-experimental). The kit is
|
|
114
120
|
experimental (it may change before chi 1.0). If a test needs a fake of chi's own (a stub of a `Samagotchi::`
|
|
115
121
|
class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather than reaching into chi.
|
|
@@ -132,13 +138,7 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
|
|
|
132
138
|
|
|
133
139
|
## Verifying it live
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
grep plugin_loaded ~/.local/state/samagotchi/samagotchi.log | tail -1 # bundle=my-bundle commands=1 hooks=1
|
|
140
|
-
chi sessions delete --force SESSION_ID # leave no test session behind
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
(`$XDG_STATE_HOME/samagotchi/samagotchi.log` when `XDG_STATE_HOME` is set.) Then tell the user what it adds,
|
|
144
|
-
how to use it, and that a new session picks it up.
|
|
141
|
+
`chi bundle try . -p "..."` **is** the live check: a real model turn on the installed bundle, with its trace
|
|
142
|
+
(tools, plugin, notices, cards). Don't repeat it with `chi send` sessions on the user's own install. After the
|
|
143
|
+
final `chi bundle install .`, `chi bundle list` showing the bundle and its version is enough. Then tell the user
|
|
144
|
+
what it adds, how to use it, and that a new session picks it up.
|
data/docs/cli.md
CHANGED
|
@@ -149,7 +149,10 @@ non-interactive mode. `--keep` retains the temporary sandbox and prints its path
|
|
|
149
149
|
one experimental JSON object to stdout and human trace lines to stderr; its keys are `session_id`, `exit_code`,
|
|
150
150
|
`answer`, `tools` (`index`, `name`, `args`, `status`, `duration_ms`), `notices`, `cards`, `questions`,
|
|
151
151
|
`interactive_blocked`, `used_memories`, `tokens` (`prompt`, `completion`), `cost`, `cost_is_estimate`, `model`,
|
|
152
|
-
`sandbox`, `denied` (1-based tool indexes)
|
|
152
|
+
`sandbox`, `denied` (1-based tool indexes), `stderr_tail` (last 4,000 characters) and `timed_out`. A run past
|
|
153
|
+
`--timeout` is interrupted like a Ctrl-C and still prints the trace it got to, with `timed_out: true` (text output:
|
|
154
|
+
the trace lines, then `timed out after N seconds` on stderr). That turn's messages aren't saved, so its tool calls
|
|
155
|
+
come from their timing records: name, status and duration, with empty `args`. Exit status: 2 for usage,
|
|
153
156
|
1 for install/turn failure or timeout, 130 for Ctrl-C, otherwise the child's status.
|
|
154
157
|
|
|
155
158
|
`try` runs the bundle's code and the model's tools with your real HOME, account and keys. Its temporary working
|
data/docs/configuration.md
CHANGED
|
@@ -73,7 +73,8 @@ session:
|
|
|
73
73
|
# keep_empty: false # true keeps sessions nothing happened in (default: deleted when left)
|
|
74
74
|
# max_children: 4 # running sessions one session may have delegated at a time (the delegate tool)
|
|
75
75
|
# delegate_reports: wake # a child's reply reaches its parent by itself: wake (an idle parent runs a turn), queue, off
|
|
76
|
-
#
|
|
76
|
+
# task_wakes: wake # a background task finishing unseen: wake (an idle session runs a turn), queue, off
|
|
77
|
+
# max_wakes: 10 # wake turns in a row (delegate reports, finished tasks, context, peers) with no human input
|
|
77
78
|
|
|
78
79
|
# Baseline memories preloaded into the system prompt (same shape as --memory);
|
|
79
80
|
# name entries you have, or chi warns at each start.
|
|
@@ -160,7 +161,7 @@ or `chi web` restart.
|
|
|
160
161
|
| `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
|
|
161
162
|
| `recap.*` | Worker start | The engine builds its recap job once. |
|
|
162
163
|
| `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
|
|
163
|
-
| `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
|
|
164
|
+
| `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.task_wakes`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
|
|
164
165
|
| `session.retention_*`, `session.max_count`, `session.keep_status`, `session.sweep_interval_hours` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. The sweep interval is checked on the next hub tick. |
|
|
165
166
|
| `web.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
|
|
166
167
|
| `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
|
|
@@ -1622,7 +1623,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
1622
1623
|
| `session.keep_empty` | `false` | | Keep sessions nothing happened in. |
|
|
1623
1624
|
| `session.max_children` | `4` | | Running delegated sessions one session may have. |
|
|
1624
1625
|
| `session.delegate_reports` | `wake` | | When a delegate child ends a turn, its reply reaches the parent: `wake` runs an idle parent's turn for it, `queue` waits for the parent's next turn, `off` leaves it to `delegate_result`. |
|
|
1625
|
-
| `session.
|
|
1626
|
+
| `session.task_wakes` | `wake` | | When a background task the session started (`task_create`, an `execute` handed over at its limit) finishes and the model hasn't seen it finish: `wake` runs an idle session's turn opened by a `TASK FINISHED` note, `queue` leaves the note for the next turn, `off` leaves it to `task_wait`. See [Finish wake](internals/background-tasks.md#finish-wake). |
|
|
1627
|
+
| `session.max_wakes` | `10` | | Wake turns a session runs in a row with no human input: for delegate reports, finished background tasks, attached context and peer answers; past it reports wait for the next turn and task finishes and context updates arrive as notes. |
|
|
1626
1628
|
| `session.retention_days` | `14` | yes | Delete sessions not updated for N days; `0` = forever. See [Sessions](sessions.md). |
|
|
1627
1629
|
| `session.max_count` | `500` | yes | Keep the newest N; `0` = uncapped. |
|
|
1628
1630
|
| `session.keep_status` | none | yes | Comma list of statuses never pruned (a session a worker or `chi` has open is never pruned anyway). |
|
|
@@ -19,7 +19,7 @@ Behavior:
|
|
|
19
19
|
|
|
20
20
|
- `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
|
|
21
21
|
- Task metadata and output are persisted under `tmp/tasks/`: each task's `tmp/tasks/<id>/task.json` is a `TaskRecord` (`lib/samagotchi/tools/task_record.rb`, a value object: `.from_file` reads it, `#to_file` is the JSON object written, an update is a `#with`). Its keys keep their order; `stderr_path` and `handed_over_by` (a handed-over execute) and `stop_requested_by` (a stop under way or done) are left out until set. `bundles/slow-commands` reads these files on its own, so the shape is a contract.
|
|
22
|
-
- A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's
|
|
22
|
+
- A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's restart hold, the finish wake's watch) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
|
|
23
23
|
- A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
|
|
24
24
|
- Task listing is workspace-scoped (current project only).
|
|
25
25
|
- `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (always an absolute path: a relative `XDG_STATE_HOME` is taken from the directory chi started in; outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
|
|
@@ -27,11 +27,36 @@ Behavior:
|
|
|
27
27
|
- A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
|
|
28
28
|
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
|
|
29
29
|
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
|
|
30
|
-
- Which tasks are a session's own (a cancelled turn's note,
|
|
30
|
+
- Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|
|
33
33
|
- `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
|
|
34
34
|
- `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
|
|
35
35
|
- They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
|
|
36
36
|
|
|
37
|
+
## Finish wake
|
|
38
|
+
|
|
39
|
+
A task the model leaves running (it ends its turn: "I'll check when it's finished") is reported when it ends, so the result doesn't wait for the user's next message. `session.task_wakes` (env `SAMAGOTCHI_SESSION_TASK_WAKES`): `wake` (default), `queue` or `off` (no watch, as before).
|
|
40
|
+
|
|
41
|
+
- **The watch** (`TaskWatch`, `task_watch.rb`) is the Worker's, in memory: the in-process REPL (`--no-shared`) has none, and a crash loses it (those tasks behave as before). The Worker's Engine observer feeds it the raw `:tool_call_completed` events, the input `Bridge#own_task_ids` reads too. A `task_create` result and an `execute` hand-over add the task (`TaskRuntime.created_id`: a `task_id:` the command printed doesn't count), the hand-over's limit kept for the note. What shows the model the end removes it: a `task_wait` with a final status, or with `wait_result: pattern_matched` (the model chose its own "done" marker); a `task_get` with a final status; any `task_stop`. `task_list` and a `read` of `output.log` don't: a model that read the log still gets the wake once. Tasks a tool starts inside an anytime command (`/btw` and the like) reach the same observer, so they are watched too.
|
|
42
|
+
- **The idle tick**: with something watched, each idle pass (after delegate reports, before due reminders) refreshes the watched records (`TaskRuntime.get_record`, one `ps` per running task) and takes the finished ones, keeping only the ids the conversation still holds (`TaskRuntime.created_ids_in`: a `!rollback` or a restored turn may have dropped the result that started one; an LLM context strategy can't, since its stale and forget stubs are drawn in the request, never written over the saved result). A note that can't be written puts its task back for the next pass. An empty watch costs nothing. A task that finishes during a turn and is still unseen when the turn ends wakes on the next idle pass. One the user stopped with the web's stop-task button is dropped without a note.
|
|
43
|
+
- **The note** is a context note under its own header (`ContextNote::TASK_HEADER`; `TaskFinishNote`), `note_id: task-<id>`, `source: task <id8>`:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
[TASK FINISHED from task 3f9a2c1d, 14:20]
|
|
47
|
+
Background task 3f9a2c1d finished: exit 0 after 6m12s (handed over by execute at its 120s limit).
|
|
48
|
+
command: bundle exec rspec spec
|
|
49
|
+
<task_wait's own text (TaskWait.finished_text): status, the log's last 10 lines, a stderr_tail>
|
|
50
|
+
chi started this turn because a background task you started finished; your user didn't write anything.
|
|
51
|
+
Carry on with what you planned to do with its result. If you planned nothing, tell your user in a sentence or two how it went.
|
|
52
|
+
[END NOTE]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The duration runs from `started_at` to the `exit_code` file's mtime (the shell's EXIT trap wrote it as it ended; `finished_at` is when a refresh first noticed the end), else `finished_at`. The body is cut to `max_tool_output_chars` as a `task_wait` result is (`ToolOutputCut`'s `:head_tail_ref`, naming the logs).
|
|
56
|
+
- **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
|
|
57
|
+
- **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
|
|
58
|
+
- **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
|
|
59
|
+
- **Idle exit**: with `wake`, a watched task still running holds the worker's idle exit and `/exit` (`WorkerIdleExit`'s `:background_tasks`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. With `queue` they don't: nothing would wake for the end, so keeping the worker up isn't worth it; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
|
|
60
|
+
- **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
|
|
61
|
+
|
|
37
62
|
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
data/docs/internals/bundles.md
CHANGED
|
@@ -24,7 +24,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
24
24
|
|---|---|
|
|
25
25
|
| `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
|
|
26
26
|
| `BundleCommand::Check` (bundle_command/check.rb) | `chi bundle check [PATH] [--fix] [--set K=V]`: a bundle being written, checked as an install takes it (docs/cli.md lists the codes); parses the manifest from `Shas.parseable` (a placeholder sha is a `sha` finding, not a YAML error), reads the user's installed bundles only for `clash`, and installs and loads through `Testing::Install` in a `Testing::Sandbox` with `force: true`, the log's echoes swallowed |
|
|
27
|
-
| `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: filtered-config `Sandbox` installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output. `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers. |
|
|
27
|
+
| `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: filtered-config `Sandbox` installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output (tool records the messages lack, from a turn a `--timeout` interrupt cut, become rows without args; `Run::Result#timed_out` becomes `timed_out`). `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers. |
|
|
28
28
|
| `BundleCommand::Scaffold` (bundle_command/scaffold.rb) | `chi bundle new NAME`: the starting files (block-form `plugin:` entry, `requires_chi` at this chi, a kit test that passes as written); the manifest's shas are filled through `Shas.refresh` |
|
|
29
29
|
| `BundleCommand::TestRun` (bundle_command/test_run.rb) | `chi bundle test`: `RbConfig.ruby -I <chi's lib>` on `test/**/*_test.rb` (Minitest) or `spec/` (RSpec, as `rake bundles:specs`), under `Bundler.with_unbundled_env` when Bundler's runtime is loaded |
|
|
30
30
|
| `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / hook / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
|
data/docs/internals/engine.md
CHANGED
|
@@ -489,12 +489,12 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
489
489
|
| `:thinking_refused` / `:reasoning_history_refused` | the chat loop asked a step again without the thinking fields / the reasoning sent back; the Engine logs it and makes a once-per-session-and-host `:hook_notice` of it, the event itself never reaches the sink | `{iteration, model, level, detail}` |
|
|
490
490
|
| `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
|
|
491
491
|
| `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
|
|
492
|
-
| `:tool_call_started` | before a call runs | see ToolRunner |
|
|
492
|
+
| `:tool_call_started` | before a call runs | Engine event includes `call` (see ToolRunner); the Bridge's SSE frame omits it |
|
|
493
493
|
| `:tool_call_completed` | after a call ran | see ToolRunner |
|
|
494
494
|
| `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
|
|
495
495
|
| `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
|
|
496
496
|
| `:guardrail_warning` | a guardrails line during a turn | `{message}` |
|
|
497
|
-
| `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
|
|
497
|
+
| `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
|
|
498
498
|
| `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
|
|
499
499
|
| `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
|
|
500
500
|
| `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
|
|
@@ -83,6 +83,10 @@ right-hand column happens.
|
|
|
83
83
|
finishes booting between load and the first turn changes search answers only, so two sessions on the same
|
|
84
84
|
toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
|
|
85
85
|
same two things (`Registry#index_needed?`), never live groups.
|
|
86
|
+
- A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
|
|
87
|
+
it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
|
|
88
|
+
Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
|
|
89
|
+
between the two, or whose worker restarts after `task_wakes` changed, misses the cache once.
|
|
86
90
|
- On remote Claude, `LLM::PromptCache.mark` sends the system message as two text parts. The stable part carries
|
|
87
91
|
breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
|
|
88
92
|
#2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
|
data/docs/sessions.md
CHANGED
|
@@ -17,7 +17,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
17
17
|
|
|
18
18
|
A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
|
|
19
19
|
|
|
20
|
-
**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.
|
|
20
|
+
**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, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running (with `session.task_wakes: wake`) 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 (for background tasks, which ones and how to stop them). 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.
|
|
21
21
|
|
|
22
22
|
**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.
|
|
23
23
|
|
|
@@ -143,7 +143,7 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
|
|
|
143
143
|
the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
|
|
144
144
|
`… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
|
|
145
145
|
`… The wake turn for the change in attached context pr-7 was not answered; chi starts no other wake turn until
|
|
146
|
-
the user writes.]` after a wake turn chi started (a context source's change, a delegate's report), not the user
|
|
146
|
+
the user writes.]` after a wake turn chi started (a context source's change, a delegate's report, a background task that finished), not the user
|
|
147
147
|
(for a delegate's report it adds `chi keeps the report and brings it again with the user's next message.`);
|
|
148
148
|
- cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
|
|
149
149
|
above ends where it was cut off.]` (an answer or only narration had streamed), or `…; no answer had been shown.]` when only thinking had streamed;
|
|
@@ -10,8 +10,10 @@ module Samagotchi
|
|
|
10
10
|
# the provider's raw `payload`, and no raw `content` (the chat loop's is
|
|
11
11
|
# reasoning + content again, the native loop's the raw stream with its
|
|
12
12
|
# tool-call bytes and thought tags): clients read `text`, `thinking`,
|
|
13
|
-
# `tool_call` and `counts`, never a provider's payload shape.
|
|
14
|
-
# the
|
|
13
|
+
# `tool_call` and `counts`, never a provider's payload shape.
|
|
14
|
+
# :tool_call_started omits the model's `call`; clients render its row from
|
|
15
|
+
# the other fields. :turn_completed omits `result`; `turn_summary.output`
|
|
16
|
+
# carries the answer. `?raw=1` on the worker's stream skips this (debugging).
|
|
15
17
|
#
|
|
16
18
|
# Pure: the event is the one hash SessionObserver hands every observer
|
|
17
19
|
# (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
|
|
@@ -28,13 +30,19 @@ module Samagotchi
|
|
|
28
30
|
# @param event [Hash] the engine's event (symbol keys; its payload has
|
|
29
31
|
# string keys)
|
|
30
32
|
# @return [Hash] the event as clients get it: a copy for a
|
|
31
|
-
#
|
|
33
|
+
# projected event, the same object for every other event
|
|
32
34
|
def project(event)
|
|
33
|
-
return event unless event.is_a?(Hash)
|
|
35
|
+
return event unless event.is_a?(Hash)
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
case event[:type].to_s
|
|
38
|
+
when "generation_chunk"
|
|
39
|
+
projected = event.except(:payload, :content)
|
|
40
|
+
found = counts(event[:payload])
|
|
41
|
+
found ? projected.merge(counts: found) : projected
|
|
42
|
+
when "tool_call_started" then event.except(:call)
|
|
43
|
+
when "turn_completed" then event.except(:result)
|
|
44
|
+
else event
|
|
45
|
+
end
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
# The payload's normalized counts, nil keys left out; nil when the
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -805,7 +805,8 @@ module Samagotchi
|
|
|
805
805
|
# event log held, so no POST /turn lands between its check and its
|
|
806
806
|
# answer; it leaves from its loop after this reply.
|
|
807
807
|
# 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
|
|
808
|
-
# {status: "held", reason:} naming what keeps it up
|
|
808
|
+
# {status: "held", reason:} naming what keeps it up (with tasks: [{id:,
|
|
809
|
+
# command:}] and a hint: how to stop them, for background_tasks). A past +deadline+
|
|
809
810
|
# (see #handle_post_turn), checked with the event log held: 408
|
|
810
811
|
# deadline_passed, the worker not asked (its client said it didn't stop).
|
|
811
812
|
# Returns [headers, status, body].
|
|
@@ -838,7 +839,11 @@ module Samagotchi
|
|
|
838
839
|
return [{}, 200, body]
|
|
839
840
|
end
|
|
840
841
|
|
|
841
|
-
|
|
842
|
+
# A hold may name what it waits for ({reason:, tasks:}: the background
|
|
843
|
+
# tasks a finish wake watches).
|
|
844
|
+
held = reason.is_a?(Hash) ? reason : { reason: reason }
|
|
845
|
+
[{}, 409, { status: "held", reason: held[:reason].to_s, tasks: held[:tasks], hint: held[:hint],
|
|
846
|
+
session_id: @session_id }.compact]
|
|
842
847
|
end
|
|
843
848
|
|
|
844
849
|
# Queue a turn for this session through its file IPC (the worker polls
|