samagotchi 0.100.1 → 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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +6 -0
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/internals/working-set.md +130 -0
  9. data/docs/testing.md +1 -1
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +3 -0
  12. data/lib/samagotchi/engine.rb +30 -0
  13. data/lib/samagotchi/host_keys.rb +35 -18
  14. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  15. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  16. data/lib/samagotchi/tool_response.rb +10 -6
  17. data/lib/samagotchi/tool_runner.rb +25 -6
  18. data/lib/samagotchi/version.rb +1 -1
  19. data/lib/samagotchi/web/app.rb +49 -1
  20. data/lib/samagotchi/web/public/app.js +33 -3
  21. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  22. data/lib/samagotchi/web/public/data.js +6 -0
  23. data/lib/samagotchi/web/public/index.html +127 -1
  24. data/lib/samagotchi/web/public/info_bar.js +16 -4
  25. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  26. data/lib/samagotchi/web/public/refs.js +10 -1
  27. data/lib/samagotchi/web/public/stage_model.js +6 -5
  28. data/lib/samagotchi/web/public/stage_view.js +34 -2
  29. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  30. data/lib/samagotchi/web/public/turn_view.js +16 -4
  31. data/lib/samagotchi/web/public/working_set.js +520 -0
  32. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  33. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  34. data/lib/samagotchi/web/server.rb +6 -4
  35. data/lib/samagotchi/working_set.rb +200 -0
  36. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47dbac4f107af7d9c7d9d571b907a1e9e777326732614ed34986f2dce954168a
4
- data.tar.gz: 4335f033596dc75f3db80b754a347e36862d0fd07f1d72bafcc7a5c50ed539ff
3
+ metadata.gz: 6c75329bad389a0434a3f99db7d05d512a45864aa3ce08ecb8a6a7399bddbe33
4
+ data.tar.gz: c057af30d62ee4f72bf0b681c10edb0bd0bf5225e14a3e1470f4b0d726bbaf67
5
5
  SHA512:
6
- metadata.gz: a2454b93c9271b14ba0349c2991f6b0312dfdee76fd6a90005feb38726025dad71b6b4f1701bcbb2622c3db786d86fe2e58636668aa2f7d43d0ac289cae36e5a
7
- data.tar.gz: 8fef8d270b58719f286b57c840f1a5fc3a53de10fec01fdcc08ea3de1315de17d267a834759b05ae09c05ca80b8f27009329eb38ff0aaf9a7b6ab59585b3e1d3
6
+ metadata.gz: d2324f636a992fe36e2c0250ed55cf979b574359fd7e0f67271ceaf8da0803ffd60fc70408d62c73af984a2f9310b6acbfef92bbaac61a6c272e20540e38c840
7
+ data.tar.gz: cffd6a4d948302b563f1e30cf5542be9db7e95a68cc07a97cdb2b81e008454f7fe605f8bb96946c6b43c0fccd1957d05ea764867fde4f976f4699f242d8897fe
data/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ 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
+
11
32
  ## [0.100.1] - 2026-10-22
12
33
 
13
34
  ### Fixed
@@ -3807,7 +3828,8 @@ and long-lived sessions.
3807
3828
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3808
3829
  hotkey panel that send selected text or the clipboard to your sessions.
3809
3830
 
3810
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.1...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
3811
3833
  [0.100.1]: https://github.com/dm1try/samagotchi/compare/v0.100.0...v0.100.1
3812
3834
  [0.100.0]: https://github.com/dm1try/samagotchi/compare/v0.99.0...v0.100.0
3813
3835
  [0.99.0]: https://github.com/dm1try/samagotchi/compare/v0.98.0...v0.99.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
@@ -445,6 +445,12 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
445
445
  `turn_count`, when the page's merge came up short.
446
446
  - `?cards=1`: the cards, from `/tail`; the session file is only stat'ed for the 404.
447
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
+
448
454
  A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
449
455
  shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
450
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. |
@@ -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/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.1
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:
@@ -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
@@ -21,16 +21,22 @@ module Samagotchi
21
21
  # Reads one generic password through /usr/bin/security (by absolute
22
22
  # path: the helper's PATH isn't the shell's) in its own process group,
23
23
  # killed at the deadline: a locked keychain or an access dialog must not
24
- # hang a turn. nil on a miss, a non-zero exit, a timeout, no binary, or
25
- # off macOS.
24
+ # hang a turn. The deadline covers the spawn too (macOS can hold a first
25
+ # exec for minutes while it scans the binary): the run is a thread the
26
+ # caller gives up on, and a pid that turns up late is killed and reaped
27
+ # there. nil on a miss, a non-zero exit, a timeout, no binary, or off
28
+ # macOS.
26
29
  class Keychain
27
30
  SECURITY = "/usr/bin/security"
28
31
  TIMEOUT = 5
29
32
 
