samagotchi 0.100.0 → 0.101.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/hooks.md +2 -0
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/working-set.md +130 -0
  10. data/docs/sessions.md +1 -1
  11. data/docs/testing.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
  15. data/lib/samagotchi/config.rb +3 -0
  16. data/lib/samagotchi/engine.rb +30 -0
  17. data/lib/samagotchi/hooks/loader.rb +17 -2
  18. data/lib/samagotchi/host_keys.rb +35 -18
  19. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  20. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  21. data/lib/samagotchi/tool_response.rb +10 -6
  22. data/lib/samagotchi/tool_runner.rb +25 -6
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/app.rb +49 -1
  25. data/lib/samagotchi/web/public/app.js +77 -11
  26. data/lib/samagotchi/web/public/chains.js +24 -0
  27. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  28. data/lib/samagotchi/web/public/data.js +6 -0
  29. data/lib/samagotchi/web/public/index.html +134 -2
  30. data/lib/samagotchi/web/public/info_bar.js +16 -4
  31. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  32. data/lib/samagotchi/web/public/markdown_place.js +47 -1
  33. data/lib/samagotchi/web/public/refs.js +10 -1
  34. data/lib/samagotchi/web/public/send_turn.js +6 -2
  35. data/lib/samagotchi/web/public/session_actions.js +10 -5
  36. data/lib/samagotchi/web/public/stage_model.js +6 -5
  37. data/lib/samagotchi/web/public/stage_view.js +34 -2
  38. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  39. data/lib/samagotchi/web/public/turn_view.js +37 -11
  40. data/lib/samagotchi/web/public/working_set.js +520 -0
  41. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  42. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  43. data/lib/samagotchi/web/server.rb +6 -4
  44. data/lib/samagotchi/working_set.rb +200 -0
  45. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10cafc8e1deb147528195213bd69b946f428918a8c344c57022b2c905cd2f4fb
4
- data.tar.gz: 6b6764b06a4effc61ac2edd1d2221d7afd232b2a8ce3470f0b14280d3ded432d
3
+ metadata.gz: 6c75329bad389a0434a3f99db7d05d512a45864aa3ce08ecb8a6a7399bddbe33
4
+ data.tar.gz: c057af30d62ee4f72bf0b681c10edb0bd0bf5225e14a3e1470f4b0d726bbaf67
5
5
  SHA512:
