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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -2
  4. data/docs/configuration.md +29 -0
  5. data/docs/internals/context-telemetry.md +23 -5
  6. data/docs/internals/engine.md +40 -18
  7. data/docs/sessions.md +2 -2
  8. data/docs/testing.md +23 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +2 -0
  12. data/lib/samagotchi/file_ref.rb +59 -0
  13. data/lib/samagotchi/tool_row_fields.rb +46 -0
  14. data/lib/samagotchi/tool_runner.rb +7 -11
  15. data/lib/samagotchi/version.rb +1 -1
  16. data/lib/samagotchi/web/app.rb +25 -2
  17. data/lib/samagotchi/web/capabilities.rb +13 -9
  18. data/lib/samagotchi/web/editor.rb +38 -0
  19. data/lib/samagotchi/web/message_parts.rb +5 -10
  20. data/lib/samagotchi/web/public/activity.js +33 -18
  21. data/lib/samagotchi/web/public/app.js +237 -1133
  22. data/lib/samagotchi/web/public/commands.js +236 -0
  23. data/lib/samagotchi/web/public/composer.js +101 -0
  24. data/lib/samagotchi/web/public/diff_view.js +30 -4
  25. data/lib/samagotchi/web/public/index.html +5 -0
  26. data/lib/samagotchi/web/public/info_bar.js +78 -0
  27. data/lib/samagotchi/web/public/lists_view.js +725 -0
  28. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  29. data/lib/samagotchi/web/public/question_cards.js +2 -1
  30. data/lib/samagotchi/web/public/refs.js +134 -0
  31. data/lib/samagotchi/web/public/restore.js +81 -0
  32. data/lib/samagotchi/web/public/session_state.js +52 -0
  33. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  34. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  35. data/lib/samagotchi/web/public/turn_events.js +17 -258
  36. data/lib/samagotchi/web/public/turn_html.js +5 -1
  37. data/lib/samagotchi/web/server.rb +4 -4
  38. metadata +11 -2
  39. 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: 143c9195fbbe50c8f8193c76815004f30354a01d2e1ec7d5019e70bc79c376df
4
- data.tar.gz: c1047a49f05415f8ea9a2789cf32ecc2f6edbcbf5bbfa432a8b919bd193ae360
3
+ metadata.gz: f90692cf867e2bd082f40c2ae576604656d23169163ed5584f9221ccec528ac1
4
+ data.tar.gz: ac4852809e3ccdc332dd0d052e17671096eb167691cdde6f01fc2ef6e43b9870
5
5
  SHA512:
6
- metadata.gz: 12b4e73421655936e2d3183e7839f4a8580b1d2b93633cff098170540e2c98ba61cf02c847a4af8b5e19a59424f15691a81d0b38195640263773bdccf2e8b3d3
7
- data.tar.gz: bf03f5dab6f9d7e30d37531127dfb88e48e779eb65f271de703f12566d728bbe869a0d3a935d33932afff4556c09b21bfe4d3d95f4b72c131bfc0521a41bf4b9
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.54.0...HEAD
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.
@@ -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 or a
26
- cadence tick, and not again for a bucket a resumed session's line already
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 doesn't report one. chi asks llama.cpp for its real window first (`/props`, the per-slot `n_ctx`); this setting only fills in when it can't (mlx, oMLX, server down), and 256000 is the last resort.
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. The buckets' guidance still gates the model's own `[CONTEXT: …]` line; nothing else reads `context.status_cadence` (it was removed once the event streamed on every request).
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.
@@ -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 and window_setting
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:) per call, with the capped output
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
- protected paths, then the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match
399
- the tool, its targets and the model). A gate that raises denies the call.
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 (per chat/native
461
- and thinking level) and is reset only on a model switch, a tools change or a profile change. The stable part
462
- (base prompt, identity, memories, AGENT.md, memory indexes) comes before the volatile tail (model, working
463
- directory, session). prompt-caching.md covers this in detail.
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 renders with lit-html (`turn_view.js`, `thinking_chip.js`,
19
- `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
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 title and, for an alias's call, the name the model used.
162
- title = part.slice(:title, :called_as).compact
163
- view = part[:view] ? { view: part[:view] } : {}
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[:title] = event[:title] if event[:title]
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.54.0
3
+ version: 0.55.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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 "tool_view"
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
- # The worker runs in its session's working directory.
58
- title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
- started[:title] = title if title
60
- # The name the model called it by when that was an alias's (bash).
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[:view] = view if view
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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.54.0"
4
+ VERSION = "0.55.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end