samagotchi 0.54.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 +21 -1
- data/bin/chi +3 -2
- data/docs/configuration.md +29 -0
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +40 -18
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +2 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- 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 +25 -2
- 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 +33 -18
- data/lib/samagotchi/web/public/app.js +237 -1133
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +5 -0
- 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_cards.js +2 -1
- 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/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +11 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f90692cf867e2bd082f40c2ae576604656d23169163ed5584f9221ccec528ac1
|
|
4
|
+
data.tar.gz: ac4852809e3ccdc332dd0d052e17671096eb167691cdde6f01fc2ef6e43b9870
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 541c55286cdad591988db3b43af6c60480061f931b418c83ff44afe3fe62e677df638021f082ce42241bd1f0145ca3a88173f9046341e9d282351cdc92814a1c
|
|
7
|
+
data.tar.gz: ae24243b3978c3f8f87a655c50d9f446128618b63d6dc5b10923f09eef90d11fcd4bee714692d287157340029f83097476c7d057f27c8ddff98c86f30678758b
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.55.0] - 2026-10-10
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: refs. A file tool's row (read, edit, write) and the lines of its diff open the file in your editor: they look
|
|
16
|
+
like text until hovered (a dotted underline and `↗`, a diff line's `↗ N`), and a click opens the current file at the
|
|
17
|
+
line read or edited, the diff line's line or the hunk's start. Only a viewer on this machine gets them (a loopback
|
|
18
|
+
request, or this machine on its LAN address); the phone sees plain text. `web.editor` picks the editor: `vscode`
|
|
19
|
+
(the default), `vscode-insiders`, `cursor`, `zed`, `none`, or a URL template with `{path}` and `{line}`. The
|
|
20
|
+
browser and VS Code each ask once to allow it.
|
|
21
|
+
- For development: `script/affected-specs`, the spec files to run for a change (changed specs, name matches,
|
|
22
|
+
docs-reading specs, specs naming the changed class), ranked and capped. It isn't part of the gem. See
|
|
23
|
+
[docs/testing.md](docs/testing.md).
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Web: a page that joins mid-turn (or reloads during one) keeps a grep that found nothing as a muted "no match" row,
|
|
28
|
+
as it showed live; it used to lose the mark.
|
|
29
|
+
|
|
11
30
|
## [0.54.0] - 2026-10-10
|
|
12
31
|
|
|
13
32
|
### Changed
|
|
@@ -2442,7 +2461,8 @@ and long-lived sessions.
|
|
|
2442
2461
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
2443
2462
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
2444
2463
|
|
|
2445
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
2464
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.55.0...HEAD
|
|
2465
|
+
[0.55.0]: https://github.com/dm1try/samagotchi/compare/v0.54.0...v0.55.0
|
|
2446
2466
|
[0.54.0]: https://github.com/dm1try/samagotchi/compare/v0.53.0...v0.54.0
|
|
2447
2467
|
[0.53.0]: https://github.com/dm1try/samagotchi/compare/v0.52.0...v0.53.0
|
|
2448
2468
|
[0.52.0]: https://github.com/dm1try/samagotchi/compare/v0.51.0...v0.52.0
|
data/bin/chi
CHANGED
|
@@ -137,7 +137,7 @@ parser = OptionParser.new do |opts|
|
|
|
137
137
|
"every session outside a repo) or all") { |v| options[:scope] = v }
|
|
138
138
|
# Accepted values of string entries that the registry doesn't type as enums.
|
|
139
139
|
value_hints = { "status.line" => "on|off", "web.host" => "127.0.0.1|::1|localhost|lan|IP", "recap.sentences" => "N|N-M",
|
|
140
|
-
"web.annotate_presets" => "text|text" }
|
|
140
|
+
"web.annotate_presets" => "text|text", "web.editor" => "vscode|vscode-insiders|cursor|zed|none|URL" }
|
|
141
141
|
# Universal config flags (implicit convention: ENV SAMAGOTCHI_* ↔ YAML dotted ↔ CLI --kebab)
|
|
142
142
|
# Generated from Samagotchi::Config registry — Option A: snake leaf in YAML, kebab in CLI.
|
|
143
143
|
Samagotchi::Config.cli_entries.each do |entry|
|
|
@@ -235,7 +235,8 @@ if options[:web]
|
|
|
235
235
|
view = Samagotchi::Config.get("web.view")
|
|
236
236
|
exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
|
|
237
237
|
markdown: markdown, view: view, new_token: options[:new_token],
|
|
238
|
-
annotate_presets: Samagotchi::Config.get("web.annotate_presets")
|
|
238
|
+
annotate_presets: Samagotchi::Config.get("web.annotate_presets"),
|
|
239
|
+
editor: Samagotchi::Config.get("web.editor"))
|
|
239
240
|
end
|
|
240
241
|
|
|
241
242
|
# --resume and --attach take a unique id prefix too, like git.
|
data/docs/configuration.md
CHANGED
|
@@ -1094,6 +1094,34 @@ its own). The first target is a long session such as a day-long coordinator, whe
|
|
|
1094
1094
|
turn that runs a layer or whose session set its strategy) and the web's chip. A woken worker's status line counts
|
|
1095
1095
|
its saved context against the session's budget.
|
|
1096
1096
|
|
|
1097
|
+
## Opening files from chi web
|
|
1098
|
+
|
|
1099
|
+
In `chi web`, a file tool's row (read, edit, write) and the lines of its diff are refs to that file. They look like
|
|
1100
|
+
text until hovered (the title gets a dotted underline and `↗`, a diff line an accent bar and `↗ N`); a click opens the
|
|
1101
|
+
file in your editor: a row's title at the lines it read or edited (an edit without a range at its first changed
|
|
1102
|
+
line, a write at line 1), a diff line at its line, a hunk header at the hunk's start. A drag that selects text isn't
|
|
1103
|
+
a click. `web.editor` picks what opens:
|
|
1104
|
+
|
|
1105
|
+
```yaml
|
|
1106
|
+
web:
|
|
1107
|
+
editor: vscode # or vscode-insiders, cursor, zed, none
|
|
1108
|
+
# editor: "idea://open?file={path}&line={line}" # a template of your own
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
- A preset, or a URL template with `{path}` (the absolute path, each segment URL-encoded, the slashes kept) and an
|
|
1112
|
+
optional `{line}` (1 when there is none). A value that is neither a preset nor has `{path}` logs a warning and
|
|
1113
|
+
counts as `none`. A change needs a `chi web` restart.
|
|
1114
|
+
- A template that puts `{path}` in a query (`?file={path}`) gets the path's slashes unencoded; editors accept that.
|
|
1115
|
+
- The first click asks twice, once each: the browser asks to open the editor's links (choose "always allow"; Safari
|
|
1116
|
+
and Chrome ask per origin, so another `chi web` port asks again), and VS Code asks to allow opening local files.
|
|
1117
|
+
- It opens the file as it is now, not the agent's copy: after later edits a diff line's number can point elsewhere,
|
|
1118
|
+
and a file in a worktree that was removed won't open. The row's diff and output are what the agent saw.
|
|
1119
|
+
- Only a viewer on this machine gets refs: a loopback request, or this machine on its own LAN address
|
|
1120
|
+
(`web.host: lan`, the LAN URL or its QR code opened here). A phone sees plain text. An SSH tunnel or a local
|
|
1121
|
+
reverse proxy (`tailscale serve`) looks like loopback, so a remote viewer behind one gets refs that can't open.
|
|
1122
|
+
- `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
|
|
1123
|
+
`editor_url`, the template (null otherwise).
|
|
1124
|
+
|
|
1097
1125
|
## All settings
|
|
1098
1126
|
|
|
1099
1127
|
Every setting below takes the three forms described in
|
|
@@ -1208,6 +1236,7 @@ described in their own sections.
|
|
|
1208
1236
|
| `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
|
|
1209
1237
|
| `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
|
|
1210
1238
|
| `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
|
|
1239
|
+
| `web.editor` | `vscode` | yes | What a file ref in `chi web` (a file tool row's title, a diff line) opens, for a viewer on this machine: a preset (`vscode`, `vscode-insiders`, `cursor`, `zed`) or a URL template with `{path}` and an optional `{line}`; `none` turns refs off. See [Opening files from chi web](#opening-files-from-chi-web). |
|
|
1211
1240
|
| `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
|
|
1212
1241
|
| `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
|
|
1213
1242
|
| `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
|
|
@@ -22,9 +22,8 @@ Emission behavior:
|
|
|
22
22
|
- The estimate is emitted on every request (both loops, before each request),
|
|
23
23
|
so the web and the attached TUI get a fresh number each time.
|
|
24
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
|
|
26
|
-
|
|
27
|
-
names.
|
|
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.
|
|
28
27
|
- This is warn-only behavior (no automatic history truncation).
|
|
29
28
|
- When the model server reports real `usage` fields in the stream payload, the
|
|
30
29
|
telemetry uses those actual token counts (prefixed `src=server`) instead of the
|
|
@@ -57,6 +56,25 @@ both loops):
|
|
|
57
56
|
Configuration:
|
|
58
57
|
|
|
59
58
|
- `SAMAGOTCHI_CONTEXT_STATUS` (`true` by default): set to `false` or `0` to disable telemetry.
|
|
60
|
-
- `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).
|
|
61
61
|
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (default `4.0`): heuristic ratio for char-to-token estimation.
|
|
62
|
-
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` (default `20,40,60,80`): comma-separated threshold percentages.
|
|
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,7 @@ 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}.
|
|
215
227
|
- The activity's status (`ToolActivity#tool_activity_status`): `error` for an `Error:` result and for an
|
|
216
228
|
execute that exited non-zero, except a grep-family search that found nothing (`ToolActivity.no_match?`:
|
|
217
229
|
exit 1, no stderr block, the last step is grep/egrep/fgrep/rg/ag/pgrep, no `&&` after a step that can fail
|
|
@@ -299,8 +311,8 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
299
311
|
|---|---|---|
|
|
300
312
|
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images?}` |
|
|
301
313
|
| `:reminder_injected` | due reminders went into the turn | `{reminders}` |
|
|
302
|
-
| `:context_status` | every request, the current estimate before it | `{iteration,
|
|
303
|
-
| `: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`) |
|
|
304
316
|
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
|
|
305
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?}` |
|
|
306
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` |
|
|
@@ -368,7 +380,7 @@ puts the hook runtime on the event: `event[:hook]` (the hook's label) and five h
|
|
|
368
380
|
|
|
369
381
|
| Helper | What it does | Used by |
|
|
370
382
|
|---|---|---|
|
|
371
|
-
| `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 |
|
|
372
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?) |
|
|
373
385
|
| `event[:stop_turn].call(reason)` | Cancel the running turn (reason `:hook`), with a warn notice | loop-guard (repeated calls), check-in (`/checkin stop`) |
|
|
374
386
|
| `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard (thinking repeats itself) |
|
|
@@ -394,9 +406,18 @@ From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `st
|
|
|
394
406
|
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
|
|
395
407
|
the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
|
|
396
408
|
known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
397
|
-
`event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call
|
|
398
|
-
|
|
399
|
-
|
|
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.
|
|
400
421
|
|
|
401
422
|
The verdict is one of:
|
|
402
423
|
|
|
@@ -457,10 +478,11 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
457
478
|
`FailedTurn.attach`; after a Ctrl-C, only `turn.messages`.
|
|
458
479
|
4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers are
|
|
459
480
|
persistent.
|
|
460
|
-
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
464
486
|
|
|
465
487
|
## Key files
|
|
466
488
|
|
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:**
|
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?
|
|
@@ -275,9 +277,7 @@ module Samagotchi
|
|
|
275
277
|
part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
|
|
276
278
|
tool: event[:tool], params: event[:params], status: "running" }
|
|
277
279
|
part[:label] = event[:label] if event[:label]
|
|
278
|
-
part[
|
|
279
|
-
part[:called_as] = event[:called_as] if event[:called_as]
|
|
280
|
-
part[:view] = event[:view] if event[:view]
|
|
280
|
+
ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
|
|
281
281
|
parts << part
|
|
282
282
|
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
283
283
|
when :tool_call_completed
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -250,6 +250,8 @@ module Samagotchi
|
|
|
250
250
|
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn]),
|
|
251
251
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
252
252
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
|
|
253
|
+
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
254
|
+
Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli]),
|
|
253
255
|
|
|
254
256
|
Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli]),
|
|
255
257
|
Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli]),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/read"
|
|
4
|
+
require_relative "tools/write"
|
|
5
|
+
require_relative "tools/edit"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# A ref to the file a read/write/edit call touched, for a UI to trace it
|
|
9
|
+
# (the web opens it in this machine's editor): its absolute path (the
|
|
10
|
+
# call's path against the session's cwd) and the lines the call named (a
|
|
11
|
+
# range read or a range edit). A ref is a kind and a target; what a click
|
|
12
|
+
# does is the UI's (refs.js keeps the actions per kind), so nothing here
|
|
13
|
+
# names an editor.
|
|
14
|
+
FileRef = Data.define(:path, :line, :end_line) do
|
|
15
|
+
# @return [Hash] {kind: "file", path:, line:, end_line:}, the lines only
|
|
16
|
+
# when there are some
|
|
17
|
+
def to_h = { kind: FileRef::KIND, **super.compact }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class FileRef
|
|
21
|
+
KIND = "file"
|
|
22
|
+
|
|
23
|
+
TOOLS = [Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME].freeze
|
|
24
|
+
|
|
25
|
+
# @param cwd [String, nil] the session's working directory; without one
|
|
26
|
+
# a relative path gets no ref (an absolute or ~ path still does)
|
|
27
|
+
# @return [FileRef, nil] nil for a tool that isn't a file tool and for a
|
|
28
|
+
# path it can't place. +line+: the call's start_line when positive;
|
|
29
|
+
# +end_line+: its end_line, only when past +line+.
|
|
30
|
+
def self.for(tool_name, call, cwd:)
|
|
31
|
+
return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
path = absolute((tool_name == Tools::Read::NAME ? call[:content] : call[:path]).to_s.strip, cwd)
|
|
34
|
+
return nil unless path
|
|
35
|
+
|
|
36
|
+
line = positive(call[:start_line])
|
|
37
|
+
end_line = line && positive(call[:end_line])
|
|
38
|
+
new(path: path, line: line, end_line: end_line && end_line > line ? end_line : nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.absolute(raw, cwd)
|
|
42
|
+
return nil if raw.empty?
|
|
43
|
+
return File.expand_path(raw) if raw.start_with?("/", "~")
|
|
44
|
+
return nil if cwd.to_s.empty?
|
|
45
|
+
|
|
46
|
+
File.expand_path(raw, cwd)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
# ~user for a user that doesn't exist.
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
private_class_method :absolute
|
|
52
|
+
|
|
53
|
+
def self.positive(value)
|
|
54
|
+
number = Integer(value.to_s.strip, exception: false)
|
|
55
|
+
number&.positive? ? number : nil
|
|
56
|
+
end
|
|
57
|
+
private_class_method :positive
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_activity"
|
|
4
|
+
require_relative "tool_view"
|
|
5
|
+
require_relative "file_ref"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The fields a tool row shows beyond its params, built from the call in
|
|
9
|
+
# one place for every path: the live tool_call_started (ToolRunner#run)
|
|
10
|
+
# and a reload from the saved call (Web::MessageParts#tool_part). The
|
|
11
|
+
# bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
|
|
12
|
+
# these lists, so a new field is added here and travels everywhere.
|
|
13
|
+
#
|
|
14
|
+
# They travel in two places:
|
|
15
|
+
# * ACTIVITY_KEYS: top-level on tool_call_started, inside +activity+ on
|
|
16
|
+
# tool_call_completed (ToolActivity.tool_activity_event builds that
|
|
17
|
+
# activity itself, for the TUI and the metrics);
|
|
18
|
+
# * EVENT_KEYS: top-level on both, so a UI that missed the start (a
|
|
19
|
+
# replay gap) builds its row from the completed event.
|
|
20
|
+
#
|
|
21
|
+
# params and label stay where they are: their sources differ per path
|
|
22
|
+
# (the registry's preview, a plugin's label, the saved shown params).
|
|
23
|
+
module ToolRowFields
|
|
24
|
+
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
25
|
+
EVENT_KEYS = %i[view ref].freeze
|
|
26
|
+
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param cwd [String, nil] the session's working directory (the title is
|
|
31
|
+
# relative to it when inside it)
|
|
32
|
+
# @return [Hash] title (a few words: a project-relative path, a command
|
|
33
|
+
# without its "cd … &&"), called_as (the name the model called it by
|
|
34
|
+
# when that was an alias's: bash), view (ToolView: the full command)
|
|
35
|
+
# and ref (FileRef: the file a file tool touched), each only when
|
|
36
|
+
# there is one
|
|
37
|
+
def for(tool_name, call, cwd:)
|
|
38
|
+
{
|
|
39
|
+
title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
|
|
40
|
+
called_as: call[:called_as],
|
|
41
|
+
view: ToolView.for(tool_name, call)&.to_h,
|
|
42
|
+
ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
|
|
43
|
+
}.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "tool_activity"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "tool_row_fields"
|
|
5
5
|
require_relative "guardrails"
|
|
6
6
|
require_relative "vision_context"
|
|
7
7
|
require_relative "log"
|
|
@@ -54,14 +54,10 @@ module Samagotchi
|
|
|
54
54
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
55
55
|
tool: call[:name], call: call.dup, params: params }
|
|
56
56
|
started[:label] = label if label
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
started[:called_as] = call[:called_as] if call[:called_as]
|
|
62
|
-
# The full command for a richer UI (params stays cut at 80).
|
|
63
|
-
view = ToolView.for(call[:name], call)&.to_h
|
|
64
|
-
started[:view] = view if view
|
|
57
|
+
# Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
|
|
58
|
+
# its session's working directory.
|
|
59
|
+
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
60
|
+
started.merge!(fields)
|
|
65
61
|
emit(on_stream_event, started)
|
|
66
62
|
|
|
67
63
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
@@ -102,8 +98,8 @@ module Samagotchi
|
|
|
102
98
|
completed[:diff] = diff if diff
|
|
103
99
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
104
100
|
# Also here: a UI that missed the start (a replay gap) builds its row
|
|
105
|
-
# from this event.
|
|
106
|
-
completed
|
|
101
|
+
# from this event (its title is in the activity).
|
|
102
|
+
completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
|
|
107
103
|
emit(on_stream_event, completed)
|
|
108
104
|
# After the completed event: a hook's card (check-in, skills) prints
|
|
109
105
|
# under the call's tool row, not before it.
|