6
- metadata.gz: a4da078e7bb452a96fbc8d38f9565946fa12a3e3b8185e35f26032fcc2a3ae76249ec5ad7712dd12b74fec0f52db5fac7c03890f5074fca747985944b50f8e26
7
- data.tar.gz: 3cc980b577869624c31a8c577b69612198569875d5c3b1e2f9070a4aebed2b5d4e6d4d29d44e25e56e2144b2346f4017352dca2e7a01b2898bc6fa5046b4459e
6
+ metadata.gz: d2324f636a992fe36e2c0250ed55cf979b574359fd7e0f67271ceaf8da0803ffd60fc70408d62c73af984a2f9310b6acbfef92bbaac61a6c272e20540e38c840
7
+ data.tar.gz: cffd6a4d948302b563f1e30cf5542be9db7e95a68cc07a97cdb2b81e008454f7fe605f8bb96946c6b43c0fccd1957d05ea764867fde4f976f4699f242d8897fe
data/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.101.0] - 2026-10-22
12
+
13
+ ### Added
14
+
15
+ - Web (experimental, off by default): the stage view's **working set**, which shows what defines the model's
16
+ context: the files it read or edited, the memories, the fetched URLs and the preloaded and muted memories. It
17
+ shows when each one came in, folded into a few lines beside the stage. It replaces the mem chip with
18
+ `● set N`, adds a footprint line under each finished turn, and opens a list whose step tags jump to the call.
19
+ Items say whether the model still has them: **stale** (faded) when the file changed after the model last saw it,
20
+ through `execute`, your editor or anything else (the model's own edits don't count); **forgotten** (struck)
21
+ when the LLM context strategy no longer sends any of it. Each row shows its size in tokens. Turn it on with
22
+ `web.working_set: true`, `chi web --web-working-set` or `?working_set=1`. See
23
+ [docs/cli.md](docs/cli.md#the-working-set).
24
+
25
+ ### Fixed
26
+
27
+ - macOS: reading a host's API key from the Keychain can no longer hang chi past its 5 s deadline when starting
28
+ `security` stalls (a first run scanned by XProtect): chi warns and goes on without the key.
29
+
30
+ Update with `chi update`.
31
+
32
+ ## [0.100.1] - 2026-10-22
33
+
34
+ ### Fixed
35
+
36
+ - The web's `continue →` no longer takes a draft left over from another session (or the start page) as the new
37
+ link's first message: the opener goes in, and the toast's **Restore draft** brings the draft back. A draft written
38
+ in the continued session still stays.
39
+ - work-cap 0.1.1: a changed `bundles: work-cap:` setting reaches a running session (the caps are resolved again when the
40
+ settings change; the state file still wins per meter), and an empty key (`tool_calls:` with no value) falls through to
41
+ `child:` / `default:` instead of reading as "off"; `off`, `false` and `0` stay an explicit no cap.
42
+ - Web: a closed step's text in the history renders as markdown (with `web.markdown` on) and keeps its source refs,
43
+ live and after a reload, as the answer does.
44
+ - A hook entry in config.yml's `hooks:` that isn't a mapping, or has no `path:`, warns naming it
45
+ (`hooks.before_turn[0]`) instead of vanishing; the event's other entries still load.
46
+
11
47
  ## [0.100.0] - 2026-10-22
12
48
 
13
49
  ### Added
@@ -3792,7 +3828,9 @@ and long-lived sessions.
3792
3828
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3793
3829
  hotkey panel that send selected text or the clipboard to your sessions.
3794
3830
 
3795
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.0...HEAD
3831
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.101.0...HEAD
3832
+ [0.101.0]: https://github.com/dm1try/samagotchi/compare/v0.100.1...v0.101.0
3833
+ [0.100.1]: https://github.com/dm1try/samagotchi/compare/v0.100.0...v0.100.1
3796
3834
  [0.100.0]: https://github.com/dm1try/samagotchi/compare/v0.99.0...v0.100.0
3797
3835
  [0.99.0]: https://github.com/dm1try/samagotchi/compare/v0.98.0...v0.99.0
3798
3836
  [0.98.0]: https://github.com/dm1try/samagotchi/compare/v0.97.0...v0.98.0
data/bin/chi CHANGED
@@ -333,6 +333,7 @@ if options[:web]
333
333
  view = Samagotchi::Config.get("web.view")
334
334
  exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
335
335
  markdown: markdown, view: view, new_token: options[:new_token],
336
+ working_set: Samagotchi::Config.get("web.working_set"),
336
337
  annotate_presets: Samagotchi::Config.get("web.annotate_presets"),
337
338
  editor: Samagotchi::Config.get("web.editor"))
338
339
  end
data/docs/architecture.md CHANGED
@@ -431,7 +431,10 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
431
431
  `drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
432
432
  question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
433
433
  (`turn_view.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
