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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -1
- data/bin/chi +1 -0
- data/docs/architecture.md +6 -0
- data/docs/cli.md +55 -0
- data/docs/configuration.md +1 -0
- data/docs/internals/engine.md +1 -0
- data/docs/internals/working-set.md +130 -0
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +30 -0
- 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 +33 -3
- 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 +127 -1
- 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/refs.js +10 -1
- 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 +16 -4
- 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,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.
|
|
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
|
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/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/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 `&`, `<`, `>`
|
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
|
data/lib/samagotchi/host_keys.rb
CHANGED
|
@@ -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.
|
|
25
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
pid =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
61
|
+
return worker.value if worker.join(@timeout)
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
11
|
-
#
|
|
12
|
-
# link) with /bin/sh: writing a new body costs
|
|
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 = "
|
|
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)
|
|
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)
|
|
16
|
-
|
|
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)
|
|
96
|
-
#
|
|
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.
|
|
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
|
|