30
- def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"])
33
+ # +spawner+: how the run starts (Process.spawn's arguments; specs stall it).
34
+ def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"],
35
+ spawner: Process.method(:spawn))
31
36
  @security = security
32
37
  @timeout = timeout
33
38
  @macos = host_os.to_s.include?("darwin")
39
+ @spawner = spawner
34
40
  end
35
41
 
36
42
  # @return [String, nil]
@@ -45,33 +51,44 @@ module Samagotchi
45
51
  private
46
52
 
47
53
  def run(name)
48
- reader, writer = IO.pipe
49
- pid = Process.spawn(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
50
- in: File::NULL, out: writer, err: File::NULL, pgroup: true)
51
- writer.close
52
- waiter = Process.detach(pid)
53
- unless waiter.join(@timeout)
54
- stop(pid, waiter)
55
- Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
56
- return nil
54
+ lock = Mutex.new
55
+ pid = nil
56
+ late = false
57
+ worker = Thread.new do
58
+ Thread.current.report_on_exception = false
59
+ security(name) { |spawned| lock.synchronize { late ? stop(spawned) : pid = spawned } }
57
60
  end
58
- return nil unless waiter.value.success?
61
+ return worker.value if worker.join(@timeout)
59
62
 
60
- key = reader.read.to_s.chomp
61
- key.strip.empty? ? nil : key
63
+ lock.synchronize do
64
+ late = true
65
+ stop(pid) if pid
66
+ end
67
+ Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
68
+ nil
62
69
  rescue SystemCallError
63
70
  nil
71
+ end
72
+
73
+ # One run, start to reap; yields the pid as soon as there is one.
74
+ def security(name)
75
+ reader, writer = IO.pipe
76
+ pid = @spawner.call(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
77
+ in: File::NULL, out: writer, err: File::NULL, pgroup: true)
78
+ writer.close
79
+ yield pid
80
+ key = reader.read.to_s.chomp
81
+ _, status = Process.wait2(pid)
82
+ status.success? && !key.strip.empty? ? key : nil
64
83
  ensure
65
84
  writer&.close unless writer&.closed?
66
85
  reader&.close
67
86
  end
68
87
 
69
- def stop(pid, waiter)
88
+ def stop(pid)
70
89
  Process.kill(:KILL, -pid)
71
90
  rescue SystemCallError
72
91
  nil
73
- ensure
74
- waiter.join(1)
75
92
  end
76
93
  end
77
94
 
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "tmpdir"
5
4
 
6
5
  module Samagotchi
7
6
  module Testing
8
7
  # Fake commands for a test's PATH (Sandbox#fake_executable), cheap to
9
8
  # exec: macOS checks a new executable file on its first exec (about
10
- # 0.5–1 s, once per file). A fake is a symlink to one launcher, made
11
- # once per process, that runs the fake's body (<name>.sh beside the
12
- # link) with /bin/sh: writing a new body costs nothing.
9
+ # 0.5–1 s, once per file; minutes on a busy machine). A fake is a
10
+ # symlink to the checked-in launcher, which runs the fake's body
11
+ # (<name>.sh beside the link) with /bin/sh: writing a new body costs
12
+ # nothing.
13
13
  module FakeExecutable
14
- LAUNCHER = "#!/bin/sh\nexec /bin/sh \"$0.sh\" \"$@\"\n"
14
+ LAUNCHER = File.expand_path("fake_executable_launcher", __dir__)
15
15
 
16
16
  module_function
17
17
 
@@ -26,17 +26,7 @@ module Samagotchi
26
26
  path
27
27
  end
28
28
 
29
- def launcher
30
- @launcher ||= begin
31
- dir = Dir.mktmpdir("chi-fake-exec")
32
- owner = Process.pid
33
- at_exit { FileUtils.rm_rf(dir) if Process.pid == owner }
34
- File.join(dir, "launcher").tap do |path|
35
- File.write(path, LAUNCHER)
36
- File.chmod(0o755, path)
37
- end
38
- end
39
- end
29
+ def launcher = LAUNCHER
40
30
  end
41
31
  end
42
32
  end
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # What every FakeExecutable (and spec/support/fake_executables.rb) fake
3
+ # links to: runs the fake's body, <link>.sh, with /bin/sh. Checked in, not
4
+ # written per process: macOS scans a just-written executable at its first
5
+ # exec, which under a busy run can stall for minutes.
6
+ exec /bin/sh "$0.sh" "$@"
@@ -10,10 +10,11 @@ module Samagotchi
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
12
  # Saved with a result for the web's reload (a plugin tool's params line,
13
- # label and row refs, an edit/write's diff) and for the LLMContextView (each
13
+ # label and row refs, an edit/write's diff), for the LLMContextView (each
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
- # LLMContextEdit); the prompt never renders them.
16
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
15
+ # LLMContextEdit) and for WorkingSet (a file run's stamp, ToolRun#stamp);
16
+ # the prompt never renders them.
17
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids tool_stamps edits].freeze
17
18
  # The tools a chat result loaded (#single), saved with it.