434
- the turn's end and after a reload. A file ref outside the
434
+ the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
435
+ from its message's `html` (`turn_view.js` `textTemplate`), a live turn's from `POST /api/render` on its step's
436
+ first open after the turn's end (`markdown_place.js` `renderStepTexts`, which pairs its refs again after the
437
+ swap); the text as written stays on it as `data-copy-source` (`stepText`, a reloaded card's kept reply). A file ref outside the
435
438
  session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
436
439
  session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
437
440
  file ref is dropped rather than shown unchecked.
@@ -442,6 +445,12 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
442
445
  `turn_count`, when the page's merge came up short.
443
446
  - `?cards=1`: the cards, from `/tail`; the session file is only stat'ed for the 404.
444
447
 
448
+ The stage view's working set (`web.working_set`) has its own read, `GET /api/sessions/:id/working_set`: the
449
+ `WorkingSet` projection (`{items, at}`: each file, memory and URL live, stale or forgotten, with its tokens) of the
450
+ worker's `/snapshot` messages, else the session file, under the session's LLM context strategy. Its live form is the
451
+ `:working_set_updated` event. The page overlays it on the set it builds from its tool rows
452
+ ([internals/working-set.md](internals/working-set.md)).
453
+
445
454
  A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
446
455
  shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
447
456
  turn's records.
data/docs/cli.md CHANGED
@@ -15,6 +15,7 @@
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
16
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
17
17
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
+ - `chi web --web-working-set` — the stage view's working set (experimental, off by default; `--no-web-working-set` turns a config's `web.working_set: true` off): what each turn brought into the model's context, in the stage and under each finished turn; `?working_set=1|0` on the page URL overrides it (see [The working set](#the-working-set))
18
19
  - `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
19
20
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
20
21
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
@@ -956,6 +957,60 @@ steps saved before that have none, so they show no thinking. An `edit` or
956
957
  made (up to 120 lines or 8 KB), live and after a reload (see
957
958
  [Guardrails](guardrails.md#ask) for the diff an approval shows first).
958
959
 
960
+ ### The working set
961
+
962
+ `web.working_set: true` (or `chi web --web-working-set`,
963
+ `SAMAGOTCHI_WEB_WORKING_SET=true`; `?working_set=1` or `?working_set=0` on the
964
+ page URL for one page load) shows, in the stage view, what a turn brought into
965
+ the model's context: the files it read, edited or wrote, the memories it read
966
+ or wrote and the URLs it fetched. Off by default while it is tried out; the
967
+ turn view ignores it.
968
+
969
+ - **The rail**, on the right of the stage's live slots: the running turn's set
970
+ in four lines, folded as it grows (files under one folder become
971
+ `…/web/public/ · 9 files ✎2`, memories `◆ 3 memories`, else `N files · K
972
+ dirs`), with the newest entries marked for a few seconds and the context
973
+ ring once its fill is known. It never changes the stage's height. Its `–`
974
+ folds it to one line (`SET 17 + ◆ name`), kept per browser; a click opens the
975
+ list. In a stage narrower than 760 px it is one line under the last calls;
976
+ on a phone it isn't shown.
977
+ - **The footprint**, one line under each finished turn's block, live and after
978
+ a reload: `touched …/web/public/ · 4 files · ◆ 2 memories s2–10 · +1 url ·
979
+ edited 1`. A step tag (`s2`) goes to that call's row; the rest opens the
980
+ turn's list. A turn that touched nothing has none.
981
+ - **The `set N` chip** in the session bar, in place of the `mem N` chip (whose
982
+ tooltip goes too): the session's whole set, preloaded (`--memory`) and muted
983
+ (`--mute`) memories included; its dot lights when the set changed since you
984
+ last opened it.
985
+ - **The list** the three open (a bottom sheet on a phone): this turn or the
986
+ whole session, files grouped by folder (a group opens in place), memories
987
+ (used, preloaded, muted) and URLs, each with what was done to it (`read ×2`,
988
+ `read, edited`, `preloaded`) and a step tag (`s3`, `t2·s3` in the session's
989
+ scope, `prompt` for a preloaded memory) that goes to the call. A file opens
990
+ as its row's title does, a URL in a new tab.
991
+
992
+ Only `read`, `edit`, `write`, `memory_read`, `memory_write` and `web_fetch`
993
+ calls that succeeded count; files an `execute` touched don't.
994
+
995
+ What the model still has of each item comes from chi, not the page, so it is
996
+ the same live, after a reload and in every tab:
997
+ - **stale** (faded): the file changed since the model last read, edited or
998
+ wrote it, whatever changed it (a later `execute` such as `sed` or `git
999
+ checkout`, your editor, another session). The model's own successful edit or
1000
+ write doesn't make a file stale. Chi checks at each request and when you
1001
+ open the list, so a file changed while the session sat idle shows stale on
1002
+ opening it. A turn saved before chi kept that record shows its files live.
1003
+ - **forgotten** (struck): nothing of the item's content is still sent to the
1004
+ model under the session's current LLM context strategy (`forget_outputs`
1005
+ forgot every read of it; see
1006
+ [the forget layer](configuration.md#llm-context-the-forget-layer)). Lines a
1007
+ forget kept count as still sent. Under `/llm-context strategy none` nothing
1008
+ is cut, so a forgotten item is live again from the next request.
1009
+
1010
+ The list says `stale` or `forgotten` in words with each row, and how many
1011
+ tokens its content costs as sent (`~1.2k`); a folded group shows its forgotten
1012
+ members as `✂n`, and a footprint adds `n forgotten`.
1013
+
959
1014
  ### Web annotate presets
960
1015
 
961
1016
  Selecting text in an answer, a thinking block, a tool row or one of your
@@ -1689,6 +1689,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1689
1689
  | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
1690
1690
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
1691
1691
  | `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). |
1692
+ | `web.working_set` | `false` | yes | The stage view's working set (experimental): the files, memories and URLs a turn brought into the model's context, in a rail in the stage, a footprint line under each finished turn and a `set N` chip in place of the mem chip. `?working_set=1` or `?working_set=0` on the page URL overrides it for that page load; the turn view ignores it. See [CLI: the working set](cli.md#the-working-set). |
1692
1693
  | `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). |
1693
1694
  | `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). |
