samagotchi 0.92.0 → 0.93.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 +31 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/guardrails.md +4 -5
- data/docs/internals/engine.md +6 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +122 -556
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fb0f158cad212e0caef1636e67b16290dd85d750a906a04e409adf9c29511ea
|
|
4
|
+
data.tar.gz: e1ff65cd0228751f4adcd8601492a952070cc59d415d324e7908fdd3f998e61e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b58b31c103e24a12b880dbeeae67be626b85b8478c87a19ba9e36440d10e07b34b3f4d615d5f3fceb5518a76172643736efc66263646cc0f75c919186b597bda
|
|
7
|
+
data.tar.gz: 05e2fc82913cafd6bc7a80fb0bc625b8f235b9008a78955b146a857e8f2b6c9e9b7c6c5a5717f31c2df32f14b17e4bc98781e68f8d160b95a657c1071dd85484
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,35 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.93.0] - 2026-10-20
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- A session open in a chi REPL is refused with one message everywhere: `session ID is open in a chi REPL; close it
|
|
16
|
+
there first` (chi web's stop, restart, turn and command, `chi sessions stop|restart`, chi send). Before, some
|
|
17
|
+
places said `owned by an interactive TUI`.
|
|
18
|
+
- Plugins: `ctx.http` turns only network, TLS and provider errors into `HttpError`; any other error (a bug in chi)
|
|
19
|
+
is logged with its backtrace and raised as it is, instead of looking like a failed request. In the bundle test
|
|
20
|
+
kit, `bundle.questions` entries are `Samagotchi::HookQuestion` values (same fields as before).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Guardrail rules' `command:` keeps a `cat`/`tee` heredoc body after an `exec` that sends the shell's output to a
|
|
25
|
+
file (`exec >run.sh; cat <<EOF …`, `exec 3>run.sh; cat <<EOF >&3 …`, also inside `{ }`, `if`, a function, or
|
|
26
|
+
with `exec {fd}>file` / `3<>file`): the body may be run later, as with `cat <<EOF >run.sh`; before, it was left
|
|
27
|
+
out as data.
|
|
28
|
+
- A config.yml that doesn't parse (saved mid-edit) no longer tells a worker or chi web that every start-only setting
|
|
29
|
+
changed and needs a restart: it warns once that the file doesn't parse, and the settings are compared again once
|
|
30
|
+
it does.
|
|
31
|
+
- A background delegate's relayed approval is reported to the parent's model as `approval relayed to your user:
|
|
32
|
+
execute: git push → …`, as a waiting parent already got it; before, it read `hook question relayed…` with an
|
|
33
|
+
empty label. A relayed question from a delegate's own delegate is named by its header (`Cost cap`), not the
|
|
34
|
+
outer card's `delegate ab12 asks: Cost cap`.
|
|
35
|
+
- `chi send --wait` on a queued image message that is removed (unqueued) as the wait looks no longer returns the
|
|
36
|
+
running turn's reply: the removal writes its tombstone before it takes the file, so the wait reads it as removed.
|
|
37
|
+
|
|
38
|
+
Update with `chi update` (the system bundle 0.93.0), then restart `chi web`.
|
|
39
|
+
|
|
11
40
|
## [0.92.0] - 2026-10-19
|
|
12
41
|
|
|
13
42
|
### Added
|
|
@@ -3605,7 +3634,8 @@ and long-lived sessions.
|
|
|
3605
3634
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3606
3635
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3607
3636
|
|
|
3608
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3637
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.93.0...HEAD
|
|
3638
|
+
[0.93.0]: https://github.com/dm1try/samagotchi/compare/v0.92.0...v0.93.0
|
|
3609
3639
|
[0.92.0]: https://github.com/dm1try/samagotchi/compare/v0.91.0...v0.92.0
|
|
3610
3640
|
[0.91.0]: https://github.com/dm1try/samagotchi/compare/v0.90.0...v0.91.0
|
|
3611
3641
|
[0.90.0]: https://github.com/dm1try/samagotchi/compare/v0.89.0...v0.90.0
|
data/bin/chi
CHANGED
|
@@ -267,14 +267,14 @@ end
|
|
|
267
267
|
# --parent-answers: a level in any case (no abbreviations), else a usage
|
|
268
268
|
# error, as chi send's.
|
|
269
269
|
if options[:parent_answers]
|
|
270
|
-
require "samagotchi/guardrails/
|
|
270
|
+
require "samagotchi/guardrails/parent_levels"
|
|
271
271
|
levels = Samagotchi::Guardrails::ParentPolicy::ORDER
|
|
272
|
-
|
|
273
|
-
unless levels.include?(
|
|
272
|
+
level = Samagotchi::Guardrails::ParentPolicy.parse_level(options[:parent_answers])
|
|
273
|
+
unless levels.include?(level)
|
|
274
274
|
warn "Error: --parent-answers takes #{levels.join(", ")}, not #{options[:parent_answers]}"
|
|
275
275
|
exit 2
|
|
276
276
|
end
|
|
277
|
-
options[:parent_answers] =
|
|
277
|
+
options[:parent_answers] = level
|
|
278
278
|
end
|
|
279
279
|
if options[:parent_answers] && web
|
|
280
280
|
warn "Error: --parent-answers is a new session's (chi --parent-answers, chi send --new --parent-answers), not chi web's"
|
data/docs/architecture.md
CHANGED
|
@@ -334,9 +334,9 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
334
334
|
(one that waits for the running turn's end: `--queue`, Alt+Enter, `/queue TEXT`, or a message
|
|
335
335
|
with images) before a turn takes it (web route `POST /api/sessions/:id/turn/:enqueued_id/dequeue`,
|
|
336
336
|
the × on a queued bubble; `/unqueue` in the attached TUI). Under the event log, like its enqueue:
|
|
337
|
-
`SessionInbox.take_queued_input`
|
|
338
|
-
|
|
339
|
-
announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
|
|
337
|
+
`SessionInbox.take_queued_input` writes a tombstone (`input/removed/<enqueued_id>`, for `ReplyWait`'s
|
|
338
|
+
`turn:`), claims the file as the drain does (one rename wins; a lost claim takes the tombstone back),
|
|
339
|
+
deletes it, and the Bridge announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
|
|
340
340
|
(a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
|
|
341
341
|
unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
|
|
342
342
|
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
@@ -584,7 +584,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
584
584
|
| Component | Main file(s) | What it is |
|
|
585
585
|
|---|---|---|
|
|
586
586
|
| Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`, `worker_wakes.rb`, `worker_task_wakes.rb`, `task_watch.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns and the wake turns nobody typed (delegate reports, a background task that finished unseen: [Finish wake](internals/background-tasks.md#finish-wake), attached context), exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
|
|
587
|
-
| Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
|
|
587
|
+
| Config | `config.rb`, `config/restart_watch.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)); the notice a worker or chi web gets when config.yml changes a setting it reads only at its start. |
|
|
588
588
|
| Paths | `paths.rb` (`Paths`) | The XDG base dirs chi's files live under, read from the env on every call: `Paths.state_home` (`$XDG_STATE_HOME`, else `~/.local/state`) and `Paths.config_home` (`$XDG_CONFIG_HOME`, else `~/.config`), both defaults under `Dir.home`; a blank variable counts as unset, a relative one is made absolute from the current directory. `Paths.tilde` shows a path with `~` for the home folder. `SessionManager` hands a spawned worker the spawner's `XDG_STATE_HOME` / `XDG_CONFIG_HOME`, when set, made absolute, so a relative one isn't read from the session's folder. |
|
|
589
589
|
| MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
|
|
590
590
|
| Bundle authoring | `bundle_command.rb` (`BundleCommand`), `bundle_command/{scaffold,check,test_run}.rb`, `bundle_command/try/{run,sandbox,trace}.rb`, `testing.rb` + `testing/` (the test kit) | `chi bundle new`, `check`, `test` and `try`, and the kit a bundle's tests load it with ([bundle-authoring.md](bundle-authoring.md), [internals/bundles.md](internals/bundles.md)). |
|
data/docs/guardrails.md
CHANGED
|
@@ -336,11 +336,10 @@ the `<<TAG` word and the terminator line stay in the text, so a rule on
|
|
|
336
336
|
anything else (`bash`, an interpreter, `ssh`, a wrapper), an unquoted one
|
|
337
337
|
whose body holds `$(…)` or backticks, a piped one, a file-writing
|
|
338
338
|
`cat`/`tee` (a `>&2` or `>&-` writes no file; `>& file` does), a
|
|
339
|
-
here-string and an unterminated heredoc all stay in it
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
question and hooks see the whole command.
|
|
339
|
+
here-string and an unterminated heredoc all stay in it, and so does every
|
|
340
|
+
`cat`/`tee` body after an `exec` that sent the shell's output to a file
|
|
341
|
+
(`exec >r.sh; cat <<E`, `exec 3>r.sh; cat <<E >&3`). Approvals, the
|
|
342
|
+
approval question and hooks see the whole command.
|
|
344
343
|
|
|
345
344
|
`memory: remove` matches a `memory_write` with `remove: true` (it moves a
|
|
346
345
|
memory into the bundle trash). Nothing else asks about it: a memory's file is
|
data/docs/internals/engine.md
CHANGED
|
@@ -569,6 +569,11 @@ Model probes (per turn):
|
|
|
569
569
|
Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
|
|
570
570
|
puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
|
|
571
571
|
`Hooks::Runtime` (built by `HookRoutes`, `hook_routes.rb`, which routes each to the UIs). A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
|
|
572
|
+
A question takes keywords only at that plugin-facing edge (`event[:ask_user]`, `ctx.ask_user`); from there it is one
|
|
573
|
+
`HookQuestion` (`hook_question.rb`: the question, its options, header, freeform flag, the hook's label and its
|
|
574
|
+
label => effect map) through `Hooks::Runtime#ask_user` / `Plugin::Host#ask_user`, `Engine#hook_ask_user` and
|
|
575
|
+
`HookRoutes#ask_user` to `QuestionDesk#ask_for_hook`, which turns the effects into the pending question's per-option
|
|
576
|
+
tags. The bundle test kit records the same value (`Testing::Recorder#questions`).
|
|
572
577
|
|
|
573
578
|
| Helper | What it does | Used by |
|
|
574
579
|
|---|---|---|
|
|
@@ -747,6 +752,7 @@ Paths are under `lib/samagotchi/`.
|
|
|
747
752
|
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
748
753
|
| `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
|
|
749
754
|
| `question_desk.rb`, `pending_question.rb`, `question_answer.rb` | `QuestionDesk`: the one open question (ask, post, answer, cancel, relay mark); `PendingQuestion` and `QuestionAnswer`, its values (Hashes only in events, Bridge JSON, the session file and the hook/plugin `ask_user` result) |
|
|
755
|
+
| `hook_question.rb` | `HookQuestion`: a hook's or plugin's `ask_user` question as one value, from the runtime/host edge to `QuestionDesk#ask_for_hook` |
|
|
750
756
|
| `event_routes.rb` | `EventRoutes`: where a card (`Engine#show_card`), a notice (`HookRoutes#notify`) and a guardrails line (`#guardrail_notify`, turn or between turns only) go: held while the plugins load, an anytime command's, an init task's, a running turn's event, else announced or held for `holding_announcements` |
|
|
751
757
|
| `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView` (what the kernel's peer tools see of the Engine) and `Engine::RelayPeer` (a delegate's approval relay); `PeerSources`: the attached context sources a peer bundle declares as its peer's answers (`chi.peer_source`) |
|
|
752
758
|
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
data/docs/sessions.md
CHANGED
|
@@ -88,7 +88,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
88
88
|
- `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
|
|
89
89
|
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new session there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
90
90
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
91
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
91
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
92
92
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
93
93
|
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
|
|
94
94
|
|
data/docs/testing.md
CHANGED
|
@@ -26,7 +26,8 @@ exception's text changes between Ruby versions: write the string yourself).
|
|
|
26
26
|
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
|
|
27
27
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
28
28
|
`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`,
|
|
29
|
-
`chains.js`, `
|
|
29
|
+
`chains.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`,
|
|
30
|
+
`vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
|
|
30
31
|
never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
|
|
31
32
|
`htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
|
|
32
33
|
escaped (a `"` in text stays `"`) and an attribute with `&` and `"` escaped. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
|
|
@@ -141,8 +141,8 @@ module Samagotchi
|
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
# The Messages API root: the OpenAI base without its trailing /v1
|
|
144
|
-
# (
|
|
145
|
-
def self.api_root(candidate) = candidate.base
|
|
144
|
+
# (LLM::AnthropicMessages.api_root), e.g. https://api.anthropic.com.
|
|
145
|
+
def self.api_root(candidate) = LLM::AnthropicMessages.api_root(candidate.base)
|
|
146
146
|
|
|
147
147
|
# Fireworks by its host (fireworks.ai or a subdomain), or by a model id
|
|
148
148
|
# in its accounts/fireworks/ namespace.
|
|
@@ -194,12 +194,10 @@ module Samagotchi
|
|
|
194
194
|
def commit
|
|
195
195
|
@taken.each_value do |report|
|
|
196
196
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
197
|
-
next cursor unless cursor.report_claim&.
|
|
197
|
+
next cursor unless cursor.report_claim&.id == report.claim_id
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
current = cursor.with(report_claim: nil)
|
|
202
|
-
current == before ? after : current
|
|
199
|
+
current = cursor.without_claim
|
|
200
|
+
current == report.before.without_claim ? report.after.without_claim : current
|
|
203
201
|
end
|
|
204
202
|
delete_rings(report.rings)
|
|
205
203
|
rescue StandardError => e
|
|
@@ -212,7 +210,7 @@ module Samagotchi
|
|
|
212
210
|
def release
|
|
213
211
|
@taken.each_value do |report|
|
|
214
212
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
215
|
-
cursor.report_claim&.
|
|
213
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
216
214
|
end
|
|
217
215
|
end
|
|
218
216
|
@taken = {}
|
|
@@ -234,11 +232,11 @@ module Samagotchi
|
|
|
234
232
|
return report.with(relay: nil) unless relay
|
|
235
233
|
|
|
236
234
|
question = report.relay
|
|
237
|
-
|
|
235
|
+
label = Tools::DelegateRelay.label(question)
|
|
238
236
|
line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
|
|
239
|
-
Tools::DelegateRelay.line(
|
|
237
|
+
Tools::DelegateRelay.line(label, "answered on the child")
|
|
240
238
|
elsif relay.relay_desk.settled?(report.child_id, question.id)
|
|
241
|
-
Tools::DelegateRelay.line(
|
|
239
|
+
Tools::DelegateRelay.line(label, "answered on a relay already; it waits for the user on the child")
|
|
242
240
|
else
|
|
243
241
|
outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
|
|
244
242
|
return hold_back(report) if outcome.stopped
|
|
@@ -286,7 +284,7 @@ module Samagotchi
|
|
|
286
284
|
def release_report(report)
|
|
287
285
|
@taken.delete(report.child_id)
|
|
288
286
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
289
|
-
cursor.report_claim&.
|
|
287
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
290
288
|
end
|
|
291
289
|
nil
|
|
292
290
|
end
|
|
@@ -330,11 +328,11 @@ module Samagotchi
|
|
|
330
328
|
timeout: 0, turn: before.turn)
|
|
331
329
|
return nil unless reportable?(wait)
|
|
332
330
|
|
|
333
|
-
claim_id = before.report_claim&.
|
|
334
|
-
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
335
|
-
|
|
331
|
+
claim_id = before.report_claim&.id || SecureRandom.hex(16)
|
|
332
|
+
after = before.without_claim.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
333
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
336
334
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
337
|
-
claim =
|
|
335
|
+
claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
|
|
338
336
|
return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
|
|
339
337
|
state_dir: @state_dir)
|
|
340
338
|
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -46,8 +46,26 @@ module Samagotchi
|
|
|
46
46
|
# (#interactive?): a web tab, an attached chi TUI.
|
|
47
47
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
48
|
|
|
49
|
+
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
+
# runs turns for on its own, by prefix; system:reminder is "reminder",
|
|
51
|
+
# anything else "client".
|
|
52
|
+
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
54
|
+
|
|
49
55
|
module_function
|
|
50
56
|
|
|
57
|
+
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
|
+
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
+
# wake), "reminder", else "client" (a person, chi send, a plugin, nil).
|
|
60
|
+
# @return [String]
|
|
61
|
+
def origin(client_id)
|
|
62
|
+
id = client_id.to_s
|
|
63
|
+
return "reminder" if id == REMINDER
|
|
64
|
+
|
|
65
|
+
ORIGIN_PREFIXES.each { |prefix, origin| return origin if id.start_with?(prefix) }
|
|
66
|
+
"client"
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# Whether +client_id+ is a web tab or a chi TUI, which see a command's
|
|
52
70
|
# reply and can send the line it asks for (CacheConfirm); chi send, a
|
|
53
71
|
# delegate, a plugin, chi itself and nil are not.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Config
|
|
5
|
+
# Tells a worker or chi web when config.yml changes a setting it only
|
|
6
|
+
# reads at its start (Config::Entry#lifecycle: worker_start or web_start,
|
|
7
|
+
# and process_start): "server.port changed — applies after a worker
|
|
8
|
+
# restart". ConfigFile.watch_restart_settings! adds a Watcher;
|
|
9
|
+
# ConfigFile.read_yaml reports each new read of the file (#notify) or a
|
|
10
|
+
# file that doesn't parse (#unreadable), which tells nothing and warns
|
|
11
|
+
# once, instead of every start-only key reading as changed.
|
|
12
|
+
#
|
|
13
|
+
# Thread-safe: the watchers list sits behind one mutex; callbacks run
|
|
14
|
+
# outside it.
|
|
15
|
+
module RestartWatch
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@watchers = []
|
|
18
|
+
|
|
19
|
+
# One process's start-only settings and their start values.
|
|
20
|
+
class Watcher
|
|
21
|
+
LIFECYCLES = {
|
|
22
|
+
worker: %i[worker_start process_start],
|
|
23
|
+
web: %i[web_start process_start]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :path
|
|
27
|
+
|
|
28
|
+
# @param data [Hash, nil] config.yml as the process read it at start
|
|
29
|
+
# @param file_key [Array, nil] that read's [mtime, size]
|
|
30
|
+
def initialize(process:, path:, data:, file_key:, on_change:)
|
|
31
|
+
lifecycles = LIFECYCLES.fetch(process) { raise ArgumentError, "unknown config process #{process.inspect}" }
|
|
32
|
+
raise ArgumentError, "a config change callback is required" unless on_change
|
|
33
|
+
|
|
34
|
+
@process = process
|
|
35
|
+
@path = path
|
|
36
|
+
@entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
|
|
37
|
+
@values = @entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] }
|
|
38
|
+
@noticed = {}
|
|
39
|
+
@file_key = file_key
|
|
40
|
+
@on_change = on_change
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# [callback, message] for each start-only setting the file read now
|
|
44
|
+
# (+data+, under +file_key+) changes: once per key per new value,
|
|
45
|
+
# none for a value back at its start value. Empty for a read already
|
|
46
|
+
# seen. Caller holds RestartWatch's mutex.
|
|
47
|
+
def changes(file_key, data)
|
|
48
|
+
return [] unless seen!(file_key)
|
|
49
|
+
|
|
50
|
+
@entries.filter_map do |entry|
|
|
51
|
+
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
52
|
+
if @values[entry.key] == value
|
|
53
|
+
@noticed.delete(entry.key)
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
# Already told for this value: a later edit of another key.
|
|
57
|
+
next if @noticed.key?(entry.key) && @noticed[entry.key] == value
|
|
58
|
+
|
|
59
|
+
@noticed[entry.key] = value
|
|
60
|
+
[@on_change, "#{entry.key} changed — applies after #{restart}"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Notes +file_key+ as read; false when it already was. Caller holds
|
|
65
|
+
# RestartWatch's mutex.
|
|
66
|
+
def seen!(file_key)
|
|
67
|
+
return false if @file_key == file_key
|
|
68
|
+
|
|
69
|
+
@file_key = file_key
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def restart = @process == :worker ? "a worker restart" : "restarting chi web"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module_function
|
|
79
|
+
|
|
80
|
+
# Adds a Watcher for +process+ (:worker or :web).
|
|
81
|
+
# @return [Proc] unsubscribes
|
|
82
|
+
def watch(process:, path:, data:, file_key:, &on_change)
|
|
83
|
+
watcher = Watcher.new(process: process, path: path, data: data, file_key: file_key, on_change: on_change)
|
|
84
|
+
@mutex.synchronize { @watchers << watcher }
|
|
85
|
+
-> { @mutex.synchronize { @watchers.delete(watcher) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Tells each watcher of +path+ which of its start-only settings the file
|
|
89
|
+
# read now (+data+, under +file_key+; nil for a missing file) changes.
|
|
90
|
+
def notify(path, file_key, data)
|
|
91
|
+
notices = @mutex.synchronize do
|
|
92
|
+
@watchers.select { |watcher| watcher.path == path }.flat_map { |watcher| watcher.changes(file_key, data) }
|
|
93
|
+
end
|
|
94
|
+
notices.each { |callback, message| callback.call(message) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The file at +path+ (under +file_key+) doesn't parse as a mapping
|
|
98
|
+
# (+problem+ says why): no watcher is told anything (its settings stay
|
|
99
|
+
# compared with the next read that parses), and a process watching it
|
|
100
|
+
# warns once per problem.
|
|
101
|
+
def unreadable(path, file_key, problem)
|
|
102
|
+
watched = @mutex.synchronize do
|
|
103
|
+
@watchers.select { |watcher| watcher.path == path }.each { |watcher| watcher.seen!(file_key) }.any?
|
|
104
|
+
end
|
|
105
|
+
return unless watched
|
|
106
|
+
|
|
107
|
+
ConfigFile.warn_once("config: #{path} doesn't parse (#{problem}); " \
|
|
108
|
+
"start-only settings are checked again when it does")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "model_ref"
|
|
|
10
10
|
require_relative "paths"
|
|
11
11
|
require_relative "llm_context_strategy"
|
|
12
12
|
require_relative "host_model"
|
|
13
|
+
require_relative "config/restart_watch"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -556,10 +557,6 @@ module Samagotchi
|
|
|
556
557
|
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
|
|
557
558
|
end
|
|
558
559
|
|
|
559
|
-
def set_cli_overrides(overrides)
|
|
560
|
-
reload!(cli_overrides: overrides)
|
|
561
|
-
end
|
|
562
|
-
|
|
563
560
|
# Problems with the keys of a parsed config.yml, one message per key the
|
|
564
561
|
# code doesn't read (with a "did you mean" when a known key is close).
|
|
565
562
|
# Every config-exposed entry in ENTRIES is known as written; names under
|
|
@@ -750,7 +747,7 @@ module Samagotchi
|
|
|
750
747
|
cache = @yaml_cache ||= {}
|
|
751
748
|
unless File.file?(path)
|
|
752
749
|
cache.delete(path)
|
|
753
|
-
|
|
750
|
+
Config::RestartWatch.notify(path, nil, nil)
|
|
754
751
|
return nil
|
|
755
752
|
end
|
|
756
753
|
stat = File.stat(path)
|
|
@@ -758,75 +755,37 @@ module Samagotchi
|
|
|
758
755
|
hit = cache[path]
|
|
759
756
|
return hit[1] if hit && hit[0] == key
|
|
760
757
|
|
|
758
|
+
problem = nil
|
|
761
759
|
data = begin
|
|
762
760
|
YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
763
|
-
rescue StandardError
|
|
761
|
+
rescue StandardError => e
|
|
762
|
+
problem = "#{e.class}: #{e.message.lines.first.to_s.strip[0, 200]}"
|
|
764
763
|
nil
|
|
765
764
|
end
|
|
765
|
+
problem ||= "its top level isn't a mapping" unless data.nil? || data.is_a?(Hash)
|
|
766
766
|
data = nil unless data.is_a?(Hash)
|
|
767
767
|
cache[path] = [key, data]
|
|
768
|
-
|
|
768
|
+
if problem
|
|
769
|
+
Config::RestartWatch.unreadable(path, key, problem)
|
|
770
|
+
else
|
|
771
|
+
Config::RestartWatch.notify(path, key, data)
|
|
772
|
+
end
|
|
769
773
|
data
|
|
770
774
|
end
|
|
771
775
|
|
|
772
776
|
# Calls +on_change+ with one line ("server.port changed — applies after a
|
|
773
777
|
# worker restart") when config.yml changes a setting this process only
|
|
774
|
-
# reads at its start (Config::
|
|
775
|
-
#
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
+
# reads at its start (Config::RestartWatch). Checked when the file is
|
|
779
|
+
# read again (#read_yaml), once per key per new value: none at start,
|
|
780
|
+
# none for a value back at its start value, none while the file doesn't
|
|
781
|
+
# parse (that warns once).
|
|
778
782
|
# @param process [Symbol] :worker or :web
|
|
779
783
|
# @return [Proc] unsubscribes
|
|
780
|
-
def watch_restart_settings!(process:, env: ENV, &
|
|
781
|
-
lifecycles = case process
|
|
782
|
-
when :worker then %i[worker_start process_start]
|
|
783
|
-
when :web then %i[web_start process_start]
|
|
784
|
-
else raise ArgumentError, "unknown config process #{process.inspect}"
|
|
785
|
-
end
|
|
786
|
-
raise ArgumentError, "a config change callback is required" unless on_change
|
|
787
|
-
|
|
784
|
+
def watch_restart_settings!(process:, env: ENV, &)
|
|
788
785
|
path = global_path(env: env)
|
|
789
786
|
data = read_yaml(env: env, path: path)
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
path: path, process: process, entries: entries,
|
|
793
|
-
values: entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] },
|
|
794
|
-
noticed: {},
|
|
795
|
-
file_key: File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil,
|
|
796
|
-
on_change: on_change
|
|
797
|
-
}
|
|
798
|
-
restart_watchers_mutex.synchronize { (@restart_watchers ||= []) << watcher }
|
|
799
|
-
-> { restart_watchers_mutex.synchronize { @restart_watchers&.delete(watcher) } }
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# Tells each watcher of +path+ which of its start-only settings the file
|
|
803
|
-
# read now (+data+, under +file_key+) changes.
|
|
804
|
-
def notify_restart_settings(path, file_key, data)
|
|
805
|
-
notices = restart_watchers_mutex.synchronize do
|
|
806
|
-
Array(@restart_watchers).flat_map do |watcher|
|
|
807
|
-
next [] unless watcher[:path] == path && watcher[:file_key] != file_key
|
|
808
|
-
|
|
809
|
-
watcher[:file_key] = file_key
|
|
810
|
-
watcher[:entries].filter_map do |entry|
|
|
811
|
-
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
812
|
-
if watcher[:values][entry.key] == value
|
|
813
|
-
watcher[:noticed].delete(entry.key)
|
|
814
|
-
next
|
|
815
|
-
end
|
|
816
|
-
# Already told for this value: a later edit of another key.
|
|
817
|
-
next if watcher[:noticed].key?(entry.key) && watcher[:noticed][entry.key] == value
|
|
818
|
-
|
|
819
|
-
watcher[:noticed][entry.key] = value
|
|
820
|
-
restart = watcher[:process] == :worker ? "a worker restart" : "restarting chi web"
|
|
821
|
-
[watcher[:on_change], "#{entry.key} changed — applies after #{restart}"]
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
notices.each { |callback, message| callback.call(message) }
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
def restart_watchers_mutex
|
|
829
|
-
@restart_watchers_mutex ||= Mutex.new
|
|
787
|
+
file_key = File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil
|
|
788
|
+
Config::RestartWatch.watch(process: process, path: path, data: data, file_key: file_key, &)
|
|
830
789
|
end
|
|
831
790
|
|
|
832
791
|
# Print +message+ to stderr unless this process already did. The config
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1402,14 +1402,7 @@ module Samagotchi
|
|
|
1402
1402
|
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1403
1403
|
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1404
1404
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1405
|
-
|
|
1406
|
-
source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
|
|
1407
|
-
elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
|
|
1408
|
-
elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
|
|
1409
|
-
elsif client_id.start_with?(ClientId::TASK_PREFIX) then "task"
|
|
1410
|
-
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1411
|
-
else "client"
|
|
1412
|
-
end
|
|
1405
|
+
source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
|
|
1413
1406
|
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1414
1407
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1415
1408
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
@@ -1523,10 +1516,9 @@ module Samagotchi
|
|
|
1523
1516
|
def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
|
|
1524
1517
|
private :guardrail_notify
|
|
1525
1518
|
|
|
1519
|
+
# @param question [HookQuestion]
|
|
1526
1520
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
1527
|
-
def hook_ask_user(question
|
|
1528
|
-
@hook_routes.ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
1529
|
-
end
|
|
1521
|
+
def hook_ask_user(question) = @hook_routes.ask_user(question)
|
|
1530
1522
|
private :hook_ask_user
|
|
1531
1523
|
|
|
1532
1524
|
# @return [Boolean] true when a running turn was cancelled now
|
|
@@ -2569,9 +2561,7 @@ module Samagotchi
|
|
|
2569
2561
|
messages: -> { plugin_messages },
|
|
2570
2562
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
2571
2563
|
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
2572
|
-
ask_user:
|
|
2573
|
-
hook_ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
2574
|
-
},
|
|
2564
|
+
ask_user: ->(question) { hook_ask_user(question) },
|
|
2575
2565
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2576
2566
|
cancelled: lambda {
|
|
2577
2567
|
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|