18
19
  LOADED = :loaded
19
20
  # A run's "[name]" lead: the name, then a newline, a space or the end.
@@ -84,7 +85,7 @@ module Samagotchi
84
85
  entry[:image_counts] = runs.map { |run| Array(run.images).size }
85
86
  end
86
87
  saved = { tool_params: runs.map(&:shown_params), tool_labels: runs.map(&:shown_label),
87
- tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs) }
88
+ tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs), tool_stamps: runs.map(&:stamp) }
88
89
  saved.each { |key, values| entry[key] = values if values.any? }
89
90
  entry[:tool_ids] = ids
90
91
  entry
@@ -92,8 +93,10 @@ module Samagotchi
92
93
 
93
94
  # The chat loop's entry for one call: its capped output, paired with the
94
95
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
95
- # #joined's tool_ids); a find_tools hit's +loaded+ names (saved on every
96
- # chat backend: a wire that loads tools renders them, LLM::AnthropicWire).
96
+ # #joined's tool_ids) and its stamp (a one-stamp tool_stamps, likewise:
97
+ # read by the run's place); a find_tools hit's +loaded+ names (saved on
98
+ # every chat backend: a wire that loads tools renders them,
99
+ # LLM::AnthropicWire).
97
100
  def single(run, tool_call_id:, ids:)
98
101
  entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
99
102
  entry[:images] = run.images if run.images&.any?
@@ -102,6 +105,7 @@ module Samagotchi
102
105
  entry[:tool_diffs] = run.diff if run.diff
103
106
  entry[:tool_refs] = run.refs if run.refs
104
107
  entry[:tool_ids] = ids
108
+ entry[:tool_stamps] = [run.stamp] if run.stamp
105
109
  entry[LOADED] = run.loaded if run.loaded&.any?
106
110
  entry
107
111
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "tool_activity"
4
4
  require_relative "tool_row_fields"
5
5
  require_relative "row_refs"
6
+ require_relative "file_ref"
6
7
  require_relative "guardrails"
7
8
  require_relative "vision_context"
8
9
  require_relative "log"
@@ -18,12 +19,14 @@ module Samagotchi
18
19
  # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
20
  # tool_response entries) and the loops' activity list. Fields a call didn't
20
21
  # produce (images, shown_params, shown_label, refs, diff, output_file,
21
- # loaded) are nil. +loaded+: the tools a find_tools hit loads
22
- # (Tools::ToolIndex::FoundText#loaded), by their wire names.
22
+ # loaded, stamp) are nil. +loaded+: the tools a find_tools hit loads
23
+ # (Tools::ToolIndex::FoundText#loaded), by their wire names. +stamp+: an
24
+ # ok read/edit/write's file right after it ran ({path:, mtime_ns:,
25
+ # size:}), what WorkingSet tells a file changed since by.
23
26
  ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
24
- :output_file, :loaded) do
27
+ :output_file, :loaded, :stamp) do
25
28
  def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
26
- shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil)
29
+ shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil, stamp: nil)
27
30
  super
28
31
  end
29
32
  end
@@ -153,11 +156,27 @@ module Samagotchi
153
156
  images: images&.any? ? images : nil,
154
157
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
155
158
  shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
156
- loaded: result[:loaded])
159
+ loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call) : nil)
157
160
  end
158
161
 
159
162
  private
160
163
 
164
+ # An ok read/edit/write's file as it is now, right after the call:
165
+ # {path:, mtime_ns:, size:}, the path as FileRef has it (against the
166
+ # session's folder, no realpath). nil for any other tool, a path it
167
+ # can't place and a stat that fails or isn't a file's.
168
+ def file_stamp(call)
169
+ ref = FileRef.for(call[:name], call, cwd: session_cwd)
170
+ return nil unless ref
171
+
172
+ stat = File.stat(ref.path)
173
+ return nil unless stat.file?
174
+
175
+ { path: ref.path, mtime_ns: (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec, size: stat.size }
176
+ rescue SystemCallError
177
+ nil
178
+ end
179
+
161
180
  # +output+ cut to +cap+ by its tool's policy (ToolOutputCut); an
162
181
  # execute result keeps its tail too, its whole output saved in the
163
182
  # session's folder.
@@ -310,7 +329,7 @@ module Samagotchi
310
329
  # The session's folder (the Engine's, KernelLoop#session_cwd), else the
311
330
  # process's: a bare kernel (specs) has none.
312
331
  def session_cwd
313
- cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
332
+ cwd = @kernel.session_cwd&.call if @kernel.respond_to?(:session_cwd)
314
333
  cwd.to_s.empty? ? Dir.pwd : cwd
315
334
  end
316
335