1694
1695
  | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
data/docs/hooks.md CHANGED
@@ -28,6 +28,8 @@ hooks:
28
28
 
29
29
  Each event key holds a list of entries. A `hooks:` that isn't a mapping, or an event key that isn't a list
30
30
  (`before_turn: guard.rb`), warns at each start and loads nothing for it; an empty key loads nothing, silently.
31
+ An entry that isn't a mapping (`- guard.rb`) or has no `path:` warns the same way, naming it
32
+ (`hooks.before_turn[0]`, counted from 0), and is skipped alone: the key's other entries load.
31
33
 
32
34
  ## Plugin Format
33
35
 
@@ -509,6 +509,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
509
509
  | `:question_requested` / `:question_answered` / `:question_cancelled` | `QuestionDesk`: a question is asked (an approval, `ask_user_question`, a hook's or plugin's `ask_user`, the step-limit Continue), answered, or withdrawn | `{pending_question}` / `{id, answer}` / `{id, reason}` |
510
510
  | `:plugin_init_wait` | a turn waits for plugin init tasks that provide tools (`PluginTasks`) | `{tasks: [{bundle, id, label}]}` |
511
511
  | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
512
+ | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty: a tool-free turn emits none (`Engine#emit_working_set`) | `{items: [{kind, key, state, tokens, ops, count, first_id}], at}` (`WorkingSet`, internals/working-set.md) |
512
513
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
513
514
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
514
515
  | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
