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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -1
- data/bin/chi +1 -0
- data/docs/architecture.md +10 -1
- data/docs/cli.md +55 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +2 -0
- data/docs/internals/engine.md +1 -0
- data/docs/internals/working-set.md +130 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +30 -0
- data/lib/samagotchi/hooks/loader.rb +17 -2
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +49 -1
- data/lib/samagotchi/web/public/app.js +77 -11
- data/lib/samagotchi/web/public/chains.js +24 -0
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/index.html +134 -2
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/markdown_place.js +47 -1
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/send_turn.js +6 -2
- data/lib/samagotchi/web/public/session_actions.js +10 -5
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_view.js +37 -11
- data/lib/samagotchi/web/public/working_set.js +520 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +426 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/working_set.rb +200 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c75329bad389a0434a3f99db7d05d512a45864aa3ce08ecb8a6a7399bddbe33
|
|
4
|
+
data.tar.gz: c057af30d62ee4f72bf0b681c10edb0bd0bf5225e14a3e1470f4b0d726bbaf67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
data/docs/internals/engine.md
CHANGED
|
@@ -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
|
|
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,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: work-cap
|
|
3
|
-
version: 0.1.
|
|
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:
|
|
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
|
|
16
|
-
#
|
|
17
|
-
# session
|
|
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.
|
|
30
|
-
#
|
|
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
|
|
40
|
-
# /workcap or Continue writes a new
|
|
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
|
|
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
|
|
305
|
-
# read (and a child:/default: that
|
|
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)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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).
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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.
|
|
114
|
-
next unless
|
|
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
|