samagotchi 0.74.0 → 0.76.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 +68 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +21 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +140 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +32 -35
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +18 -8
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +7 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- metadata +4 -1
data/docs/sessions.md
CHANGED
|
@@ -51,6 +51,8 @@ chi sessions stats ID [--format text|json] # cost, tokens and progres
|
|
|
51
51
|
|
|
52
52
|
`chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics` (its `turn_records` / `tool_records` are the recent ones only: a live worker's newest finished turn and any not yet saved, the disk snapshot's newest saved turn; `analytics.json` holds them all); a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
|
|
53
53
|
|
|
54
|
+
Each turn record in `analytics.json` names the model the turn ran on as `model_ref` (the asked `host:model`, from its first generation; `model` stays the name the server says it served); a turn stopped before it asked a model has none. The first turn after a `/model` switch (or a hook's) to another model also records what its first generation prefilled on the new model: `switch_prefill_tokens` (the prompt less the cached reads; none under 128 tokens, or for a generation with estimated counts) and `switch_prefill_cost_estimate` (from the new model's configured price, no completion; none without a price). A session saved before this has no `model_ref` on its records.
|
|
55
|
+
|
|
54
56
|
A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
|
|
55
57
|
|
|
56
58
|
Examples:
|
|
@@ -74,9 +76,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
74
76
|
**Ordering:**
|
|
75
77
|
|
|
76
78
|
- `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.
|
|
77
|
-
- 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 chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), 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 chat starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
79
|
+
- 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 chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
78
80
|
- 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).
|
|
79
|
-
- 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
|
|
81
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
80
82
|
- 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).
|
|
81
83
|
|
|
82
84
|
**Test-session hygiene:**
|
|
@@ -204,19 +206,19 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
204
206
|
|
|
205
207
|
## Session chains
|
|
206
208
|
|
|
207
|
-
A session can **continue** an earlier one: "the next
|
|
209
|
+
A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
|
|
208
210
|
|
|
209
211
|
`SessionManager.continue_session` makes the next link:
|
|
210
212
|
|
|
211
213
|
- **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
|
|
212
|
-
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
214
|
+
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
213
215
|
- **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
|
|
214
216
|
- **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
|
|
215
217
|
- **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
|
|
216
218
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
217
219
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
218
220
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
219
|
-
- **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 already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its
|
|
221
|
+
- **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 already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
220
222
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
221
223
|
- **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.
|
|
222
224
|
|
|
@@ -146,8 +146,12 @@ module Samagotchi
|
|
|
146
146
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
bundle_name =
|
|
150
|
-
|
|
149
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Install")
|
|
150
|
+
return 1 unless prepared
|
|
151
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
|
|
152
|
+
|
|
153
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
|
|
154
|
+
run_installer(installer, failed: "Install") do |manifest|
|
|
151
155
|
print_hooks_and_plugin(manifest)
|
|
152
156
|
print_provenance(bundle_name, manifest)
|
|
153
157
|
end
|
|
@@ -164,7 +168,7 @@ module Samagotchi
|
|
|
164
168
|
dry_run = opts.fetch(:dry_run, false)
|
|
165
169
|
agent = opts[:agent]
|
|
166
170
|
if source.nil? || source.empty?
|
|
167
|
-
@stderr.puts
|
|
171
|
+
@stderr.puts UPGRADE_HELP
|
|
168
172
|
return CLI::Exit::USAGE
|
|
169
173
|
end
|
|
170
174
|
expanded_source = expand_source(source)
|
|
@@ -172,18 +176,21 @@ module Samagotchi
|
|
|
172
176
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
|
|
173
177
|
end
|
|
174
178
|
|
|
175
|
-
bundle_name =
|
|
179
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Upgrade")
|
|
180
|
+
return 1 unless prepared
|
|
181
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Upgrade")
|
|
182
|
+
|
|
176
183
|
unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
177
184
|
@stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
|
|
178
185
|
# (no Hooks/Plugin lines here)
|
|
179
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
|
|
186
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dry_run: dry_run)
|
|
180
187
|
return run_installer(installer, failed: "Install") do |manifest|
|
|
181
188
|
next dry_run_done if dry_run
|
|
182
189
|
|
|
183
190
|
print_provenance(bundle_name, manifest)
|
|
184
191
|
end
|
|
185
192
|
end
|
|
186
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
193
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
187
194
|
run_installer(installer, failed: "Upgrade") do |manifest|
|
|
188
195
|
print_hooks_and_plugin(manifest, plugin: !dry_run)
|
|
189
196
|
next dry_run_done if dry_run
|
|
@@ -193,12 +200,44 @@ module Samagotchi
|
|
|
193
200
|
end
|
|
194
201
|
end
|
|
195
202
|
|
|
196
|
-
# The
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
# The source normalized once (a git clone, a zip or tar extract) and the
|
|
204
|
+
# bundle's name, its manifest's name: for every kind of source, a git URL
|
|
205
|
+
# too. The Installer installs from that extract and removes it. Nil
|
|
206
|
+
# after "<word> failed: …" (an unknown source, or a missing or invalid
|
|
207
|
+
# manifest: an install needs one anyway), the extract removed.
|
|
208
|
+
# @return [Array(MemoryBundle::PreparedSource, String), nil]
|
|
209
|
+
def prepare_or_fail(expanded_source, word)
|
|
210
|
+
prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
|
|
211
|
+
named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
|
|
212
|
+
rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
|
|
213
|
+
Samagotchi::MemoryBundle::Manifest::ValidationError => e
|
|
200
214
|
@stderr.puts "#{word} failed: #{e.message}"
|
|
201
|
-
|
|
215
|
+
ensure
|
|
216
|
+
# Any failure (an unexpected error too) removes the extract.
|
|
217
|
+
prepared&.cleanup unless named
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A bundle installed from this source under another name: a git URL
|
|
221
|
+
# bundle recorded before chi named it by its manifest (it took the
|
|
222
|
+
# repo's name, chi-foo for name: foo). Installing it again would load
|
|
223
|
+
# both copies' plugin and hooks, so "<word> failed: … installed as
|
|
224
|
+
# chi-foo" instead (the extract removed). Only when no bundle has the
|
|
225
|
+
# manifest's name; sources compare by SourceIdentity.
|
|
226
|
+
# @return [Boolean] refused
|
|
227
|
+
def installed_under_another_name?(expanded_source, bundle_name, prepared, word)
|
|
228
|
+
return false if Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
229
|
+
|
|
230
|
+
identity = Samagotchi::MemoryBundle::SourceIdentity.of(expanded_source)
|
|
231
|
+
return false unless identity
|
|
232
|
+
|
|
233
|
+
other, = Samagotchi::MemoryBundle::Provenance.each_installed.find do |name, bundle|
|
|
234
|
+
name != bundle_name && !bundle.error? && Samagotchi::MemoryBundle::SourceIdentity.of(bundle.source) == identity
|
|
235
|
+
end
|
|
236
|
+
return false unless other
|
|
237
|
+
|
|
238
|
+
prepared.cleanup
|
|
239
|
+
@stderr.puts "#{word} failed: #{expanded_source} is installed as #{other}: `chi bundle uninstall #{other}`, then install it again"
|
|
240
|
+
true
|
|
202
241
|
end
|
|
203
242
|
|
|
204
243
|
def installer_for(source, name, scope:, force:, **)
|
|
@@ -728,40 +767,14 @@ module Samagotchi
|
|
|
728
767
|
parsed
|
|
729
768
|
end
|
|
730
769
|
|
|
731
|
-
#
|
|
770
|
+
# The source as given: a git URL as is, a shipped bundle's name as its
|
|
771
|
+
# dir, anything else as an expanded path.
|
|
732
772
|
def expand_source(src)
|
|
733
773
|
return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
|
|
734
774
|
|
|
735
775
|
Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
|
|
736
776
|
end
|
|
737
777
|
|
|
738
|
-
def bundle_name_for(expanded_src)
|
|
739
|
-
# Try to derive from manifest if possible (skip git clone for speed if possible)
|
|
740
|
-
if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
|
|
741
|
-
# For git URLs, avoid double clone: derive name from URL basename if no manifest yet
|
|
742
|
-
# Let Installer do the real manifest read; fallback to URL basename
|
|
743
|
-
base = expanded_src.split("/").last.to_s.split("#").first.to_s
|
|
744
|
-
base = base.sub(/\.git\z/i, "")
|
|
745
|
-
return base.empty? ? "bundle" : base
|
|
746
|
-
end
|
|
747
|
-
require "yaml"
|
|
748
|
-
nd = nil
|
|
749
|
-
owned = false
|
|
750
|
-
begin
|
|
751
|
-
nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
|
|
752
|
-
m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
|
|
753
|
-
m.name
|
|
754
|
-
rescue Samagotchi::MemoryBundle::Manifest::ValidationError
|
|
755
|
-
# Fallback to source basename without archive extensions
|
|
756
|
-
src = expanded_src
|
|
757
|
-
name = File.basename(src)
|
|
758
|
-
name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
|
|
759
|
-
name.empty? ? File.basename(nd || src) : name
|
|
760
|
-
ensure
|
|
761
|
-
Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
|
|
762
|
-
end
|
|
763
|
-
end
|
|
764
|
-
|
|
765
778
|
def build_conflict_prompt(bundle_name, conflicts, expanded_source)
|
|
766
779
|
lines = []
|
|
767
780
|
lines << "Memory bundle upgrade has conflicts that need interactive resolution."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-peer
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -10,7 +10,7 @@ files:
|
|
|
10
10
|
description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Codex's answer comes as the codex-outbox context"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
13
|
+
sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
|
|
14
14
|
scripts:
|
|
15
15
|
pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
|
|
16
16
|
chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
|
|
@@ -168,10 +168,16 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
168
168
|
# [stdout, stderr, status]; both pipes read on threads, so a full one
|
|
169
169
|
# can't block the command.
|
|
170
170
|
def run(*command)
|
|
171
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUEUE_SECONDS
|
|
171
172
|
Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
172
173
|
stdin.close
|
|
173
174
|
out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
174
|
-
|
|
175
|
+
threads = [wait, out, err]
|
|
176
|
+
within_deadline = lambda do |thread|
|
|
177
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
thread.join([remaining, 0].max)
|
|
179
|
+
end
|
|
180
|
+
unless threads.all?(&within_deadline)
|
|
175
181
|
begin
|
|
176
182
|
Process.kill("KILL", -wait.pid)
|
|
177
183
|
rescue SystemCallError
|
|
@@ -179,7 +185,18 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
179
185
|
end
|
|
180
186
|
# The group is gone, so the pipes end: let the readers finish
|
|
181
187
|
# before popen3 closes them.
|
|
182
|
-
|
|
188
|
+
cleanup_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
189
|
+
threads.each do |thread|
|
|
190
|
+
remaining = cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
thread.join([remaining, 0].max)
|
|
192
|
+
end
|
|
193
|
+
# The queue itself finished fine, and only a process it left held
|
|
194
|
+
# the pipes: the message is likely queued, so don't say "try again"
|
|
195
|
+
# (a retry would deliver it twice).
|
|
196
|
+
if wait.join(0) && wait.value.success?
|
|
197
|
+
raise SendError, "codex queue exited but left a process holding its output (stopped after " \
|
|
198
|
+
"#{QUEUE_SECONDS} s): the message may already be queued, check Codex before sending again"
|
|
199
|
+
end
|
|
183
200
|
raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
|
|
184
201
|
"again, or pair again from Codex"
|
|
185
202
|
end
|
|
@@ -152,8 +152,7 @@ module Samagotchi
|
|
|
152
152
|
def launch_config
|
|
153
153
|
env = { "LANG" => LANG }
|
|
154
154
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
155
|
-
env
|
|
156
|
-
env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
|
|
155
|
+
env.merge!(Paths.absolute_xdg(env: env, set: :key))
|
|
157
156
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
158
157
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
159
158
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
@@ -168,6 +168,11 @@ module Samagotchi
|
|
|
168
168
|
unresolved(:window_unresolved, e, nil)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# The window config.yml gives this model or its host
|
|
172
|
+
# (ContextWindow.setting), never asking a server: a session loaded on
|
|
173
|
+
# it (Engine#session=, before a worker's Bridge serves). nil when none.
|
|
174
|
+
def configured_context_window = ContextWindow.setting(target, names: names)
|
|
175
|
+
|
|
171
176
|
# The window as its loop would see it (ChatLoop#context_window: a remote
|
|
172
177
|
# chat host has no /props, only its model list); nil when it can't be
|
|
173
178
|
# resolved.
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1005,6 +1005,9 @@ module Samagotchi
|
|
|
1005
1005
|
sync_kernel_client!
|
|
1006
1006
|
@client.invalidate_context_window!
|
|
1007
1007
|
@metrics.forget_model_reports!
|
|
1008
|
+
# The last turn's ctx % was the old model's window's
|
|
1009
|
+
# (#announce_switched_context! sends the new one's).
|
|
1010
|
+
@last_context_status = nil
|
|
1008
1011
|
ref = effective_model_ref
|
|
1009
1012
|
if persist_default
|
|
1010
1013
|
ConfigFile.write_default_model!(ref)
|
|
@@ -1013,6 +1016,42 @@ module Samagotchi
|
|
|
1013
1016
|
ref
|
|
1014
1017
|
end
|
|
1015
1018
|
|
|
1019
|
+
# After /model: how full the context is on the new model's window,
|
|
1020
|
+
# before its first turn: the last turn's count (the old model's
|
|
1021
|
+
# tokenizer's, replaced at the next generation) against the new model's
|
|
1022
|
+
# window (EffectiveModel#context_window, ContextWindow.resolve: the
|
|
1023
|
+
# running server first, one /props GET that a remote chat host is
|
|
1024
|
+
# spared; else the host's cached model list; else the configured
|
|
1025
|
+
# window) and the
|
|
1026
|
+
# session's budget. Sent as a :context_status event (the web's ctx
|
|
1027
|
+
# meter, the attached TUI's status row) with context_window_tokens, and
|
|
1028
|
+
# kept for a client that joins later (#session_state_snapshot; the
|
|
1029
|
+
# window in the metrics' context too, which a page counts from). A window
|
|
1030
|
+
# that can't be resolved sends usage nil: the UIs clear the % until the
|
|
1031
|
+
# next generation. Nothing before the session's first counted turn.
|
|
1032
|
+
# +configured_only+: the configured window only, never asking a server
|
|
1033
|
+
# (EffectiveModel#configured_context_window): a loaded session's (#session=).
|
|
1034
|
+
# @return [Hash, nil] the event sent
|
|
1035
|
+
def announce_switched_context!(configured_only: false)
|
|
1036
|
+
used = @metrics.snapshot.dig(:context, :used_tokens)
|
|
1037
|
+
return nil unless used.is_a?(Integer)
|
|
1038
|
+
|
|
1039
|
+
model = effective_model
|
|
1040
|
+
window = configured_only ? model.configured_context_window : current_context_window(model)
|
|
1041
|
+
display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
|
|
1042
|
+
@last_context_status = display&.dup
|
|
1043
|
+
@metrics.note_context_window(window.tokens, window.source) if window
|
|
1044
|
+
usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
|
|
1045
|
+
window_source: window.source.to_s, source: "switch" }
|
|
1046
|
+
event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
|
|
1047
|
+
context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
|
|
1048
|
+
@session_observer.notify(event)
|
|
1049
|
+
event
|
|
1050
|
+
rescue StandardError => e
|
|
1051
|
+
Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1052
|
+
nil
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1016
1055
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
1017
1056
|
|
|
1018
1057
|
# Register a hook callback for a named lifecycle event.
|
|
@@ -1640,8 +1679,30 @@ module Samagotchi
|
|
|
1640
1679
|
# A woken worker's /stats and status line count the turns before it.
|
|
1641
1680
|
bind_metrics(session)
|
|
1642
1681
|
@used_memories.absorb(session)
|
|
1682
|
+
# Loaded on another model than its last turn ran on (a --resume'd
|
|
1683
|
+
# REPL's --model, a worker woken after a /model with no turn since):
|
|
1684
|
+
# the saved ctx % was that model's window's. Drop it and count it as
|
|
1685
|
+
# /model does, on a configured window only: this runs before a
|
|
1686
|
+
# worker's Bridge serves, so no /props GET or model list here (a slow
|
|
1687
|
+
# or hung host would hold up the worker's start). No configured
|
|
1688
|
+
# window: no % (ctx ?) until the first generation reports one.
|
|
1689
|
+
return unless model_switched_since_last_turn?
|
|
1690
|
+
|
|
1691
|
+
@metrics.forget_model_reports!
|
|
1692
|
+
announce_switched_context!(configured_only: true)
|
|
1643
1693
|
end
|
|
1644
1694
|
|
|
1695
|
+
# Whether the last turn that named its model ran on another one than
|
|
1696
|
+
# the effective model; false before any (or when the ref can't be told).
|
|
1697
|
+
def model_switched_since_last_turn?
|
|
1698
|
+
last = @metrics.last_model_ref
|
|
1699
|
+
!last.nil? && last != effective_model_ref
|
|
1700
|
+
rescue StandardError => e
|
|
1701
|
+
Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1702
|
+
false
|
|
1703
|
+
end
|
|
1704
|
+
private :model_switched_since_last_turn?
|
|
1705
|
+
|
|
1645
1706
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
1646
1707
|
attr_reader :recap
|
|
1647
1708
|
|
|
@@ -2047,7 +2108,8 @@ module Samagotchi
|
|
|
2047
2108
|
result = backend(model).complete(
|
|
2048
2109
|
messages: turn.messages,
|
|
2049
2110
|
max_iterations: turn.limit,
|
|
2050
|
-
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller
|
|
2111
|
+
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
|
|
2112
|
+
model_ref: generation_model_ref(model)),
|
|
2051
2113
|
cancel_controller: turn.controller,
|
|
2052
2114
|
model_name: bare_for_backend,
|
|
2053
2115
|
max_tool_output_chars: max_tool_output_chars,
|
|
@@ -2059,6 +2121,18 @@ module Samagotchi
|
|
|
2059
2121
|
end
|
|
2060
2122
|
private :generate
|
|
2061
2123
|
|
|
2124
|
+
# The ref (host:model, as asked) a turn's generations ran on: the model
|
|
2125
|
+
# #generate resolved, after the before_turn hooks (which may switch it).
|
|
2126
|
+
# SessionMetrics keys its per-model segments on it. nil when it can't be
|
|
2127
|
+
# named (the turn's record then has none).
|
|
2128
|
+
def generation_model_ref(model)
|
|
2129
|
+
model.ref
|
|
2130
|
+
rescue StandardError => e
|
|
2131
|
+
Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2132
|
+
nil
|
|
2133
|
+
end
|
|
2134
|
+
private :generation_model_ref
|
|
2135
|
+
|
|
2062
2136
|
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2063
2137
|
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2064
2138
|
# Entries before it never move between the turns (#turn_messages adds
|
|
@@ -2594,13 +2668,17 @@ module Samagotchi
|
|
|
2594
2668
|
#
|
|
2595
2669
|
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
2596
2670
|
# each chunk's thinking and text after the UIs had it.
|
|
2597
|
-
|
|
2671
|
+
#
|
|
2672
|
+
# +model_ref+: the turn's model (#generation_model_ref), added to each
|
|
2673
|
+
# :generation_started as `model_ref:` (both loops send through here).
|
|
2674
|
+
def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
|
|
2598
2675
|
watch = stream_watch(cancel_controller)
|
|
2599
2676
|
proc do |event|
|
|
2600
2677
|
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
2601
2678
|
@steer_cut.observe(event, progress)
|
|
2602
2679
|
case event[:type]
|
|
2603
2680
|
when :generation_started
|
|
2681
|
+
event = event.merge(model_ref: model_ref) if model_ref
|
|
2604
2682
|
@thinking_notices.generation_started
|
|
2605
2683
|
watch&.started(event[:iteration])
|
|
2606
2684
|
when :generation_retrying
|
|
@@ -2626,13 +2704,15 @@ module Samagotchi
|
|
|
2626
2704
|
|
|
2627
2705
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2628
2706
|
# {decode_tps:, source:}, nil without one), the session's running
|
|
2629
|
-
# token totals (`tokens:`, the snapshot's block) and
|
|
2707
|
+
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
2708
|
+
# (`model_segments:`, the snapshot's) and whether its served
|
|
2630
2709
|
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
2631
2710
|
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2632
2711
|
# so the event it gets next finds it closed.
|
|
2633
2712
|
def with_generation_report(event)
|
|
2634
2713
|
report = @metrics.finish_generation
|
|
2635
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens,
|
|
2714
|
+
event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
|
|
2715
|
+
served_expected_by: served_expected_by(event[:served_model]))
|
|
2636
2716
|
rescue StandardError => e
|
|
2637
2717
|
Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2638
2718
|
event
|
|
@@ -943,19 +943,22 @@ module Samagotchi
|
|
|
943
943
|
private :emit_llm_context
|
|
944
944
|
|
|
945
945
|
# A request's re-prefilled tokens, for both loops: of the prompt the
|
|
946
|
-
# last request on +model+
|
|
947
|
-
# server didn't reuse from its cache this
|
|
948
|
-
# +cached+), so an LLM context edit's cache break
|
|
949
|
-
# anything else that rewrote the prompt's middle or
|
|
950
|
-
# under TokenUsage::REPREFILL_MIN_TOKENS (a cache
|
|
951
|
-
# a server that reports no cached count (or 0: a
|
|
952
|
-
# from no cache), the first request, a model
|
|
953
|
-
#
|
|
946
|
+
# last request on +model+ through this client (the host) sent (this
|
|
947
|
+
# kernel's, any turn), what the server didn't reuse from its cache this
|
|
948
|
+
# time (that prompt less +cached+), so an LLM context edit's cache break
|
|
949
|
+
# shows, and so does anything else that rewrote the prompt's middle or
|
|
950
|
+
# lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
|
|
951
|
+
# block's rounding), for a server that reports no cached count (or 0: a
|
|
952
|
+
# cold cache isn't told from no cache), the first request, a model
|
|
953
|
+
# switch (another bare model, or the same one on another host: the
|
|
954
|
+
# Engine points #client at the new host's; SessionMetrics counts that
|
|
955
|
+
# prefill as the switch's), and the first request after a warm-up
|
|
956
|
+
# (#warmed_up!).
|
|
954
957
|
# @return [Integer, nil]
|
|
955
958
|
def reprefilled_tokens(model, prompt:, cached:)
|
|
956
959
|
previous = @reprefill_baseline
|
|
957
|
-
@reprefill_baseline = { model: model, prompt: prompt.to_i } if prompt.to_i.positive?
|
|
958
|
-
return nil unless previous && previous[:model] == model && cached.to_i.positive?
|
|
960
|
+
@reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
|
|
961
|
+
return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
|
|
959
962
|
|
|
960
963
|
tokens = previous[:prompt] - cached.to_i
|
|
961
964
|
tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
|
|
@@ -125,7 +125,14 @@ module Samagotchi
|
|
|
125
125
|
raise ArgumentError, "unknown llm_context field #{field}" unless FIELDS.include?(field)
|
|
126
126
|
|
|
127
127
|
text = word.to_s.strip
|
|
128
|
-
|
|
128
|
+
value = if text.casecmp?(DEFAULT_WORD)
|
|
129
|
+
nil
|
|
130
|
+
elsif field == :budget_tokens
|
|
131
|
+
LLMContextStrategy.budget_tokens(text)
|
|
132
|
+
else
|
|
133
|
+
send(:"parse_#{field}", text)
|
|
134
|
+
end
|
|
135
|
+
[field, value]
|
|
129
136
|
end
|
|
130
137
|
(current || new).with(**fields)
|
|
131
138
|
end
|
|
@@ -152,11 +159,6 @@ module Samagotchi
|
|
|
152
159
|
rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
|
|
153
160
|
end
|
|
154
161
|
|
|
155
|
-
# "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
|
|
156
|
-
# .update reaches it (and the other parse_<field>s) by its name.
|
|
157
|
-
# @return [Integer] 0 for off
|
|
158
|
-
def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
|
|
159
|
-
|
|
160
162
|
private_class_method :file_value, :file_strategy, :file_budget
|
|
161
163
|
end
|
|
162
164
|
end
|