@@ -0,0 +1,130 @@
1
+ # The working set: what the model really has
2
+
3
+ The stage view's working set (`web.working_set`, [cli.md](../cli.md#the-working-set)) shows the files, memories
4
+ and URLs a session's tool runs brought into the model's context. The page builds the set itself from its tool rows
5
+ (`working_set.js`, phase 1). The server says what the model still has of each item: **live**, **stale** (a file
6
+ changed since the model last saw it) or **forgotten** (nothing of it is still sent), and its size in tokens. This
7
+ page covers the server's side and how the page overlays it.
8
+
9
+ ## Stamps: `tool_stamps`
10
+
11
+ `ToolRunner#run` stamps an ok `read`, `edit` or `write` (`result[:activity][:status] == "ok"`) with its file right
12
+ after the call: `ToolRun#stamp`, `{path:, mtime_ns:, size:}`. The path is the one `FileRef.for` gives (the call's
13
+ path against the session's folder, `File.expand_path`, no realpath). A stat that fails, or that isn't a file's (a
14
+ directory), gives no stamp; nor does any other tool.
15
+
16
+ Both `ToolResponse` shapes save the stamps as `tool_stamps`, one per run, read by the run's place
17
+ (`ToolIds::Ref#run`):
18
+ - `ToolResponse.joined` (the native loop's batch entry): `runs.map(&:stamp)`, only when one is non-nil, like
19
+ `tool_refs`.
20
+ - `ToolResponse.single` (the chat loop's entry for one call): `[stamp]`, a one-stamp array like its `tool_ids`.
21
+
22
+ `tool_stamps` is in `ToolResponse::SAVED_KEYS`, so `ChatLoop#plain` keeps it and no prompt or wire ever renders it
23
+ (the exclusion specs: `spec/llm/chat_loop_spec.rb` "a file run's stamp", `spec/kernel_loop_spec.rb`). After a
24
+ reload the stamp hashes have string keys; the projection reads both. A session saved before the stamps has none, so
25
+ its files are never stale.
26
+
27
+ The model's own successful `edit` or `write` stamps the file after it ran, so it never makes the file stale. Anything
28
+ else that changes it does: a later `execute` (`sed`, `git checkout`), the user's editor, another session. A failed
29
+ edit that changed the file anyway leaves the earlier stamp, so the file shows stale. On a filesystem with 1 s mtimes a
30
+ same-size edit in the same second goes unseen (APFS keeps nanoseconds).
31
+
32
+ ## The projection: `WorkingSet.build`
33
+
34
+ `Samagotchi::WorkingSet.build(conversation, cwd:, strategy:, stat: File.method(:stat))` (`lib/samagotchi/working_set.rb`)
35
+ is pure and returns a `WorkingSet::Snapshot` (`items`, `at`: the conversation's size in entries, the web's ordering
36
+ key). Each `WorkingSet::Item` is `kind` (`file`, `memory`, `url`), `key`, `state`, `tokens`, `ops`, `count` and
37
+ `first_id` (the first run's ToolIds id).
38
+
39
+ **Runs.** It pairs the runs twice with `LLMContextStale.paired_runs`: once on the stored conversation, once on what
40
+ `LLMContextView.new(strategy:).messages(conversation)` sends (stubs keep their `[name]` lead, so they still pair).
41
+ The two are matched by `ref.id`. The calls are the dispatched ones (`Tools::BuiltinCalls.build`), so aliases resolve
42
+ as the loops resolve them. A run that doesn't pair (a "ran as:" corrected call, an output holding the run separator)
43
+ is left out. So is a failed run (`LLMContextStale`'s rule: an `Error` after the `[name]` lead; a denied call's
44
+ `Error:` too) and an interrupted one (`ToolResponse.interrupted_text?`).
45
+
46
+ **Items** follow the page's `rowEntries`:
47
+ - `read`, `edit` and `write` give a file, keyed by its `FileRef` path; `read` adds op `read`, `edit` and `write`
48
+ add op `edit`.
49
+ - `memory_read` gives one memory per name (`Tools::MemoryRead.parse_names`; an empty name, the index listing, gives
50
+ none). A partial read ("a,b" where b is missing: the found bodies, then `Error: memory not found: b`) keeps the
51
+ found names only, and its tokens are split evenly among them.
52
+ - `memory_write` gives the memory `call[:path]`, op `write`. A `remove: true` write stays in the set.
53
+ - `web_fetch` gives the URL with its whitespace collapsed.
54
+
55
+ **Tokens and forgotten.** Only content outputs (`read`, `memory_read`, `web_fetch`) carry tokens and decide
56
+ forgotten. Their tokens are the sent text's length over `TokenUsage::CHARS_PER_TOKEN`, so a stub costs what the stub
57
+ costs and kept lines count themselves. An image read counts its text only. `edit`, `write` and `memory_write`
58
+ outputs are short confirmations whose content sits in the call's arguments, which no strategy cuts: they add ops
59
+ and stamps, never tokens, and they never make an item forgotten.
60
+
61
+ An item is **forgotten** when it has content outputs and none of them is still sent under the session's *current*
62
+ strategy. A content output is still sent when:
63
+ - its sent text equals the stored one, or the stored one with the forget layer's `[#id] ` after its lead;
64
+ - no applied edit of a layer the strategy runs is saved on it (a staged edit doesn't count; an edit saved under a
65
+ strategy the session has since left, such as `/llm-context strategy none`, isn't sent, so the output is live
66
+ again);
67
+ - it is a forget that keeps lines (`forget_outputs keep:`);
68
+ - it is a stale stub an edit made ("superseded by a later edit", `llm_context.stale_edits`): the model wrote the
69
+ file, so what it has of it is current.
70
+
71
+ A stale stub of an old read that a newer read superseded never strikes the file: the newer read is still sent. A file
72
+ only edited or written is never forgotten.
73
+
74
+ **Stale** is a file item whose latest run left a stamp, and whose file now differs from that stamp in mtime or size,
75
+ or is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
76
+
77
+ **Cost.** Two `paired_runs` walks, one view build and one stat per file item. A 500-message session builds in about
78
+ 10 ms on an M5 Ultra and 75 ms on a CI runner; `spec/working_set_spec.rb` only guards against a blow-up (< 400 ms).
79
+
80
+ ## Delivery
81
+
82
+ **The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (`Engine#emit_working_set`):
83
+ - after each `:generation_started`, in `build_stream_event_handler`, from the running turn's conversation
84
+ (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
85
+ (`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
86
+ what that request sends;
87
+ - once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
88
+ turn-end apply has run by then).
89
+
90
+ It is emitted only when the set differs from the last one emitted for the session (kept per session id), and never
91
+ while the set is empty, so a tool-free turn on a fresh session emits nothing. It goes out whatever the web's flag;
92
+ the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
93
+
94
+ **The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`.
95
+ With a live worker it builds from the worker's snapshot messages (as `handle_show` reads them, keys symbolized) and
96
+ the worker's llm ctx chip. Without one it builds from the session file and its saved chip
97
+ (`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. A worker's snapshot holds the
98
+ session's messages, not the running turn's, so a GET during a turn answers an older `at` than that turn's events,
99
+ and the page keeps the events' set.
100
+
101
+ ## The page's overlay
102
+
103
+ The page keeps its row-derived items and their `at` ordinals (the step tags' jump path), and overlays the server's
104
+ `state` and `tokens` by key (`working_set.js overlay`):
105
+ - files by absolute path (the row's `ref.path`; a title fallback won't match, so it stays live);
106
+ - memories by name;
107
+ - URLs by the row's key, which is its params preview with whitespace collapsed, cut at 80 characters with a "…".
108
+ The "…" is stripped and the rest is matched as a prefix of the server's URL.
109
+
110
+ An item the server doesn't know yet (a running call) stays live. Known mismatch: live rows' refs resolve against the
111
+ worker's `Dir.pwd` (a real path), the server against `working_directory`, so a session under `/tmp` can miss
112
+ (`/private/tmp`).
113
+
114
+ `working_set_view.js` keeps one server snapshot per session and takes a new one only when its `at` is at least the
115
+ kept one's (`newerSnapshot`): a GET that answers after a newer event never wins. The page asks for it when it seeds a
116
+ session's set (load, resync) and when the list opens (a file changed while the session sat idle). The stream handler
117
+ passes `working_set_updated` to it, and does nothing when the flag is off. A new snapshot redraws:
118
+ - the session bar's chip and the open list;
119
+ - the footprints, in place (`refreshFootprints`, the same node; a `data-ws-sig` of the items' states skips an
120
+ unchanged one);
121
+ - the rail (`onServer`, the stage's `scheduleRefresh`).
122
+
123
+ A state is the item's state now, not when its turn ran. A state change never refolds and never moves a line: the
124
+ rail's line key carries the label's state and its forgotten count, which are patched in place.
125
+
126
+ **Looks** (design spec §3, §5): a stale line is faded (`opacity: .55`); a forgotten one is struck in `--text-faint`
127
+ at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are
128
+ forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
129
+ in words (colour is never alone) and shows `~1.2k` tokens per row (`tokensText`). A footprint adds `n forgotten`,
130
+ struck. Titles add `(stale)` or `(forgotten)` per member.
data/docs/sessions.md CHANGED
@@ -237,7 +237,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
237
237
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
238
238
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
239
239
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
240
- - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
240
+ - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
241
241
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
242
242
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
243
243
 
data/docs/testing.md CHANGED
@@ -34,7 +34,7 @@ exception's text changes between Ruby versions: write the string yourself).
34
34
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
35
35
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
36
36
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
37
- `chains.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
37
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
38
38
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
39
39
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
40
40
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.100.0
3
+ version: 0.101.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:
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: work-cap
3
- version: 0.1.0
3
+ version: 0.1.1
4
4
  scope: system
5
5
  description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:d4de4b835d1657223517f3d6a5d8a9b089b7c8fad08ed077f72748369323bd65
9
+ sha256: sha256:f4ce7ea7f5801490a29e232c41c7bca2007c140d21f1a78bbfb60e6b6fd4f64b
10
10
  # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
11
  requires_chi: ">= 0.100.0"
12
12
  # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
@@ -12,9 +12,10 @@
12
12
  # prompt text. cost-cap (the $ cap) is its sibling; the wordings follow it.
13
13
  #
14
14
  # Settings (config.yml, bundles: work-cap:), from ctx.settings, never at
15
- # load: the caps are resolved once per session (see State below), so a
16
- # config change reaches a running session through /workcap or a new
17
- # session; warn_at and ignore_tools are read at each check.
15
+ # load: the caps are resolved once per session and settings (see State
16
+ # below), again when ctx.settings changes, so a config change reaches a
17
+ # running session (the state file still wins per meter); warn_at and
18
+ # ignore_tools are read at each check.
18
19
  # tool_calls: 400 this session's own tool-call cap (session_settings:,
19
20
  # chi --set work-cap.tool_calls=400); 0/off: none
20
21
  # minutes: 60 this session's own cap on active minutes, same rules
@@ -26,8 +27,10 @@
26
27
  # tools whose calls and time aren't counted (waiting:
27
28
  # delegate blocks on its child by default)
28
29
  # Each meter resolves on its own: its own value, else child:'s for a
29
- # delegate, else default:'s. A value that doesn't read (tool_calls: many)
30
- # warns the user once and the next one is used, never a silent "no cap".
30
+ # delegate, else default:'s. An empty key (YAML nil) is unset and falls
31
+ # through; off, false and 0 are an explicit "no cap". A value that doesn't
32
+ # read (tool_calls: many) warns the user once and the next one is used,
33
+ # never a silent "no cap".
31
34
  #
32
35
  # What counts: ctx.usage's tool calls and active time (generations plus
33
36
  # finished tool calls, never a question's wait), less ignore_tools'.
@@ -36,8 +39,8 @@
36
39
  # "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
37
40
  # Continue; a meter's "cap": null is off for the session, over the config;
38
41
  # a meter missing from the file comes from the settings. Resolved once per
39
- # session id (ctx.delegate? parses the session file) and kept until
40
- # /workcap or Continue writes a new one.
42
+ # session id and settings (ctx.delegate? parses the session file) and kept
43
+ # until /workcap or Continue writes a new file, or the settings change.
41
44
  require "json"
42
45
  require "fileutils"
43
46
 
@@ -57,7 +60,7 @@ class Plugin
57
60
 
58
61
  def initialize(_settings)
59
62
  @mutex = Mutex.new
60
- @resolved = nil # {session:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
63
+ @resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
61
64
  @warned = {} # meter => [session id, cap] the share warning was given for
62
65
  @unread_warned = nil # the session id the "isn't a number" notice was given for
63
66
  end
@@ -101,7 +104,8 @@ class Plugin
101
104
  def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
102
105
 
103
106
  # Whether +value+ reads as a cap or as none: a number (text too), off,
104
- # false or nil; not "many", true or a list.
107
+ # false or nil (nil is unset, not none: #first_readable skips it); not
108
+ # "many", true or a list.
105
109
  def self.readable?(value, meter)
106
110
  case value
107
111
  when false, nil then true
@@ -269,16 +273,17 @@ class Plugin
269
273
 
270
274
  # The session's caps now: per meter the state file's, else the session's
271
275
  # own value (--set), else child:'s for a delegate, else default:'s.
272
- # Cached per session id.
276
+ # Cached per session id and settings (a config change resolves again).
273
277
  def resolve(ctx)
274
278
  session = ctx.session_id
279
+ settings = ctx.settings
275
280
  cached = @mutex.synchronize { @resolved }
276
- return cached if cached && cached[:session] == session
281
+ return cached if cached && cached[:session] == session && cached[:settings] == settings
277
282
 
278
283
  saved = state(ctx, session)
279
284
  configured = configured(ctx)
280
285
  meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
281
- resolved = { session: session, meters: meters, unreadable: configured[:unreadable] }
286
+ resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
282
287
  @mutex.synchronize { @resolved = resolved }
283
288
  end
284
289
 
@@ -301,9 +306,9 @@ class Plugin
301
306
  end
302
307
 
303
308
  # Per meter the first of its own value, child:'s (a delegate's) and
304
- # default:'s that is set and reads (.readable?); the ones set that don't
305
- # read (and a child:/default: that isn't a mapping) are kept for the
306
- # notice (#unreadable).
309
+ # default:'s that is set (an empty key, nil, isn't) and reads
310
+ # (.readable?); the ones set that don't read (and a child:/default: that
311
+ # isn't a mapping) are kept for the notice (#unreadable).
307
312
  def configured(ctx)
308
313
  settings = ctx.settings
309
314
  unreadable = {}
@@ -327,6 +332,8 @@ class Plugin
327
332
  next unless values.key?(meter)
328
333
 
329
334
  value = values[meter]
335
+ next if value.nil?
336
+
330
337
  unless self.class.readable?(value, meter)
331
338
  unreadable[name] = value
332
339
  next
@@ -360,12 +367,15 @@ class Plugin
360
367
  {}
361
368
  end
362
369
  saved = {} unless saved.is_a?(Hash)
363
- saved[meter] = data
370
+ saved[meter] = data.transform_values { |value| whole(value) }
364
371
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
365
372
  # Resolved again at the next use, from the file now holding the meter.
366
373
  @mutex.synchronize { @resolved = nil }
367
374
  end
368
375
 
376
+ # 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
377
+ def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
378
+
369
379
  def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
370
380
 
371
381
  def count(value) = self.class.count(value)
@@ -289,6 +289,9 @@ module Samagotchi
289
289
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
290
290
  # The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
291
291
  Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
292
+ # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
293
+ # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
294
+ Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
292
295
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
293
296
  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], lifecycle: :web_start),
294
297
  # 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).
@@ -84,6 +84,7 @@ require_relative "relay_desk"
84
84
  require_relative "guardrail_wiring"
85
85
  require_relative "plugin_tasks"
86
86
  require_relative "prompt_warmup"
87
+ require_relative "working_set"
87
88
 
88
89
  module Samagotchi
89
90
  # Engine owns the core agent logic: system prompt construction, tool
@@ -1459,6 +1460,7 @@ module Samagotchi
1459
1460
  synchronize_events do
1460
1461
  kept, event = yield(seconds)
1461
1462
  replace_session_messages(turn.session, kept) if kept
1463
+ emit_working_set(turn.on_event, turn.session, turn.session.messages)
1462
1464
  turn.session.status = Session::STATUS_IDLE
1463
1465
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1464
1466
  emit_event(turn.on_event, turn.tag(event))
@@ -2835,11 +2837,39 @@ module Samagotchi
2835
2837
 
2836
2838
  event = with_generation_report(event) if event[:type] == :generation_completed
2837
2839
  emit_event(on_event, event)
2840
+ # What this request sends: both loops start a generation after
2841
+ # their apply_llm_context!.
2842
+ emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
2838
2843
  watch.feed(**progress) if watch && progress
2839
2844
  @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
2840
2845
  end
2841
2846
  end
2842
2847
 
2848
+ # The working set (WorkingSet) as the model has it, as a
2849
+ # :working_set_updated event ({items:, at:}): at each request (the
2850
+ # stream handler's :generation_started, from the running turn's
2851
+ # conversation) and at turn end (#end_turn, from the session's kept
2852
+ # messages). Only when it differs from the last one emitted for this
2853
+ # session, and never while it is empty, so a tool-free turn emits
2854
+ # none. The web overlays it on the rows' set; other UIs ignore it.
2855
+ def emit_working_set(on_event, session, conversation)
2856
+ return unless session && conversation
2857
+
2858
+ llm_context = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
2859
+ snapshot = WorkingSet.build(conversation, cwd: session.working_directory,
2860
+ strategy: llm_context&.strategy || LLMContextView::NONE)
2861
+ return if snapshot.empty?
2862
+
2863
+ items = snapshot.items.map(&:to_h)
2864
+ return if @working_set_emitted == [session.id, items]
2865
+
2866
+ @working_set_emitted = [session.id, items]
2867
+ emit_event(on_event, { type: :working_set_updated, items: items, at: snapshot.at })
2868
+ rescue StandardError => e
2869
+ Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2870
+ end
2871
+ private :emit_working_set
2872
+
2843
2873
  # A :generation_completed with the generation's decode speed (`speed:`
2844
2874
  # {decode_tps:, source:}, nil without one), the session's running
2845
2875
  # token totals (`tokens:`, the snapshot's block) and its per-model rows
@@ -110,8 +110,8 @@ module Samagotchi
110
110
  next
111
111
  end
112
112
 
113
- configs.each do |cfg|
114
- next unless cfg.is_a?(Hash) && cfg["path"]
113
+ configs.each_with_index do |cfg, index|
114
+ next unless valid_entry?(event_type, cfg, index)
115
115
 
116
116
  definitions << {
117
117
  event_type: event_type.to_s,
@@ -125,6 +125,21 @@ module Samagotchi
125
125
  definitions
126
126
  end
127
127
 
128
+ # A hook entry is a mapping with a path:; anything else warns, naming
129
+ # the entry (hooks.EVENT[N], from 0), and is skipped alone.
130
+ def self.valid_entry?(event_type, cfg, index)
131
+ where = "hooks.#{event_type}[#{index}]"
132
+ unless cfg.is_a?(Hash)
133
+ Log.warn(:hooks, "hooks_entry_invalid", echo: "[samagotchi:hooks] config.yml #{where}: must be a mapping with a path:; ignored", event: event_type.to_s, index: index)
134
+ return false
135
+ end
136
+ return true if cfg["path"].is_a?(String) && !cfg["path"].strip.empty?
137
+
138
+ Log.warn(:hooks, "hooks_entry_no_path", echo: "[samagotchi:hooks] config.yml #{where}: has no path:; ignored", event: event_type.to_s, index: index)
139
+ false
140
+ end
141
+ private_class_method :valid_entry?
142
+
128
143
  # Load a plugin from the hooks directory.
129
144
  # Returns an instance of the plugin class. Instances are cached per
130
145
  # [path, settings] across Engines: two entries with different