samagotchi 0.157.0 → 0.159.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 +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +10 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
data/docs/sessions.md
CHANGED
|
@@ -91,7 +91,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
91
91
|
|
|
92
92
|
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` in its meta row (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`), and a chain's link with `↪ <the previous link's short id>` (`--format json`: `continues`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The fill is counted against the session's LLM context budget when one is set and smaller than the window (its own `--llm-context-budget`, else the model's/host's/config's; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), as the live meter counts it; the list, the web card and the session page otherwise agree. The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
|
|
93
93
|
|
|
94
|
-
**Projects:** a session belongs to the
|
|
94
|
+
**Projects:** a session belongs to the project it was started in: the git repository, whichever worktree or subfolder of it, or a folder marked by `chi project init` and its subfolders (the same project root memories use; see [Projects without git](memory.md#projects-without-git)). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no project (`~`). Sessions saved in a folder before it was marked join its project (their stored root is `null` and is looked up again); a running `chi web` places them after its restart. `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
|
|
95
95
|
|
|
96
96
|
`--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_rows`: the rows are `SessionRow`; the plain list, the picker's text and a remote list print one line, `SessionRow#cli_line`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session a one-turn `chi --non-interactive` run has is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, continues, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a one-turn `chi --non-interactive` run, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` prints the plain list's line, with its state, `idle` for a live idle worker too, and its text; tsv and json keep `desc`).
|
|
97
97
|
|
|
@@ -100,7 +100,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
100
100
|
- The web header's project chip opens a picker. The start project is the default row (including its start folder); other rows show their root, session and waiting counts. **All projects** opens every session, and choosing a project drops the open-session hash while keeping `?view=`.
|
|
101
101
|
|
|
102
102
|
- `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.
|
|
103
|
-
- 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 as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). 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; there the pill shows whatever the strip's state, pressed, and a click on it goes back to the chat (as Esc does). 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; just `▸ 2 delegates` while the rows are open: they carry the marks then) 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`; its header is the top bar's right end: a search over preview, id and status, the count, an `archived` toggle and Select (on a phone a row of its own under the bar). Esc or the pressed pill returns; where the sessions are stored is a faint line under the list. A card is its title on one line (an untitled session reads `Untitled` over its first message), a line under it (its recap, else its first message) and its meta row, with one mark per state and no status dot: a waiting card has its warn outline and its `question` (or `approval`…) chip, an errored one an `error` badge, a session a worker runs the `live` badge (not on a waiting card: waiting means a worker), and idle, completed and stopped ones are unmarked; a family's and a chain's rows the same. 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` (`3 / 14` while a search narrows it), 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's id copies the full session id on click ("Session id copied"), the id every CLI command takes (`chi --attach ID`, `chi send ID`). 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 ⚙ chip, after the model, holds the session's thinking level, reasoning history and LLM context: `⚙` alone while the session follows the model for all three, else `⚙` and what the session set, in the accent colour (`⚙ think low · ctx stale,forget`; a phone shows ⚙ with a dot; the tooltip gives each value and its source). Its panel is the start page's ⚙ panel for this session: a Thinking row (`/thinking`), on a chat host a Reasoning row (the reasoning history, `/reasoning-history`), and a Context row with its Apply and budget fields (`/llm-context`); a tap runs that row's command at once in the worker (one reply in the conversation, as a typed one) and changes that field alone, the Context row's `default` resets the strategy, apply rule and budget (`/llm-context reset`), and a tap while a turn runs is queued for after it (the panel's note says so). `GET /api/sessions/:id`'s `session.thinking`, `session.reasoning_history` (null on a native host) and `session.llm_context` carry the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. After ⚙, the 🧰 chip names the session's toolkit: `🧰` alone on the configured default (`toolkit.default`, `session.toolkit_default`), `🧰 lean` on another (see [Toolkits](configuration.md#toolkits); `/toolkit` changes it). A `/thinking` or `/reasoning-history` change typed in the composer that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the ⚙ panel 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`; `toolkits`: `{default, suggest, kits: [{name, description?}]}`, the start page's 🧰 list: the configured `toolkit.default`, `suggest` `on` only when a new session's first message may get a suggested kit (`toolkit.suggest.mode: on`, a System 1 host, two described kits; else `audit` or `off`), and the built-in and config kits a new session can start on, without `review`, `child`, `undecided`, a kit `groups:` refuses or one that doesn't resolve (a base that is a bundle kit), the default always listed; left out when it can't be read), and `POST /api/sessions` takes `model` (blank means the default). Beside it, ⚙ opens the new session's rarely used settings (thinking and LLM context, following each `GET /api/models` row's `thinking` and `llm_context`, and the review files; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), and 🧰 its toolkit: **Auto** (with `toolkit.suggest.mode: on`: the first message may get a suggested kit, else the default) or a kit from the payload's `toolkits`, sent as `POST /api/sessions`'s `toolkit` (see [configuration.md](configuration.md#toolkits)); a refused kit (`400 toolkit_refused`) is said in the list, opened again. What is set shows on the row as an accent chip (`think high`, `tools: lean`; a dot on ⚙ or 🧰 on a phone); each is for the next new session only, never remembered. 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).
|
|
103
|
+
- Web UI (`chi web`): the page's scope is in its URL. Started in a project (a git repository or a `chi project init` folder), `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 as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a project (no repository or marked folder) it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a 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; there the pill shows whatever the strip's state, pressed, and a click on it goes back to the chat (as Esc does). 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; just `▸ 2 delegates` while the rows are open: they carry the marks then) 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`; its header is the top bar's right end: a search over preview, id and status, the count, an `archived` toggle and Select (on a phone a row of its own under the bar). Esc or the pressed pill returns; where the sessions are stored is a faint line under the list. A card is its title on one line (an untitled session reads `Untitled` over its first message), a line under it (its recap, else its first message) and its meta row, with one mark per state and no status dot: a waiting card has its warn outline and its `question` (or `approval`…) chip, an errored one an `error` badge, a session a worker runs the `live` badge (not on a waiting card: waiting means a worker), and idle, completed and stopped ones are unmarked; a family's and a chain's rows the same. 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` (`3 / 14` while a search narrows it), 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's id copies the full session id on click ("Session id copied"), the id every CLI command takes (`chi --attach ID`, `chi send ID`). 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 ⚙ chip, after the model, holds the session's thinking level, reasoning history and LLM context: `⚙` alone while the session follows the model for all three, else `⚙` and what the session set, in the accent colour (`⚙ think low · ctx stale,forget`; a phone shows ⚙ with a dot; the tooltip gives each value and its source). Its panel is the start page's ⚙ panel for this session: a Thinking row (`/thinking`), on a chat host a Reasoning row (the reasoning history, `/reasoning-history`), and a Context row with its Apply and budget fields (`/llm-context`); a tap runs that row's command at once in the worker (one reply in the conversation, as a typed one) and changes that field alone, the Context row's `default` resets the strategy, apply rule and budget (`/llm-context reset`), and a tap while a turn runs is queued for after it (the panel's note says so). `GET /api/sessions/:id`'s `session.thinking`, `session.reasoning_history` (null on a native host) and `session.llm_context` carry the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. After ⚙, the 🧰 chip names the session's toolkit: `🧰` alone on the configured default (`toolkit.default`, `session.toolkit_default`), `🧰 lean` on another (see [Toolkits](configuration.md#toolkits); `/toolkit` changes it). A `/thinking` or `/reasoning-history` change typed in the composer that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the ⚙ panel 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`; `toolkits`: `{default, suggest, kits: [{name, description?}]}`, the start page's 🧰 list: the configured `toolkit.default`, `suggest` `on` only when a new session's first message may get a suggested kit (`toolkit.suggest.mode: on`, a System 1 host, two described kits; else `audit` or `off`), and the built-in and config kits a new session can start on, without `review`, `child`, `undecided`, a kit `groups:` refuses or one that doesn't resolve (a base that is a bundle kit), the default always listed; left out when it can't be read), and `POST /api/sessions` takes `model` (blank means the default). Beside it, ⚙ opens the new session's rarely used settings (thinking and LLM context, following each `GET /api/models` row's `thinking` and `llm_context`, and the review files; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), and 🧰 its toolkit: **Auto** (with `toolkit.suggest.mode: on`: the first message may get a suggested kit, else the default) or a kit from the payload's `toolkits`, sent as `POST /api/sessions`'s `toolkit` (see [configuration.md](configuration.md#toolkits)); a refused kit (`400 toolkit_refused`) is said in the list, opened again. What is set shows on the row as an accent chip (`think high`, `tools: lean`; a dot on ⚙ or 🧰 on a phone); each is for the next new session only, never remembered. 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).
|
|
104
104
|
- 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).
|
|
105
105
|
- 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, with Stop turn in its status row while it runs, 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, three groups with hairlines between them, on one line above a phone's width (the folder gives way first): the setup (id, status, model, the ⚙ and 🧰 chips, notes, the memory or working-set chip), the relations (chain links, the parent as `↑ <id>`, delegates, participants; only when there is one) and the telemetry (folder, ctx, speed, the session's time, worker badge)), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `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 (the restarting set is `version_notices.js`'s) 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), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `strip_hidden.js` (the strip hidden or shown: the saved choice, the short window's own hide, the grip and the body's class), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `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), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `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`).
|
|
106
106
|
- 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).
|
|
@@ -115,7 +115,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
115
115
|
|
|
116
116
|
## Session file
|
|
117
117
|
|
|
118
|
-
`Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
|
|
118
|
+
`Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
|
|
119
119
|
|
|
120
120
|
| Key | Meaning | Written by |
|
|
121
121
|
|-----|---------|------------|
|
|
@@ -135,7 +135,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
135
135
|
| `test_run` | Tagged by `SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` (see Test-session hygiene). | creation |
|
|
136
136
|
| `pending_question` | The question the worker waits on (`PendingQuestion`), `null` with none. | `QuestionDesk` |
|
|
137
137
|
| `used_memory_names` | The memories the session read. | the Engine |
|
|
138
|
-
| `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a
|
|
138
|
+
| `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repository or a marked folder (looked up again on read). | creation |
|
|
139
139
|
| `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
|
|
140
140
|
| `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
|
|
141
141
|
| `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
|
|
@@ -358,7 +358,31 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
358
358
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task), and `moved_context`: `{moved: [names], stayed: [{name, why}]}`, the attached context it took (`why` `pushed` or `not movable`); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
359
359
|
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a one-turn `chi --non-interactive` run has, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link with a recap of its own reads its auto title, else its recap's first sentence, instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or the `archived` toggle is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
360
360
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
361
|
-
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
|
|
361
|
+
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain. A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
|
|
362
|
+
|
|
363
|
+
## Schedules
|
|
364
|
+
|
|
365
|
+
A **schedule** is a prompt with a clock, kept in `<state dir>/schedules.json` and
|
|
366
|
+
fired by a tick that needs no worker — cron, systemd, `chi schedule tick`, or chi
|
|
367
|
+
web's hub when `schedule.auto_tick: true`. `chi schedule add --session ID
|
|
368
|
+
--weekday 1 --time 09:00 -m "…"` makes one; `chi schedule list|show|tick|run-now|enable|disable|cancel`
|
|
369
|
+
read and run it. Each firing is either the **next link of the session's chain**
|
|
370
|
+
(the default: the head is archived, a new link takes its place, and the links
|
|
371
|
+
behind `keep_links` are deleted with their delegates — archived links are outside
|
|
372
|
+
the retention sweep, so this is what bounds them) or a **turn in the session
|
|
373
|
+
itself** (`--mode same`, queued like a typed prompt). A target that is busy is
|
|
374
|
+
**skipped**, not failed: `next_due` stays, `skips` grows, and after
|
|
375
|
+
`max_skips` (12) consecutive skips or `max_failures` (3) consecutive failures the
|
|
376
|
+
schedule is disabled and `list` says why; any firing that succeeds resets both.
|
|
377
|
+
|
|
378
|
+
The difference from `register_reminder` is where the clock lives. A reminder is
|
|
379
|
+
in-memory state of a live worker: it holds that worker up (`/exit` asks about it),
|
|
380
|
+
maxes out at 1440 minutes, and survives only the short asked-restart hand-off. A
|
|
381
|
+
schedule is a file: it outlives every worker, wake, crash and archive, has no
|
|
382
|
+
interval limit, and never holds a worker up — a tick wakes one when none is
|
|
383
|
+
running. A schedule turn carries `client_id: "system:schedule:<id>`, so both UIs
|
|
384
|
+
label it `schedule`. Full details, the busy check and the DST caveat:
|
|
385
|
+
[Scheduling](scheduling.md).
|
|
362
386
|
|
|
363
387
|
## Review sessions
|
|
364
388
|
|
|
@@ -495,7 +519,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
|
|
|
495
519
|
The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
|
|
496
520
|
|
|
497
521
|
- `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to (`turn` in `delegates.json` is the follow-up's own id, set when the child's worker names `turn_ids`): the turn of its own it runs, or the running turn whose drain merges it as a steer (the drain's `input/merged/` record says which; inferred for an older child), not a newer reply of a turn queued behind it. A follow-up the running turn ended without taking runs as its own turn and gets that turn's reply. A child on its first turn, or with an older worker, gives its next reply, as before.
|
|
498
|
-
- `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides).
|
|
522
|
+
- `cwd:` starts the child in another folder of the parent's project (a repository or a marked folder): a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). In a marked project the folder must have the parent's project root too (so a nested repository or marked project is refused); outside both only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's project …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, a path with spaces, or the main checkout when the child runs in a linked worktree of it (the work belongs in the worktree, so naming a main-checkout file is not a forgotten `cwd:`). The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
|
|
499
523
|
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
|
|
500
524
|
- A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
|
|
501
525
|
- A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
data/docs/testing.md
CHANGED
|
@@ -100,7 +100,7 @@ change, then read the diff.
|
|
|
100
100
|
The bundle-state corpus (`spec/samagotchi/memory_bundle/file_state_corpus/`, `spec/fixtures/bundle_states/`) pins what
|
|
101
101
|
every bundle file state does under every command that reads or writes it. Each case in `memory_cases.yml` (a memory:
|
|
102
102
|
owned, edited, behind, missing, dropped, the user's own, another bundle's too…) and `asset_cases.yml` (a rule, the
|
|
103
|
-
plugin, a script,
|
|
103
|
+
plugin, a script, an older install's class-file hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
|
|
104
104
|
through install, `--force`, `--dry-run`, upgrade and its flags, chi update's plan, Remerge, the system bundle's restore,
|
|
105
105
|
uninstall and status. A cell holds only what a user or the next command sees (the bytes, the trash, base and checksum,
|
|
106
106
|
the mark, the summary's word, status's label, the warnings), never the Installer's result strings. `expect/*.yml` are
|
|
@@ -321,7 +321,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
|
|
|
321
321
|
|
|
322
322
|
## Bundle specs: the test kit
|
|
323
323
|
|
|
324
|
-
A shipped bundle's plugin
|
|
324
|
+
A shipped bundle's plugin is tested through the bundle test kit (`samagotchi/testing`,
|
|
325
325
|
[plugins.md](plugins.md#testing-a-bundle-experimental)): `shipped_bundle("check-in", settings: …)` loads it through
|
|
326
326
|
chi's own loaders, registries, `ctx` and guardrail Gate, with the session, the hook helpers and `ctx.sessions`
|
|
327
327
|
recorded. No fake `chi` or `ctx`: what the kit can't express is a kit change (with a kit spec), not a local fake.
|
|
@@ -340,13 +340,13 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
|
|
|
340
340
|
- `spec/samagotchi/testing/contract_spec.rb`: the kit sets every `Plugin::Host` and `Hooks::Runtime` member,
|
|
341
341
|
`Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
|
|
342
342
|
member added to the Host fails it until the kit has it.
|
|
343
|
-
- `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin
|
|
343
|
+
- `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin registers the same
|
|
344
344
|
commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
|
|
345
345
|
(`Testing::Install`).
|
|
346
346
|
- `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
|
|
347
347
|
example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
|
|
348
348
|
by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
|
|
349
|
-
- `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin,
|
|
349
|
+
- `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, scripts, specs)
|
|
350
350
|
reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
|
|
351
351
|
bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
|
|
352
352
|
no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
|
|
@@ -14,19 +14,11 @@ module Samagotchi
|
|
|
14
14
|
# an entry says how many it came after (+past_idless+, #idless_after), and
|
|
15
15
|
# which one that is when it has an id (+after_opening+, #after_opening).
|
|
16
16
|
#
|
|
17
|
-
# The same place as a stamp (#opening_of): the id of
|
|
18
|
-
# comes after (a prompt's turn_id, a line's
|
|
19
|
-
# finds by its bubble, not by counting.
|
|
17
|
+
# The same place as a stamp (#opening_of, Steer.opening_stamp): the id of
|
|
18
|
+
# the opening the entry comes after (a prompt's turn_id, a line's
|
|
19
|
+
# opening_id), which the page finds by its bubble, not by counting.
|
|
20
|
+
# Pure: no Engine, no file.
|
|
20
21
|
class CardPlaces
|
|
21
|
-
# An opening's stamp: its turn_id (a prompt's, a wake note's), else its
|
|
22
|
-
# own opening_id (/continue no's reason line); nil for neither (a line
|
|
23
|
-
# an older chi wrote). The recap's too (RecapPlace).
|
|
24
|
-
# @return [String, nil]
|
|
25
|
-
def self.stamp(message)
|
|
26
|
-
id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
|
|
27
|
-
id.is_a?(String) && !id.empty? ? id : nil
|
|
28
|
-
end
|
|
29
|
-
|
|
30
22
|
# @param turn_log [Array<String>] the turn ids, oldest first
|
|
31
23
|
# @param messages [Array<Hash>] the messages the page draws
|
|
32
24
|
def initialize(turn_log, messages)
|
|
@@ -123,7 +115,7 @@ module Samagotchi
|
|
|
123
115
|
position >= 0 ? @last_at_or_before[position] : nil
|
|
124
116
|
end
|
|
125
117
|
|
|
126
|
-
def stamp_of(message) =
|
|
118
|
+
def stamp_of(message) = Steer.opening_stamp(message)
|
|
127
119
|
|
|
128
120
|
def idless_run(opening)
|
|
129
121
|
@ids.drop(opening + 1).take_while(&:nil?).size
|
|
@@ -183,7 +183,10 @@ module Samagotchi
|
|
|
183
183
|
when "notice"
|
|
184
184
|
close_text.call
|
|
185
185
|
notice = part[:event]
|
|
186
|
-
|
|
186
|
+
# A damaged drawing's type may be anything: only a name replays.
|
|
187
|
+
if notice.is_a?(Hash) && (notice[:type].is_a?(String) || notice[:type].is_a?(Symbol))
|
|
188
|
+
events << notice.merge(type: notice[:type].to_sym)
|
|
189
|
+
end
|
|
187
190
|
when "steer"
|
|
188
191
|
close_text.call
|
|
189
192
|
events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
|
|
@@ -55,13 +55,14 @@ module Samagotchi
|
|
|
55
55
|
# One line of the file (string-keyed drawing and end, as JSON has them).
|
|
56
56
|
Line = Data.define(:v, :turn_id, :kind, :from, :to, :from_digest, :to_digest, :drawing, :end) do
|
|
57
57
|
# @return [Line, nil] nil for a line this chi can't draw (another
|
|
58
|
-
# version, a missing field)
|
|
58
|
+
# version, a missing field, parts that aren't rows: .rows?)
|
|
59
59
|
def self.from_h(raw)
|
|
60
60
|
return nil unless raw.is_a?(Hash) && raw["v"] == VERSION
|
|
61
61
|
return nil unless raw["turn_id"].is_a?(String) && !raw["turn_id"].empty?
|
|
62
62
|
return nil unless raw["from"].is_a?(Integer) && raw["to"].is_a?(Integer) && raw["from"] < raw["to"] && raw["from"] >= 0
|
|
63
63
|
return nil unless raw["from_digest"].is_a?(String) && raw["to_digest"].is_a?(String)
|
|
64
64
|
return nil unless raw["drawing"].is_a?(Hash) && raw["end"].is_a?(Hash)
|
|
65
|
+
return nil unless TurnDrawings.rows?(raw["drawing"]["parts"])
|
|
65
66
|
|
|
66
67
|
new(v: raw["v"], turn_id: raw["turn_id"], kind: raw["kind"].to_s, from: raw["from"], to: raw["to"],
|
|
67
68
|
from_digest: raw["from_digest"], to_digest: raw["to_digest"], drawing: raw["drawing"], end: raw["end"])
|
|
@@ -134,6 +135,11 @@ module Samagotchi
|
|
|
134
135
|
private :scrubbed
|
|
135
136
|
|
|
136
137
|
class << self
|
|
138
|
+
# Whether a drawing's +parts+ are rows TurnAccumulator.replay_events
|
|
139
|
+
# can unfold: none, or a list of Hashes (a hand-edited line's may be
|
|
140
|
+
# anything, and the unfold would raise).
|
|
141
|
+
def rows?(parts) = parts.nil? || (parts.is_a?(Array) && parts.all?(Hash))
|
|
142
|
+
|
|
137
143
|
# The end event's display keys (END_KEYS), with an empty answer's
|
|
138
144
|
# notice from its summary.
|
|
139
145
|
# @return [Hash]
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -116,11 +116,14 @@ module Samagotchi
|
|
|
116
116
|
# without the worker exiting, for POST /holds/stop; returns the
|
|
117
117
|
# HoldStop::Stopped the 200's +stopped+ carries; without one that route
|
|
118
118
|
# answers 501
|
|
119
|
+
# @param on_activity [#call, nil] what the worker is busy with right now
|
|
120
|
+
# (Worker#activity_snapshot: the same signals WorkerIdleExit#hold reads)
|
|
121
|
+
# for GET /activity; without one that route answers 501
|
|
119
122
|
def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
|
|
120
123
|
port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
|
|
121
124
|
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
|
|
122
125
|
on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
|
|
123
|
-
on_holds: nil, on_holds_stop: nil)
|
|
126
|
+
on_holds: nil, on_holds_stop: nil, on_activity: nil)
|
|
124
127
|
@engine = engine
|
|
125
128
|
@on_input = on_input
|
|
126
129
|
@on_command = on_command
|
|
@@ -129,6 +132,7 @@ module Samagotchi
|
|
|
129
132
|
@exit_stopped = exit_stopped
|
|
130
133
|
@on_holds = on_holds
|
|
131
134
|
@on_holds_stop = on_holds_stop
|
|
135
|
+
@on_activity = on_activity
|
|
132
136
|
@input_format = input_format
|
|
133
137
|
@state_dir = state_dir
|
|
134
138
|
@session_id = session_id
|
|
@@ -687,9 +691,12 @@ module Samagotchi
|
|
|
687
691
|
# pair_turns: a participant's turn in a working session runs as a pair
|
|
688
692
|
# turn (Guardrails::PairBoundary); a worker without it runs it as a
|
|
689
693
|
# review turn, and chi web says so (Delivery::PAIR_FEATURE).
|
|
694
|
+
# activity: GET /activity answers with Worker#activity_snapshot, so
|
|
695
|
+
# `chi schedule tick` can ask a live worker what it is busy with.
|
|
690
696
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
691
697
|
VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
|
|
692
|
-
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE, TurnDrawings::FEATURE
|
|
698
|
+
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE, TurnDrawings::FEATURE,
|
|
699
|
+
WorkerLink::ACTIVITY_FEATURE].freeze
|
|
693
700
|
|
|
694
701
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
695
702
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -706,6 +713,7 @@ module Samagotchi
|
|
|
706
713
|
%w[POST exit] => :handle_exit_request,
|
|
707
714
|
%w[GET holds] => :handle_holds,
|
|
708
715
|
%w[POST holds/stop] => :handle_holds_stop,
|
|
716
|
+
%w[GET activity] => :handle_activity,
|
|
709
717
|
%w[POST recap] => :handle_recap,
|
|
710
718
|
%w[POST relay] => :handle_relay,
|
|
711
719
|
%w[POST relay/status] => :handle_relay_status,
|
|
@@ -1101,6 +1109,22 @@ module Samagotchi
|
|
|
1101
1109
|
[{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
|
|
1102
1110
|
end
|
|
1103
1111
|
|
|
1112
|
+
# GET /session/:id/activity: what this worker is busy with right now — the
|
|
1113
|
+
# same signals WorkerIdleExit#hold reads (a turn running, input queued, a
|
|
1114
|
+
# continue offer waiting, clients holding the stream, reminders, watched
|
|
1115
|
+
# tasks). A `chi schedule tick` runs out of process: it cannot read a
|
|
1116
|
+
# worker's memory, and the session files cannot see an open tab, so it
|
|
1117
|
+
# asks. Read-only and small: no task list, no transcript.
|
|
1118
|
+
# 200 {session_id, turn_running:, input_pending:, awaiting_continue:,
|
|
1119
|
+
# open_streams:, reminders:, tasks_holding:}; 501 activity_unavailable
|
|
1120
|
+
# when this worker has no such step.
|
|
1121
|
+
# Returns [headers, status, body].
|
|
1122
|
+
def handle_activity(_session_id, _body = nil)
|
|
1123
|
+
return [{}, 501, { error: "activity_unavailable" }] unless @on_activity
|
|
1124
|
+
|
|
1125
|
+
[{}, 200, { session_id: @session_id, **@on_activity.call }]
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1104
1128
|
# Queue a turn for this session through its file IPC (the worker polls
|
|
1105
1129
|
# its input dir). Returns [headers, status, body]. Only this bridge's own
|
|
1106
1130
|
# session: another id, in the path or the body, is refused as on every
|
|
@@ -21,7 +21,7 @@ module Samagotchi
|
|
|
21
21
|
#
|
|
22
22
|
# It installs and loads the bundle in a Testing::Sandbox (temp HOME and
|
|
23
23
|
# XDG dirs), not in the user's config or state, but in this process: the
|
|
24
|
-
# plugin's
|
|
24
|
+
# plugin's own code runs as written; --fix rewrites stale
|
|
25
25
|
# sha256 lines (MemoryBundle::Shas). #run returns 0 without errors, 1
|
|
26
26
|
# with any.
|
|
27
27
|
#
|
|
@@ -34,8 +34,8 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
Check the bundle at PATH (default: .) as an install would take it, one finding per line:
|
|
36
36
|
error|warn|info CODE file[:line]: message (fix: hint)
|
|
37
|
-
It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin
|
|
38
|
-
|
|
37
|
+
It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin
|
|
38
|
+
runs in this process as written: run it on code you'd install.
|
|
39
39
|
--fix rewrite stale and placeholder sha256 lines in manifest.yml
|
|
40
40
|
--set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
|
|
41
41
|
Exit status: 0 no errors, 1 errors, 2 usage.
|
|
@@ -81,6 +81,7 @@ module Samagotchi
|
|
|
81
81
|
# @return [Integer] exit status
|
|
82
82
|
def run
|
|
83
83
|
manifest = read_manifest
|
|
84
|
+
manifest = nil if manifest && hooks_retired?
|
|
84
85
|
if manifest
|
|
85
86
|
missing = check_files(manifest)
|
|
86
87
|
check_source_tree(manifest, missing) unless @installed
|
|
@@ -136,12 +137,27 @@ module Samagotchi
|
|
|
136
137
|
Dir.mktmpdir("chi-bundle-check-") do |tmp|
|
|
137
138
|
File.write(File.join(tmp, "manifest.yml"), MemoryBundle::Shas.parseable(@text))
|
|
138
139
|
MemoryBundle::Manifest.read(dir: tmp)
|
|
140
|
+
rescue MemoryBundle::Manifest::HooksRetired => e
|
|
141
|
+
add(:error, :"hooks-retired", "manifest.yml", e.message, line: manifest_line("hooks"), fix: hooks_fix)
|
|
142
|
+
nil
|
|
139
143
|
rescue MemoryBundle::Manifest::ValidationError => e
|
|
140
144
|
add(:error, :manifest, "manifest.yml", e.message.gsub(tmp, display(@dir)))
|
|
141
145
|
nil
|
|
142
146
|
end
|
|
143
147
|
end
|
|
144
148
|
|
|
149
|
+
# A source with hooks/*.rb (listed or not) can't be installed: the run
|
|
150
|
+
# stops there, as after a manifest error (Installer#refuse_hooks).
|
|
151
|
+
def hooks_retired?
|
|
152
|
+
files = MemoryBundle::Manifest::HooksRetired.files_in(@dir)
|
|
153
|
+
return false if files.empty?
|
|
154
|
+
|
|
155
|
+
add(:error, :"hooks-retired", "hooks/", MemoryBundle::Manifest::HooksRetired.for_files(files).message, fix: hooks_fix)
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def hooks_fix = "hook the events from plugin.rb with chi.on, then remove hooks/ and the manifest's hooks:"
|
|
160
|
+
|
|
145
161
|
# The line of manifest.yml naming +file+ (its entry), or nil.
|
|
146
162
|
def manifest_line(file)
|
|
147
163
|
base = File.basename(file)
|
|
@@ -192,7 +208,7 @@ module Samagotchi
|
|
|
192
208
|
# @return [Boolean] whether a listed file is missing
|
|
193
209
|
def check_files(manifest)
|
|
194
210
|
listed = manifest.files.keys + Array(manifest.plugin && manifest.plugin[:file]) +
|
|
195
|
-
manifest.
|
|
211
|
+
manifest.scripts.keys.map { |s| File.join("scripts", s) }
|
|
196
212
|
missing = listed.reject { |rel| File.file?(path(rel)) }
|
|
197
213
|
missing.each do |rel|
|
|
198
214
|
add(:error, :"missing-file", "manifest.yml", "lists #{rel}, which isn't in the bundle", line: manifest_line(rel),
|
|
@@ -202,7 +218,7 @@ module Samagotchi
|
|
|
202
218
|
end
|
|
203
219
|
|
|
204
220
|
# What an install leaves out: a memory files: doesn't list (by
|
|
205
|
-
# MemoryFiles' rules), a plugin
|
|
221
|
+
# MemoryFiles' rules), a plugin or script the manifest doesn't name.
|
|
206
222
|
def check_unlisted(manifest)
|
|
207
223
|
memories = MemoryBundle::MemoryFiles.select(@dir, manifest)
|
|
208
224
|
loose = Dir.glob(path("*.md")).map { |f| File.basename(f) }.sort.reject do |name|
|
|
@@ -211,9 +227,6 @@ module Samagotchi
|
|
|
211
227
|
loose.each { |name| unlisted(name, "files:") }
|
|
212
228
|
plugin = manifest.plugin && manifest.plugin[:file]
|
|
213
229
|
unlisted("plugin.rb", "plugin:") if File.file?(path("plugin.rb")) && plugin != "plugin.rb"
|
|
214
|
-
Dir.glob(path("hooks/*.rb")).sort.each do |file|
|
|
215
|
-
unlisted("hooks/#{File.basename(file)}", "hooks:") unless manifest.hooks.key?(File.basename(file))
|
|
216
|
-
end
|
|
217
230
|
Dir.glob(path("scripts/*")).sort.each do |file|
|
|
218
231
|
unlisted("scripts/#{File.basename(file)}", "scripts:") unless manifest.scripts.key?(File.basename(file))
|
|
219
232
|
end
|
|
@@ -251,7 +264,7 @@ module Samagotchi
|
|
|
251
264
|
|
|
252
265
|
# ── requires_chi ──────────────────────────────────────────────────
|
|
253
266
|
|
|
254
|
-
def code?(manifest) = manifest.plugin || manifest.
|
|
267
|
+
def code?(manifest) = manifest.plugin || manifest.scripts.any?
|
|
255
268
|
|
|
256
269
|
def check_requires_chi(manifest)
|
|
257
270
|
line = manifest_line("requires_chi")
|
|
@@ -414,7 +427,7 @@ module Samagotchi
|
|
|
414
427
|
registered.tools.each { |name| add(:info, :registered, plugin, "tool #{name}") }
|
|
415
428
|
registered.hooks.each do |event, hooks|
|
|
416
429
|
hooks.each do |hook|
|
|
417
|
-
file =
|
|
430
|
+
file = plugin
|
|
418
431
|
next add(:info, :registered, file, "hook #{event} (priority #{hook.priority})") if Hooks::EVENTS.include?(event)
|
|
419
432
|
|
|
420
433
|
add(:warn, :"hook-event", file, "hook on #{event.inspect}, which chi never fires",
|
|
@@ -59,8 +59,9 @@ module Samagotchi
|
|
|
59
59
|
|
|
60
60
|
# What the child sends: an edited file, which the copy's own shas can't
|
|
61
61
|
# tell, a manifest key that doesn't parse (the copy holds the mirror's
|
|
62
|
-
# value or none: InstalledManifest#key_problems),
|
|
63
|
-
#
|
|
62
|
+
# value or none: InstalledManifest#key_problems), the class-file hooks
|
|
63
|
+
# the record still has (they don't load), and Check's warnings and
|
|
64
|
+
# errors, naming the installed bundle's folder rather than the copy's.
|
|
64
65
|
def answer_of(bundle)
|
|
65
66
|
edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
|
|
66
67
|
Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
|
|
@@ -69,6 +70,7 @@ module Samagotchi
|
|
|
69
70
|
edited += bundle.manifest.key_problems.values.map do |message|
|
|
70
71
|
Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
|
|
71
72
|
end
|
|
73
|
+
edited << retired_hooks(bundle) unless bundle.hooks.empty?
|
|
72
74
|
edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
|
|
73
75
|
installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
|
|
74
76
|
edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
|
|
@@ -80,6 +82,15 @@ module Samagotchi
|
|
|
80
82
|
end
|
|
81
83
|
private_class_method :answer_of
|
|
82
84
|
|
|
85
|
+
# A 0.157 record's class-file hooks: they no longer load, and only an
|
|
86
|
+
# upgrade from a source that has moved them (or an uninstall) clears it.
|
|
87
|
+
def retired_hooks(bundle)
|
|
88
|
+
Check::Finding.new(level: :warn, code: :"hooks-retired", file: "hooks/", line: nil,
|
|
89
|
+
message: MemoryBundle::Manifest::HooksRetired::WORDS,
|
|
90
|
+
fix: "chi bundle upgrade #{bundle.name} once its source hooks from plugin.rb, or chi bundle uninstall #{bundle.name}")
|
|
91
|
+
end
|
|
92
|
+
private_class_method :retired_hooks
|
|
93
|
+
|
|
83
94
|
def died(why, prefix: true) = Found.new(level: "error", text: check_line(prefix ? "the check died: #{why}" : why))
|
|
84
95
|
private_class_method :died
|
|
85
96
|
|
|
@@ -163,7 +163,10 @@ module Samagotchi
|
|
|
163
163
|
reader.join(0.1) if reader.alive?
|
|
164
164
|
reader.kill if reader.alive?
|
|
165
165
|
end
|
|
166
|
-
|
|
166
|
+
# readpartial gives raw bytes: the child writes UTF-8 (the answer,
|
|
167
|
+
# the trace), so each stream is read as that, a bad byte as "?",
|
|
168
|
+
# here once, for the text and the --json report alike.
|
|
169
|
+
readers.map { |reader| reader.value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?") }
|
|
167
170
|
end
|
|
168
171
|
|
|
169
172
|
def close_streams(streams)
|