samagotchi 0.53.0 → 0.55.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 +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Memory bundles: install, upgrade and build
|
|
2
|
+
|
|
3
|
+
How `lib/samagotchi/memory_bundle/` turns a bundle (a dir, git URL, zip or tar holding `manifest.yml`, `*.md`
|
|
4
|
+
memories and optional `hooks/`, `guardrails/`, a plugin file and `scripts/`) into installed state, and back. The
|
|
5
|
+
user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memory.md#what-a-bundle-owns) and
|
|
6
|
+
[plugins.md](../plugins.md).
|
|
7
|
+
|
|
8
|
+
## Where things live
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
<scope memories dir>/ MemoryPaths.scope_dir: system ~/.config/samagotchi/memories,
|
|
12
|
+
project <system>/projects/<project key>
|
|
13
|
+
*.md, index.md the bundle's memories, one managed index line each ("· from <bundle>")
|
|
14
|
+
<system memories>/.bundles/ MemoryPaths.bundles_dir
|
|
15
|
+
<name>/manifest.json the install record (Provenance#write, read as InstalledBundle)
|
|
16
|
+
<name>/bases/ each file as installed: the base of the next upgrade's 3-way merge
|
|
17
|
+
<name>/hooks/ guardrails/ plugin/ scripts/
|
|
18
|
+
.trash/<name>-<stamp>/ files an uninstall or upgrade took away (Trash), never deleted
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Components
|
|
22
|
+
|
|
23
|
+
| Piece | Role |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash`; runs the Installer, prints its summary, runs the conflict agent step |
|
|
26
|
+
| `SourceNormalizer` (source.rb) | source → local dir; a git/zip/tar extract is *owned* and removed after the install |
|
|
27
|
+
| `Manifest` | parses/validates `manifest.yml` (files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder) |
|
|
28
|
+
| `Installer` | one install/upgrade/dry run of one bundle (below) |
|
|
29
|
+
| `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
|
|
30
|
+
| `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
|
|
31
|
+
| `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases, `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
|
|
32
|
+
| `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty) |
|
|
33
|
+
| `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
|
|
34
|
+
| `Profile` | meta bundles (`includes:`): install the members, record which |
|
|
35
|
+
| `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
|
|
36
|
+
| `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
|
|
37
|
+
| `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
|
|
38
|
+
|
|
39
|
+
Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
|
|
40
|
+
`ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
|
|
41
|
+
and checks the recorded sha256 (and `requires_chi`) before loading.
|
|
42
|
+
|
|
43
|
+
## Installer#run, in order
|
|
44
|
+
|
|
45
|
+
The order matters: warnings print in it, and a plugin/script the manifest names but the bundle lacks raises
|
|
46
|
+
`InstallError` after the memories are already copied.
|
|
47
|
+
|
|
48
|
+
1. `load_source`: normalize, read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning).
|
|
49
|
+
2. `resolve_target`: scope = CLI > manifest > `system`.
|
|
50
|
+
3. `install_memories`, per `*.md`: an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
|
|
51
|
+
classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
|
|
52
|
+
same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
|
|
53
|
+
(`<name>.<key>.md`) get no index line (`note_overlay`).
|
|
54
|
+
4. `AssetInstaller#run`: hooks (manifest-listed, else every `hooks/*.rb`), prune dropped hooks, hook checksums,
|
|
55
|
+
guardrails, plugin, scripts; each set replaces the previous version's. requires_chi warnings for hooks/rules.
|
|
56
|
+
5. An upgrade's `prune_dropped_files`: unchanged → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
|
|
57
|
+
6. `verify_memory_checksums` (strict), `warn_missing_needs`.
|
|
58
|
+
7. `write_provenance` (manifest, not a dry run): bases come from the incoming file, except where the user's edit was
|
|
59
|
+
kept (kept/noop/conflict/kept_pruned, or a re-install's skipped edit): those keep the old base, so the next
|
|
60
|
+
upgrade still sees the edit.
|
|
61
|
+
8. `detect_placeholders`.
|
|
62
|
+
|
|
63
|
+
Results are `@results` (file → `{status:, reason:}`), summarised by `#summary`. An upgrade with conflicts keeps an
|
|
64
|
+
owned source dir alive (`#source_dir`) until `#cleanup_source!`, because the conflict agent reads the incoming
|
|
65
|
+
files; `Provenance#resolve_conflicts` then moves the resolved files' bases forward.
|
|
66
|
+
|
|
67
|
+
## Builder#run, in order
|
|
68
|
+
|
|
69
|
+
`select_memories` (all but `index.md`/hidden; with `--files` the named ones + their overlays, else leaving out files
|
|
70
|
+
another installed bundle owns) → `checksum_memories` → `installed_parts` (`Builder::InstalledParts`: the same-name
|
|
71
|
+
installed bundle's hooks, rules, plugin, scripts, context providers, else the scope dir's `hooks/`/`guardrails/`) →
|
|
72
|
+
`stage_files` + `write_manifest` in a temp dir → `write_out` (zip, tar, tgz, or a new/empty dir). An installed
|
|
73
|
+
profile's name is refused (`refuse_profile!`): a build can't remake its includes.
|
|
@@ -5,7 +5,7 @@ web/SSE clients) as a `:context_status` stream event. The telemetry itself is
|
|
|
5
5
|
not injected into the model's conversation; what the model gets is one short
|
|
6
6
|
line, as a tail system message (`kind: context`), when usage rises into a bucket
|
|
7
7
|
whose guidance asks it to change how it works (from the second threshold, 40%
|
|
8
|
-
by default, up; never on a fall
|
|
8
|
+
by default, up; never on a fall, and not again for a bucket a
|
|
9
9
|
resumed session's line already names):
|
|
10
10
|
|
|
11
11
|
`[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted and range reads over full-file dumps]`
|
|
@@ -19,8 +19,11 @@ carries a `status` string using this prefix:
|
|
|
19
19
|
|
|
20
20
|
Emission behavior:
|
|
21
21
|
|
|
22
|
-
-
|
|
23
|
-
|
|
22
|
+
- The estimate is emitted on every request (both loops, before each request),
|
|
23
|
+
so the web and the attached TUI get a fresh number each time.
|
|
24
|
+
- The model's own `[CONTEXT: …]` line is still gated: it is left only on a rise
|
|
25
|
+
into a guidance bucket (from the second threshold up), never on a fall, and
|
|
26
|
+
not again for a bucket a resumed session's line already names.
|
|
24
27
|
- This is warn-only behavior (no automatic history truncation).
|
|
25
28
|
- When the model server reports real `usage` fields in the stream payload, the
|
|
26
29
|
telemetry uses those actual token counts (prefixed `src=server`) instead of the
|
|
@@ -53,7 +56,25 @@ both loops):
|
|
|
53
56
|
Configuration:
|
|
54
57
|
|
|
55
58
|
- `SAMAGOTCHI_CONTEXT_STATUS` (`true` by default): set to `false` or `0` to disable telemetry.
|
|
56
|
-
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` / `context.window_tokens`: context window size for when the server
|
|
59
|
+
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` / `context.window_tokens`: context window size for when neither the server nor the model's or host's config gives one (step 4 of the lookup below).
|
|
60
|
+
- `models.<key>.window_tokens` / `hosts.<name>.window_tokens` (config file only): the window for one model, or for every model on a host (step 3 below).
|
|
57
61
|
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (default `4.0`): heuristic ratio for char-to-token estimation.
|
|
58
|
-
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` (default `20,40,60,80`): comma-separated threshold percentages.
|
|
59
|
-
|
|
62
|
+
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` / `context.status_thresholds` (default `20,40,60,80`): comma-separated threshold percentages. They set the buckets, and the buckets' guidance gates the model's own `[CONTEXT: …]` line. (`context.status_cadence` is gone: the event streams on every request.)
|
|
63
|
+
|
|
64
|
+
The `context.*` settings are read once, when the turn's `ContextStatus` is built, so a change counts from the next turn.
|
|
65
|
+
|
|
66
|
+
Context window lookup (`ContextWindow.resolve`, the first that answers wins; the event's `window_source` / the line's
|
|
67
|
+
`window_src` names it):
|
|
68
|
+
|
|
69
|
+
1. The running server (`Client#context_window`): llama.cpp's `/props`, the per-slot `n_ctx` (`:server`). A remote
|
|
70
|
+
chat host has no `/props`, so the chat loop and the engine skip this step for it.
|
|
71
|
+
2. The host's model list (the chat adapter's `#context_window`, e.g. a provider's `context_length`; `:model_list`).
|
|
72
|
+
3. The config file's `models.<key>.window_tokens` (by the model's lookup names, as sampling and vision look a model
|
|
73
|
+
up; `:model_setting`), else `hosts.<name>.window_tokens` (`:host_setting`). `ContextWindow.setting` reads it per
|
|
74
|
+
turn for the effective model, after the hooks that may switch it.
|
|
75
|
+
4. `context.window_tokens`: CLI, `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` or the config file (`:config` or `:env`).
|
|
76
|
+
5. `ContextWindow::DEFAULT_TOKENS`, 256000 (`:default`).
|
|
77
|
+
|
|
78
|
+
Settings (3 and 4) only fill in when the server and its list report nothing (mlx, oMLX, a server that is down). A
|
|
79
|
+
window the stream's `usage` reports itself (`n_ctx` / `context_window`) wins over all of these for that request
|
|
80
|
+
(`window_src=server`). `chi self` shows the offline answer (steps 3 to 5) and which setting gave it.
|
data/docs/internals/engine.md
CHANGED
|
@@ -16,14 +16,14 @@ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoo
|
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
|
|
19
|
-
pending_input:, continue:, origin:, images:)
|
|
19
|
+
pending_input:, continue:, origin:, images:, id:)
|
|
20
20
|
│
|
|
21
|
-
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
|
|
21
|
+
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:)
|
|
22
22
|
│ # @session = session, session.status = RUNNING, used memories absorbed,
|
|
23
23
|
│ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
|
|
24
24
|
│ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
|
|
25
25
|
│ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
|
|
26
|
-
│ # origin, started_at, id}
|
|
26
|
+
│ # origin, started_at, id (the caller's id:, else a new uuid)}
|
|
27
27
|
│
|
|
28
28
|
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
29
29
|
│
|
|
@@ -39,7 +39,9 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
39
39
|
│ # Returns false when a Stop came during the probes or the init wait, else true
|
|
40
40
|
│
|
|
41
41
|
├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
42
|
-
│ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name
|
|
42
|
+
│ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name, window_setting,
|
|
43
|
+
│ # llm_context (turn_llm_context: the session's, the model's/host's, else llm_context.strategy)
|
|
44
|
+
│ # and price (turn_price: hosts.<name>.models.<id>.price), all read per turn
|
|
43
45
|
│ # turn.limit = max_iterations or IterationLimit.for
|
|
44
46
|
│ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
|
|
45
47
|
│ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
|
|
@@ -181,6 +183,7 @@ Both loops run a batch of calls the same way:
|
|
|
181
183
|
One iteration with tool calls:
|
|
182
184
|
KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
|
|
183
185
|
→ ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
186
|
+
# chat passes a block, called with each run as it finishes
|
|
184
187
|
→ :tool_dispatch_started {iteration, call_count}
|
|
185
188
|
→ for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
|
|
186
189
|
on_stream_event:, max_tool_output_chars:)
|
|
@@ -192,15 +195,24 @@ One iteration with tool calls:
|
|
|
192
195
|
→ returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
|
|
193
196
|
shown_label:, diff:}
|
|
194
197
|
→ :tool_dispatch_completed {iteration, call_count}
|
|
195
|
-
→ native: ToolResponse.joined(runs), one entry with the capped outputs
|
|
196
|
-
chat: ToolResponse.single(run, tool_call_id:)
|
|
198
|
+
→ native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
|
|
199
|
+
chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
|
|
200
|
+
capped output
|
|
201
|
+
ids: ToolIds.next_ids, chi's own ids for the outputs ("t41"), saved on the entry as tool_ids
|
|
202
|
+
(one per call of the batch for native, one for chat); LLM context edits name outputs by them
|
|
197
203
|
```
|
|
198
204
|
|
|
199
205
|
**ToolRunner** (`ToolRunner#run`):
|
|
200
206
|
- Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
|
|
201
207
|
replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
|
|
202
208
|
- Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
|
|
203
|
-
title, view}.
|
|
209
|
+
title, called_as, view, ref}. The row fields (title, called_as, view, ref) come from `ToolRowFields.for`, which the
|
|
210
|
+
reload (`Web::MessageParts#tool_part`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as) travel
|
|
211
|
+
inside `activity` on the completed event; `EVENT_KEYS` (view, ref) go top-level on both events.
|
|
212
|
+
`Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
|
|
213
|
+
`EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
|
|
214
|
+
read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
|
|
215
|
+
that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
|
|
204
216
|
- Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
|
|
205
217
|
- Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
|
|
206
218
|
`KernelLoop#dispatch` ("Error: no such tool ...", never repeating the wrong name).
|
|
@@ -211,7 +223,14 @@ One iteration with tool calls:
|
|
|
211
223
|
with `[cut: N of M chars; read it in parts]` (N kept of M, the whole within the cap unless the cap is shorter
|
|
212
224
|
than that line). The model (both loops), the event and the after_tool_call hook get that text.
|
|
213
225
|
- Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, activity,
|
|
214
|
-
images, diff, waited_ms, view}, then fires `:after_tool_call` {iteration, tool, output, status}.
|
|
226
|
+
images, diff, waited_ms, view, ref}, then fires `:after_tool_call` {iteration, tool, output, status}.
|
|
227
|
+
- The activity's status (`ToolActivity#tool_activity_status`): `error` for an `Error:` result and for an
|
|
228
|
+
execute that exited non-zero, except a grep-family search that found nothing (`ToolActivity.no_match?`:
|
|
229
|
+
exit 1, no stderr block, the last step is grep/egrep/fgrep/rg/ag/pgrep, no `&&` after a step that can fail
|
|
230
|
+
silently or sends output to /dev/null, no background `&`, no earlier exit/return or `set -e`; when unsure,
|
|
231
|
+
not a no-match). That one stays `ok` with `no_match: true` on the activity, which SessionMetrics keeps on the tool
|
|
232
|
+
record and the Bridge's TurnAccumulator on the part, for the web row's "no match" word. The model's text is
|
|
233
|
+
unchanged.
|
|
215
234
|
|
|
216
235
|
A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
|
|
217
236
|
`stop_turn`, the gate denies every remaining call of the batch.
|
|
@@ -292,8 +311,8 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
292
311
|
|---|---|---|
|
|
293
312
|
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images?}` |
|
|
294
313
|
| `:reminder_injected` | due reminders went into the turn | `{reminders}` |
|
|
295
|
-
| `:context_status` | the
|
|
296
|
-
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source}` (native adds `profile`, `profile_source`) |
|
|
314
|
+
| `:context_status` | every request (unless `context.status` is off), the current estimate before it | `{iteration, status, usage, bucket, source}` (internals/context-telemetry.md) |
|
|
315
|
+
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price}` (`price`: the turn's `ModelPrice#to_h`, nil without one; native adds `profile`, `profile_source`) |
|
|
297
316
|
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
|
|
298
317
|
| `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
|
|
299
318
|
| `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
|
|
@@ -331,14 +350,14 @@ Engine initialization (in this order, among other things):
|
|
|
331
350
|
@host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
|
|
332
351
|
@given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
|
|
333
352
|
@guardrail_wiring, @question_desk
|
|
334
|
-
@hooks =
|
|
335
|
-
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins)
|
|
336
|
-
@kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
353
|
+
@extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
|
|
354
|
+
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins)
|
|
355
|
+
wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
337
356
|
@kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
|
|
338
357
|
@hooks.runtime = hook_runtime
|
|
339
358
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
340
359
|
@prompt_builder = SystemPrompt.new(...)
|
|
341
|
-
@session_observer with the metrics, the log subscriber and the thinking tails subscribed
|
|
360
|
+
@session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
|
|
342
361
|
|
|
343
362
|
Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
|
|
344
363
|
→ checks the host (ModelProfile.check_host!), sets @effective_model_name
|
|
@@ -361,7 +380,7 @@ puts the hook runtime on the event: `event[:hook]` (the hook's label) and five h
|
|
|
361
380
|
|
|
362
381
|
| Helper | What it does | Used by |
|
|
363
382
|
|---|---|---|
|
|
364
|
-
| `event[:notify].call(text, level:)` | One line to the user (`:hook_notice`) | mcp (a server didn't start), loop-guard |
|
|
383
|
+
| `event[:notify].call(text, level:, fallback_for:)` | One line to the user (`:hook_notice`); `fallback_for: :display` marks a line that stands in for the answer's display, which a UI that renders the display's links leaves out | mcp (a server didn't start), loop-guard |
|
|
365
384
|
| `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question through the question flow; nil when there is no one to ask | known-names (a near-miss name: correct it?) |
|
|
366
385
|
| `event[:stop_turn].call(reason)` | Cancel the running turn (reason `:hook`), with a warn notice | loop-guard (repeated calls), check-in (`/checkin stop`) |
|
|
367
386
|
| `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard (thinking repeats itself) |
|
|
@@ -387,9 +406,18 @@ From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `st
|
|
|
387
406
|
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
|
|
388
407
|
the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
|
|
389
408
|
known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
390
|
-
`event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call
|
|
391
|
-
|
|
392
|
-
|
|
409
|
+
`event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call,
|
|
410
|
+
in `GuardrailWiring#checks` order:
|
|
411
|
+
|
|
412
|
+
1. load failures (`Guardrails::LoadFailures`): a required hook or rule file that failed to load denies every call;
|
|
413
|
+
2. `rules.hook_asks`: with guardrails disabled, a hook's ask is dropped (a deny still applies);
|
|
414
|
+
3. protected paths;
|
|
415
|
+
4. `ScratchWrites` (a `chi scratch` session only): write and edit under the memories folder are denied;
|
|
416
|
+
5. `ChildBoundary` (a delegate child only): a change outside its folder asks;
|
|
417
|
+
6. the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match the tool, its
|
|
418
|
+
targets and the model).
|
|
419
|
+
|
|
420
|
+
A gate that raises denies the call.
|
|
393
421
|
|
|
394
422
|
The verdict is one of:
|
|
395
423
|
|
|
@@ -430,8 +458,9 @@ Both loops:
|
|
|
430
458
|
with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
|
|
431
459
|
steer and spends nothing.
|
|
432
460
|
- **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`).
|
|
433
|
-
- **Context status**: an estimate of the window's fill before each request
|
|
434
|
-
|
|
461
|
+
- **Context status**: an estimate of the window's fill before each request,
|
|
462
|
+
emitted on every request (`:context_status`) so the UI always has a current
|
|
463
|
+
number, with a guidance line on the tail only when it rises far enough.
|
|
435
464
|
- **Cancel**: text streamed before a cancel stays, marked `[interrupted]`.
|
|
436
465
|
- **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
|
|
437
466
|
and tool call hooks.
|
|
@@ -449,10 +478,11 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
449
478
|
`FailedTurn.attach`; after a Ctrl-C, only `turn.messages`.
|
|
450
479
|
4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers are
|
|
451
480
|
persistent.
|
|
452
|
-
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
481
|
+
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
|
|
482
|
+
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
483
|
+
only on a model switch, a tools change or a profile change. The stable part (base prompt, identity,
|
|
484
|
+
memories, AGENT.md, memory indexes) comes before the volatile tail (model, working directory, session).
|
|
485
|
+
prompt-caching.md covers this in detail.
|
|
456
486
|
|
|
457
487
|
## Key files
|
|
458
488
|
|
|
@@ -476,6 +506,7 @@ Paths are under `lib/samagotchi/`.
|
|
|
476
506
|
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
477
507
|
| `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
|
|
478
508
|
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
509
|
+
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
|
479
510
|
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
480
511
|
| `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
|
|
481
512
|
| `cancellation_controller.rb` | A turn's cancel signal, and a generation's child controller |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Read Tool Size Guardrails
|
|
4
4
|
|
|
5
|
-
The `read` tool
|
|
5
|
+
The `read` tool applies adaptive limits to avoid accidental context exhaustion
|
|
6
6
|
when opening very large files (for example, VCR cassettes).
|
|
7
7
|
|
|
8
8
|
Behavior:
|
|
@@ -11,7 +11,8 @@ Behavior:
|
|
|
11
11
|
- Large files: return a head+tail preview plus truncation metadata.
|
|
12
12
|
- Extremely large files: return an error indicating the hard size limit.
|
|
13
13
|
|
|
14
|
-
Configuration
|
|
14
|
+
Configuration (each is also a config.yml key, `read.truncate_at_bytes` and so on, and a CLI
|
|
15
|
+
flag such as `--read-truncate-at-bytes`; see docs/configuration.md):
|
|
15
16
|
|
|
16
17
|
- `SAMAGOTCHI_READ_TRUNCATE_AT_BYTES` (default `65536`): files above this size return a preview instead of full content.
|
|
17
18
|
- `SAMAGOTCHI_READ_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
|
|
@@ -23,8 +24,8 @@ Optional preview telemetry:
|
|
|
23
24
|
|
|
24
25
|
Telemetry uses existing context estimation settings:
|
|
25
26
|
|
|
26
|
-
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS`
|
|
27
|
-
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN`
|
|
27
|
+
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` (`context.window_tokens`)
|
|
28
|
+
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (`context.chars_per_token`)
|
|
28
29
|
|
|
29
30
|
## Execute Tool Output Guardrails
|
|
30
31
|
|
|
@@ -33,13 +34,14 @@ The `execute` tool applies the same guardrail model to command output:
|
|
|
33
34
|
- Small stdout/stderr: returned in full.
|
|
34
35
|
- Large stdout/stderr: returned as head+tail previews with truncation metadata.
|
|
35
36
|
|
|
36
|
-
Configuration:
|
|
37
|
+
Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
|
|
37
38
|
|
|
38
39
|
- `SAMAGOTCHI_EXECUTE_TRUNCATE_AT_BYTES` (default `65536`): output above this size is truncated.
|
|
39
40
|
- `SAMAGOTCHI_EXECUTE_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
|
|
40
41
|
- `SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT` (default `80`): include estimated output token impact only when threshold is crossed.
|
|
42
|
+
- `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): a command running longer is stopped and answers `Error: command timed out after …s`.
|
|
41
43
|
|
|
42
44
|
Implementation note:
|
|
43
45
|
|
|
44
|
-
- Shared logic lives in `lib/samagotchi/tools/output_guardrails.rb` and is used by
|
|
46
|
+
- Shared logic lives in `lib/samagotchi/tools/output_guardrails.rb` and is used by `read` and `execute`, and by `edit` (its env switches) and `delegate_wait` (the head+tail preview of a child's long reply).
|
|
45
47
|
- Additional tools that can emit large payloads should rely on this shared helper for consistent behavior.
|
data/docs/plugins.md
CHANGED
|
@@ -1210,7 +1210,8 @@ A plain read keeps no version.
|
|
|
1210
1210
|
**The nudge** (`nudge: true`). Some models, finding a skill's step broken,
|
|
1211
1211
|
skip it and go on without fixing the skill. In a turn that read a skill
|
|
1212
1212
|
(`memory_read` of a `skill_*` name, or `read` of its file), the first failing
|
|
1213
|
-
tool call after it (an `execute` that exited non-zero, a
|
|
1213
|
+
tool call after it (an `execute` that exited non-zero, except a grep-family
|
|
1214
|
+
search that found nothing, a tool error) steers
|
|
1214
1215
|
the model once (a call a guardrail or the user denied doesn't count as a
|
|
1215
1216
|
failed step): *"A step of skill release failed. Find out why before skipping
|
|
1216
1217
|
it; if the skill is out of date, fix it now: edit the step that changed in
|
data/docs/sessions.md
CHANGED
|
@@ -68,9 +68,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
68
68
|
**Ordering:**
|
|
69
69
|
|
|
70
70
|
- `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.
|
|
71
|
-
- 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). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. 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 (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 model picked; 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).
|
|
71
|
+
- 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). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. 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 (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 model picked; 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).
|
|
72
72
|
- 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).
|
|
73
|
-
- 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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`).
|
|
73
|
+
- 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the actions per kind, one delegated click). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
74
74
|
- 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).
|
|
75
75
|
|
|
76
76
|
**Test-session hygiene:**
|
|
@@ -204,7 +204,7 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
|
|
|
204
204
|
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
205
205
|
- **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
|
|
206
206
|
- **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
|
|
207
|
-
- **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`
|
|
207
|
+
- **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
|
|
208
208
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
209
209
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model names a host config.yml no longer has. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
210
210
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and not `dir`, `model` or `llm_context` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a model whose host is gone; 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
data/docs/testing.md
CHANGED
|
@@ -15,12 +15,32 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
|
|
|
15
15
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
16
16
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
17
17
|
|
|
18
|
-
The web specs run in plain node. One that
|
|
19
|
-
`vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a
|
|
20
|
-
never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
|
|
18
|
+
The web specs run in plain node. One that loads lit-html (`turn_view.js`, `thinking_chip.js`, `llm_context_chip.js`,
|
|
19
|
+
`info_bar.js`, `lists_view.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a
|
|
20
|
+
devDependency only, never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
|
|
21
21
|
without lit's marker comments. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
|
|
22
22
|
`?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
|
|
23
23
|
|
|
24
|
+
## While you work: affected specs
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
script/affected-specs # the specs for this branch's change, best first, at most 10
|
|
28
|
+
bundle exec rspec $(script/affected-specs)
|
|
29
|
+
script/affected-specs --why # with the reason for each pick; --all: no cap; --run: runs them
|
|
30
|
+
script/affected-specs A..B # another range (A...B: from their merge-base); a single REV: vs the working tree
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
|
|
34
|
+
files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
|
|
35
|
+
docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the specs that
|
|
36
|
+
name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
|
|
37
|
+
The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" and a
|
|
38
|
+
hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
|
|
39
|
+
|
|
40
|
+
It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
|
|
41
|
+
(`bundle exec parallel_rspec -n 8`), unless your task says the coordinator runs it; the coordinator runs it at merge
|
|
42
|
+
either way.
|
|
43
|
+
|
|
24
44
|
## Worktrees
|
|
25
45
|
|
|
26
46
|
Each fix or feature gets its own worktree next to the main checkout:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../events"
|
|
4
4
|
require_relative "../kernel_loop"
|
|
5
5
|
require_relative "../steer"
|
|
6
|
+
require_relative "../tool_row_fields"
|
|
6
7
|
require_relative "turn_notice"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -158,9 +159,10 @@ module Samagotchi
|
|
|
158
159
|
def self.tool_replay_events(part)
|
|
159
160
|
call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
|
|
160
161
|
call[:label] = part[:label] if part[:label]
|
|
161
|
-
# Its
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
# Its row fields (ToolRowFields), each in its place: title and, for
|
|
163
|
+
# an alias's call, the name the model used, also in the activity.
|
|
164
|
+
title = part.slice(*ToolRowFields::ACTIVITY_KEYS).compact
|
|
165
|
+
view = part.slice(*ToolRowFields::EVENT_KEYS).compact
|
|
164
166
|
events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
|
|
165
167
|
if part[:status] == "running"
|
|
166
168
|
events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
|
|
@@ -169,6 +171,7 @@ module Samagotchi
|
|
|
169
171
|
|
|
170
172
|
activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
|
|
171
173
|
activity[:action] = part[:action] if part[:action]
|
|
174
|
+
activity[:no_match] = true if part[:no_match]
|
|
172
175
|
completed = { type: :tool_call_completed, **call, output: part[:output], output_truncated: !!part[:output_truncated],
|
|
173
176
|
activity: activity }
|
|
174
177
|
completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
|
|
@@ -274,9 +277,7 @@ module Samagotchi
|
|
|
274
277
|
part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
|
|
275
278
|
tool: event[:tool], params: event[:params], status: "running" }
|
|
276
279
|
part[:label] = event[:label] if event[:label]
|
|
277
|
-
part[
|
|
278
|
-
part[:called_as] = event[:called_as] if event[:called_as]
|
|
279
|
-
part[:view] = event[:view] if event[:view]
|
|
280
|
+
ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
|
|
280
281
|
parts << part
|
|
281
282
|
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
282
283
|
when :tool_call_completed
|
|
@@ -288,6 +289,7 @@ module Samagotchi
|
|
|
288
289
|
output = event[:output].to_s
|
|
289
290
|
capped = output.length > @max_output_chars
|
|
290
291
|
tool[:status] = (event.dig(:activity, :status) || "ok").to_s
|
|
292
|
+
tool[:no_match] = true if event.dig(:activity, :no_match)
|
|
291
293
|
tool[:output] = capped ? output[0, @max_output_chars] : output.dup
|
|
292
294
|
tool[:output_truncated] = capped || !!event[:output_truncated]
|
|
293
295
|
tool[:images] = event[:images] if event[:images]
|