samagotchi 0.73.0 → 0.74.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 +25 -1
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/background-tasks.md +2 -1
- data/docs/plugins.md +9 -2
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -0
- data/lib/samagotchi/paths.rb +8 -6
- data/lib/samagotchi/session_manager.rb +7 -0
- data/lib/samagotchi/tool_output_cut.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +17 -4
- data/lib/samagotchi/tools/task_runtime.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/plain_line.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +4 -2
- data/lib/samagotchi/web/public/stage_model.js +3 -33
- data/lib/samagotchi/web/public/turn_html.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +7 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baa839500eb96519376830a675e787eab121aab4c8de33fa72ffa3f007469643
|
|
4
|
+
data.tar.gz: 17e32518eaf7348ec4a08514a302a14139d71369904602815662186d9dfa584d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f52356f77fe80e1cb56f3d28c4c7844c08ca4291ee3090a89f18a13261b5ee8db5d2b77835da6b756a118e3a30000b5fe2160f3de255f90ee30294728b0edd9
|
|
7
|
+
data.tar.gz: 88d10148534c0e9daaa6b5f97356b9b4f55311a25fa591ca48f70422f5c3cd128d9204b9bf7ea3f65e71c5fd26b7554de1571ddc650311ed1ab0af67eb4073fe
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,29 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.74.0] - 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Web: a closed step's title and an answered question card's two-line copy of the reply that asked it show that
|
|
16
|
+
reply's text without its markdown syntax ("## Decision needed", "**A**", `` `port` ``, `[text](url)` read as plain
|
|
17
|
+
words); the step's body and the card's open copy stay as written.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- mcp bundle 0.7.1: an MCP server that ignores its stdin's EOF no longer outlives a chi killed with `kill -9` (or
|
|
22
|
+
crashing past Ruby): a watchdog in the server's process group stops the group when chi's end of its pipe closes.
|
|
23
|
+
- A relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` is taken from the current directory: with a relative state dir,
|
|
24
|
+
an `execute` still running at its limit was killed instead of handed over as a task.
|
|
25
|
+
- A worker started in a session's folder (by `chi`, `chi web` or `chi send`) gets the starter's `XDG_STATE_HOME` and
|
|
26
|
+
`XDG_CONFIG_HOME` made absolute: a relative one no longer points it at another state or config dir.
|
|
27
|
+
- `chi desktop install` / `upgrade` bake a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` into the helper's launch
|
|
28
|
+
file made absolute: launchd starts the helper in `/`, where it pointed at `/<dir>`.
|
|
29
|
+
- `execute` and `task_create` pass a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` on to their commands made
|
|
30
|
+
absolute: a `chi send` run in another `cwd` no longer reads another state or config dir.
|
|
31
|
+
|
|
32
|
+
Update with `chi update` (the system bundle 0.74.0, mcp 0.7.1), then restart `chi web`.
|
|
33
|
+
|
|
11
34
|
## [0.73.0] - 2026-10-16
|
|
12
35
|
|
|
13
36
|
### Added
|
|
@@ -3133,7 +3156,8 @@ and long-lived sessions.
|
|
|
3133
3156
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3134
3157
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3135
3158
|
|
|
3136
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3159
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.74.0...HEAD
|
|
3160
|
+
[0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
|
|
3137
3161
|
[0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
|
|
3138
3162
|
[0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
|
|
3139
3163
|
[0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
|
data/docs/desktop.md
CHANGED
|
@@ -239,7 +239,8 @@ exports. So `install` writes `~/Library/Application Support/Chi Helper/launch.js
|
|
|
239
239
|
- the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`);
|
|
240
240
|
- `LANG=en_US.UTF-8`;
|
|
241
241
|
- only these variables, and only when they are set: `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `GEM_HOME`, `GEM_PATH`,
|
|
242
|
-
`RUBYLIB
|
|
242
|
+
`RUBYLIB` (a relative `XDG_*` dir made absolute from the install's current folder: the helper starts in `/`).
|
|
243
|
+
No tokens and no `SAMAGOTCHI_*` settings go in;
|
|
243
244
|
- the settings the panel needs from config.yml, as chi read them then: the `kitty:` section (when
|
|
244
245
|
`kitty.listen_on` is set), the `tmux:` section (when `tmux.enabled`: the tmux binary and the socket dir resolved
|
|
245
246
|
then, and the agents) and `broadcast_timeout` (`broadcast.triage_deadline` + 10 s).
|
data/docs/guardrails.md
CHANGED
|
@@ -155,7 +155,8 @@ chi starts unsets every `SAMAGOTCHI_GUARDRAILS_*` it would inherit. What a
|
|
|
155
155
|
parent's environment does choose is the config dir: `XDG_CONFIG_HOME` picks
|
|
156
156
|
which config.yml chi reads, and a worker the parent starts or wakes (`chi
|
|
157
157
|
send` to a stopped session) reads the one the parent's `XDG_CONFIG_HOME`
|
|
158
|
-
points at
|
|
158
|
+
points at (a relative one made absolute from the parent's directory). chi
|
|
159
|
+
doesn't record the config dir per session.
|
|
159
160
|
|
|
160
161
|
The worker checks it too. `chi answer` marks its answers as a parent's
|
|
161
162
|
(`client_id: "cli:answer"`), and the worker's Bridge refuses such an allow
|
|
@@ -22,7 +22,7 @@ Behavior:
|
|
|
22
22
|
- A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's idle exit) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
|
|
23
23
|
- A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
|
|
24
24
|
- Task listing is workspace-scoped (current project only).
|
|
25
|
-
- `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
|
|
25
|
+
- `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (always an absolute path: a relative `XDG_STATE_HOME` is taken from the directory chi started in; outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
|
|
26
26
|
- The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
|
|
27
27
|
- A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
|
|
28
28
|
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
|
|
@@ -32,5 +32,6 @@ Behavior:
|
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|
|
33
33
|
- `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
|
|
34
34
|
- `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
|
|
35
|
+
- They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
|
|
35
36
|
|
|
36
37
|
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
data/docs/plugins.md
CHANGED
|
@@ -508,7 +508,8 @@ end
|
|
|
508
508
|
- A plugin whose load fails after it started services has them stopped.
|
|
509
509
|
- `kill -9` runs nothing: a child process is orphaned then. Most stdio
|
|
510
510
|
servers leave when their stdin closes, which it does as chi's process
|
|
511
|
-
ends.
|
|
511
|
+
ends. The mcp bundle also stops a server that doesn't (see
|
|
512
|
+
[The mcp bundle](#the-mcp-bundle), Stop).
|
|
512
513
|
|
|
513
514
|
### `chi.init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil) { |ctx| … }`
|
|
514
515
|
|
|
@@ -1150,7 +1151,13 @@ bundles:
|
|
|
1150
1151
|
the notice says so, and its calls fail at once until chi restarts (a new
|
|
1151
1152
|
session, or the worker's next start).
|
|
1152
1153
|
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
1153
|
-
closed, then TERM and KILL go to the server's process group.
|
|
1154
|
+
closed, then TERM and KILL go to the server's process group. A chi
|
|
1155
|
+
killed with `kill -9` (or crashing past Ruby) stops nothing itself; a
|
|
1156
|
+
server that ignores its stdin's EOF would live on then. So each server's
|
|
1157
|
+
group also holds a watchdog, a `/bin/sh` waiting on a pipe only chi
|
|
1158
|
+
holds: when that pipe closes without chi letting it go, it gives the
|
|
1159
|
+
server a second, then TERMs and KILLs its group (the server's helpers
|
|
1160
|
+
too). A server that leaves its group (`setsid`) is out of its reach.
|
|
1154
1161
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
1155
1162
|
(not started), running with its pid, failed with its count, stopped) and
|
|
1156
1163
|
their tools as `<server>/<tool>`, the name `call_tool` takes.
|
data/docs/sessions.md
CHANGED
|
@@ -76,7 +76,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
76
76
|
- `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
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).
|
|
78
78
|
- 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 `day 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`), `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`).
|
|
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 `day 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
80
|
- 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
81
|
|
|
82
82
|
**Test-session hygiene:**
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.7.
|
|
3
|
+
version: 0.7.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:67b1ed9337236deb366459aaaed4e49f0d3122a0b38f3ff9328babcd332c0182
|
|
11
11
|
# 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
|
|
12
12
|
requires_chi: ">= 0.67.0"
|
|
@@ -76,6 +76,15 @@ class Plugin
|
|
|
76
76
|
# How long close waits for the server after closing its stdin, after
|
|
77
77
|
# TERM, and after KILL.
|
|
78
78
|
STOP_GRACE_SECONDS = 1
|
|
79
|
+
# The server's watchdog (#start_watchdog), a shell in its process group
|
|
80
|
+
# whose stdin is a pipe only chi holds. A line on it (#close) lets it
|
|
81
|
+
# go; EOF means chi ended without closing the server (kill -9, a crash
|
|
82
|
+
# past Ruby), whose stdin then hit EOF too: a grace for the server to
|
|
83
|
+
# leave by itself, then TERM and KILL to the group, the way #close stops
|
|
84
|
+
# it. `kill 0` signals the shell's own group: while it runs, that group
|
|
85
|
+
# (and so its id) can't be anyone else's.
|
|
86
|
+
WATCHDOG = "trap '' TERM; read -r _ && exit 0; sleep #{STOP_GRACE_SECONDS}; " \
|
|
87
|
+
"kill -TERM 0; sleep #{STOP_GRACE_SECONDS}; kill -KILL 0".freeze
|
|
79
88
|
|
|
80
89
|
attr_reader :pid
|
|
81
90
|
|
|
@@ -101,6 +110,7 @@ class Plugin
|
|
|
101
110
|
@stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v.to_s] }, *command,
|
|
102
111
|
chdir: cwd, pgroup: true)
|
|
103
112
|
@pid = @wait.pid
|
|
113
|
+
start_watchdog
|
|
104
114
|
@reader = Thread.new { read_loop }
|
|
105
115
|
@err_reader = Thread.new { stderr_loop }
|
|
106
116
|
rescue SystemCallError => e
|
|
@@ -154,7 +164,7 @@ class Plugin
|
|
|
154
164
|
|
|
155
165
|
# End the process: stdin closed first (most servers leave then), then
|
|
156
166
|
# TERM and KILL to its process group (chi's ProcessGroup), a second's
|
|
157
|
-
# grace each.
|
|
167
|
+
# grace each. Then the watchdog is let go.
|
|
158
168
|
def close
|
|
159
169
|
@mutex.synchronize { @closing = true }
|
|
160
170
|
[@stdin].each { |io| io.close unless io.closed? }
|
|
@@ -163,6 +173,7 @@ class Plugin
|
|
|
163
173
|
stopped: -> { !@wait.alive? }, wait: ->(seconds) { @wait.join(seconds) })
|
|
164
174
|
@wait.join(STOP_GRACE_SECONDS)
|
|
165
175
|
end
|
|
176
|
+
release_watchdog
|
|
166
177
|
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
167
178
|
[@reader, @err_reader].each { |t| t.join(1) }
|
|
168
179
|
nil
|
|
@@ -172,6 +183,35 @@ class Plugin
|
|
|
172
183
|
|
|
173
184
|
private
|
|
174
185
|
|
|
186
|
+
# A server outlives a chi killed without #close when it ignores its
|
|
187
|
+
# stdin's EOF (a Node server with another live handle does): the
|
|
188
|
+
# watchdog (WATCHDOG) stops its group then. One that can't start (the
|
|
189
|
+
# server already left its group) is logged and skipped.
|
|
190
|
+
def start_watchdog
|
|
191
|
+
reader, @watchdog_pipe = IO.pipe
|
|
192
|
+
watchdog = Process.spawn("/bin/sh", "-c", WATCHDOG, in: reader, out: File::NULL, err: File::NULL, pgroup: @pid)
|
|
193
|
+
@watchdog = Process.detach(watchdog)
|
|
194
|
+
rescue SystemCallError => e
|
|
195
|
+
@log.call("watchdog_failed", error: e.class.name)
|
|
196
|
+
@watchdog_pipe&.close
|
|
197
|
+
@watchdog_pipe = nil
|
|
198
|
+
ensure
|
|
199
|
+
reader&.close
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A line tells the watchdog chi stopped the server itself.
|
|
203
|
+
def release_watchdog
|
|
204
|
+
return unless @watchdog_pipe
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
@watchdog_pipe.write("\n")
|
|
208
|
+
rescue IOError, SystemCallError # gone already: the group's KILL reached it
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
@watchdog_pipe.close unless @watchdog_pipe.closed?
|
|
212
|
+
@watchdog&.join(STOP_GRACE_SECONDS)
|
|
213
|
+
end
|
|
214
|
+
|
|
175
215
|
def write(message)
|
|
176
216
|
line = JSON.generate(message)
|
|
177
217
|
@write_mutex.synchronize do
|
|
@@ -13,6 +13,7 @@ require_relative "../installed_gem"
|
|
|
13
13
|
require_relative "../config"
|
|
14
14
|
require_relative "../host_keys"
|
|
15
15
|
require_relative "../redacted_inspect"
|
|
16
|
+
require_relative "../paths"
|
|
16
17
|
|
|
17
18
|
module Samagotchi
|
|
18
19
|
module Desktop
|
|
@@ -146,9 +147,13 @@ module Samagotchi
|
|
|
146
147
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
147
148
|
# `gem cleanup`), else this checkout's bin/chi. A section per terminal
|
|
148
149
|
# switched on (TERMINALS order); the broadcast timeout when given.
|
|
150
|
+
# The XDG dirs go in absolute (Paths): launchd starts the app in /,
|
|
151
|
+
# where a relative one would mean /<dir>.
|
|
149
152
|
def launch_config
|
|
150
153
|
env = { "LANG" => LANG }
|
|
151
154
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
155
|
+
env["XDG_CONFIG_HOME"] = Paths.config_home(env: @env) if env.key?("XDG_CONFIG_HOME")
|
|
156
|
+
env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
|
|
152
157
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
153
158
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
154
159
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
data/lib/samagotchi/paths.rb
CHANGED
|
@@ -5,17 +5,19 @@ module Samagotchi
|
|
|
5
5
|
# every call (specs and smoke runs point XDG_* elsewhere after load):
|
|
6
6
|
# state: $XDG_STATE_HOME or ~/.local/state (sessions, logs, history, plugin data)
|
|
7
7
|
# config: $XDG_CONFIG_HOME or ~/.config (config.yml, memories, hooks)
|
|
8
|
-
# A blank variable counts as unset.
|
|
9
|
-
#
|
|
8
|
+
# A blank variable counts as unset. A relative one is made absolute from
|
|
9
|
+
# the current directory (File.absolute_path: no ~ expansion), so a command
|
|
10
|
+
# run in another cwd (execute's logs and exit_code file) still finds it.
|
|
11
|
+
# The samagotchi folder inside each is #state_dir / ConfigFile.config_dir.
|
|
10
12
|
module Paths
|
|
11
13
|
APP_DIR = "samagotchi"
|
|
12
14
|
|
|
13
15
|
module_function
|
|
14
16
|
|
|
15
|
-
# @return [String] $XDG_STATE_HOME, else ~/.local/state
|
|
17
|
+
# @return [String] $XDG_STATE_HOME (absolute), else ~/.local/state
|
|
16
18
|
def state_home(env: ENV)
|
|
17
19
|
xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
18
|
-
xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
20
|
+
xdg.empty? ? File.join(Dir.home, ".local", "state") : File.absolute_path(xdg)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# @return [String] <state home>/samagotchi
|
|
@@ -23,10 +25,10 @@ module Samagotchi
|
|
|
23
25
|
File.join(state_home(env: env), APP_DIR)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
# @return [String] $XDG_CONFIG_HOME, else ~/.config
|
|
28
|
+
# @return [String] $XDG_CONFIG_HOME (absolute), else ~/.config
|
|
27
29
|
def config_home(env: ENV)
|
|
28
30
|
xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
|
|
29
|
-
xdg.empty? ? File.expand_path("~/.config") : xdg
|
|
31
|
+
xdg.empty? ? File.expand_path("~/.config") : File.absolute_path(xdg)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# +path+ as chi shows it: ~ for the home folder (~/a for one in it),
|
|
@@ -25,6 +25,7 @@ require_relative "worker_sidecar"
|
|
|
25
25
|
require_relative "worker_signals"
|
|
26
26
|
require_relative "log"
|
|
27
27
|
require_relative "log_path"
|
|
28
|
+
require_relative "paths"
|
|
28
29
|
require_relative "model_profile"
|
|
29
30
|
require_relative "installed_gem"
|
|
30
31
|
require_relative "installed_versions"
|
|
@@ -213,6 +214,8 @@ module Samagotchi
|
|
|
213
214
|
# SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
|
|
214
215
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
216
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
217
|
+
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
218
|
+
# (Paths): the worker starts in the session's directory.
|
|
216
219
|
private_class_method def self.spawn_options(session)
|
|
217
220
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
218
221
|
# terminal must not reach them (the worker then leaves the process
|
|
@@ -239,6 +242,10 @@ module Samagotchi
|
|
|
239
242
|
else
|
|
240
243
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
241
244
|
end
|
|
245
|
+
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
|
+
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
+
child_env["XDG_STATE_HOME"] = Paths.state_home unless ENV.fetch("XDG_STATE_HOME", "").to_s.strip.empty?
|
|
248
|
+
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
242
249
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
243
250
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
244
251
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -64,7 +64,8 @@ module Samagotchi
|
|
|
64
64
|
# command has none: its output isn't all there is. A command a signal
|
|
65
65
|
# ended (a segfault, an OOM kill) has an exit line with no number
|
|
66
66
|
# ("exit: "): it ended, so its output is all there is. The JS twin is
|
|
67
|
-
# turn_html.js's EXIT_LINE.
|
|
67
|
+
# turn_html.js's EXIT_LINE; spec/fixtures/exit_lines.json holds the
|
|
68
|
+
# cases both must answer alike (both specs run it).
|
|
68
69
|
STDOUT_LABEL = "\nstdout:\n"
|
|
69
70
|
STDERR_LABEL = "\nstderr:\n"
|
|
70
71
|
EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?\z/
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "registry"
|
|
4
4
|
require_relative "../tool_declarations"
|
|
5
|
+
require_relative "../paths"
|
|
5
6
|
require_relative "execute"
|
|
6
7
|
require_relative "read"
|
|
7
8
|
require_relative "write"
|
|
@@ -127,6 +128,8 @@ module Samagotchi
|
|
|
127
128
|
# level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
|
|
128
129
|
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
129
130
|
# is unset: it was that run's, not the default of a chi run here.
|
|
131
|
+
# A set XDG_STATE_HOME / XDG_CONFIG_HOME goes in absolute (Paths), so
|
|
132
|
+
# a chi run in another cwd (chi send) reads this chi's dirs.
|
|
130
133
|
def parent_env(kctx, env: ENV)
|
|
131
134
|
peers = kctx.peers
|
|
132
135
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
@@ -136,7 +139,16 @@ module Samagotchi
|
|
|
136
139
|
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
137
140
|
SESSION_MODEL_ENV => model.empty? ? nil : model,
|
|
138
141
|
SESSION_THINKING_ENV => thinking,
|
|
139
|
-
SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
|
|
142
|
+
SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env)).merge(xdg_absolute(env))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The XDG dirs set in +env+, made absolute (nothing for an unset one).
|
|
146
|
+
def xdg_absolute(env = ENV)
|
|
147
|
+
set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
|
|
148
|
+
dirs = {}
|
|
149
|
+
dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
|
|
150
|
+
dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
|
|
151
|
+
dirs
|
|
140
152
|
end
|
|
141
153
|
|
|
142
154
|
# The unsets that keep a `chi --model X` worker's default from passing
|
|
@@ -151,11 +163,12 @@ module Samagotchi
|
|
|
151
163
|
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
152
164
|
SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
|
|
153
165
|
|
|
154
|
-
# task_create's parent_env, less the
|
|
155
|
-
#
|
|
166
|
+
# task_create's parent_env, less what the model set in its env (a
|
|
167
|
+
# SAMAGOTCHI_DEFAULT_MODEL or an XDG dir it asked for stays; chi's own
|
|
168
|
+
# markers are reserved, the model's env can't set them).
|
|
156
169
|
def task_marker_env(kctx, env)
|
|
157
170
|
given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
|
|
158
|
-
parent_env(kctx).reject { |key,
|
|
171
|
+
parent_env(kctx).reject { |key, _| given.include?(key) }
|
|
159
172
|
end
|
|
160
173
|
|
|
161
174
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
@@ -99,6 +99,8 @@ module Samagotchi
|
|
|
99
99
|
# status. Only while this chi runs. A record a refresh finished in
|
|
100
100
|
# between (failed, no exit code) is corrected; a stopped task (its
|
|
101
101
|
# stop flag on disk) is left as task_stop records it.
|
|
102
|
+
# The thread is named EXIT_STATUS_THREAD (a spec joins them before it
|
|
103
|
+
# removes the tasks' folder).
|
|
102
104
|
# @return [Thread]
|
|
103
105
|
def record_exit_status(waiter, path, task_id:)
|
|
104
106
|
# Absolute now: safe against a later chdir.
|
|
@@ -106,6 +108,7 @@ module Samagotchi
|
|
|
106
108
|
path = File.expand_path(path)
|
|
107
109
|
Thread.new do
|
|
108
110
|
Thread.current.report_on_exception = false
|
|
111
|
+
Thread.current.name = EXIT_STATUS_THREAD
|
|
109
112
|
code = status_code(waiter.value)
|
|
110
113
|
next if code.nil? || record_at(record_path)&.stop_requested_by
|
|
111
114
|
|
|
@@ -124,6 +127,9 @@ module Samagotchi
|
|
|
124
127
|
status.exitstatus || (status.termsig && (128 + status.termsig))
|
|
125
128
|
end
|
|
126
129
|
|
|
130
|
+
# record_exit_status's threads' name.
|
|
131
|
+
EXIT_STATUS_THREAD = "task_exit_status"
|
|
132
|
+
|
|
127
133
|
# Reasons a refresh finishes a record with when it read no exit code.
|
|
128
134
|
MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
|
|
129
135
|
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Markdown out of the web UI's plain one-line places: the stage's live
|
|
2
|
+
// headline (stage_model.js), a closed step's title (turn_model.js
|
|
3
|
+
// genLabel), an answered question card's kept-reply line (question_cards.js).
|
|
4
|
+
// Pure, no DOM, so `node --test` covers it; the places that render markdown
|
|
5
|
+
// (step bodies, the card's open copy) keep the text as written.
|
|
6
|
+
|
|
7
|
+
// One line as plain text: the markdown a reply carries (headings, quotes,
|
|
8
|
+
// bullets and item numbers, table pipes, emphasis, code ticks, links) goes,
|
|
9
|
+
// and a line that is only syntax (a code fence, a table's separator row, a
|
|
10
|
+
// rule) is empty. A small pass, no renderer: a one-line
|
|
11
|
+
// preview only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
12
|
+
export function plainLine(line) {
|
|
13
|
+
let s = String(line ?? "").trim();
|
|
14
|
+
if (/^(```|~~~)/.test(s)) return "";
|
|
15
|
+
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
16
|
+
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
17
|
+
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?/, "");
|
|
18
|
+
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
19
|
+
if (/^\||\|$/.test(s)) {
|
|
20
|
+
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
21
|
+
}
|
|
22
|
+
// Code spans keep their text as is; an unclosed tick just goes.
|
|
23
|
+
const code = [];
|
|
24
|
+
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
25
|
+
s = s
|
|
26
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
27
|
+
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
28
|
+
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
29
|
+
.replace(/`+/g, "")
|
|
30
|
+
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
31
|
+
.replace(/(\*\*|~~)/g, "")
|
|
32
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
33
|
+
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
34
|
+
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
35
|
+
return s.replace(/\s+/g, " ").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A reply as one plain line (each of its lines plainLine's), for a preview
|
|
39
|
+
// that clamps it: blank and syntax-only lines go; the lines inside a code
|
|
40
|
+
// fence stay as written (code's "*" and "_" are not emphasis), the fences
|
|
41
|
+
// themselves go. A line that ends a sentence (or a clause: ":", ",") runs on
|
|
42
|
+
// with a space, one that doesn't (a heading, an item) with " · ", so
|
|
43
|
+
// "## Plan" and the line under it don't read as one sentence.
|
|
44
|
+
export function plainText(text) {
|
|
45
|
+
let out = "";
|
|
46
|
+
let fenced = false;
|
|
47
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
48
|
+
if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; continue; }
|
|
49
|
+
const line = fenced ? raw.trim() : plainLine(raw);
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
if (out) out += /[.!?…:;,]["')\]]*$/.test(out) ? " " : " · ";
|
|
52
|
+
out += line;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { sessionHash } from "./route.js";
|
|
23
23
|
import { clientLabel, isOwn } from "./turn_events.js";
|
|
24
24
|
import { continueLine } from "./commands.js";
|
|
25
|
+
import { plainText } from "./plain_line.js";
|
|
25
26
|
|
|
26
27
|
// @param sessionId () => the open session's id (answers go there)
|
|
27
28
|
// @param clientId this tab's id in the session's events
|
|
@@ -404,7 +405,8 @@ export function createQuestionCards({
|
|
|
404
405
|
// The reply that asked, kept with the card: what made the question
|
|
405
406
|
// answerable stays with it in the history. Muted under the "Q → A" line
|
|
406
407
|
// in two lines while the card is closed (a copy screen readers skip: not
|
|
407
|
-
// the toggle's label
|
|
408
|
+
// the toggle's label; plain text, plain_line.js: no "##" or "**" in a
|
|
409
|
+
// clamped line), and whole, selectable, at the top of the body
|
|
408
410
|
// when it is open (a drag in the summary would toggle the card).
|
|
409
411
|
const asked = saidOf.get(questionEl);
|
|
410
412
|
let full = null;
|
|
@@ -412,7 +414,7 @@ export function createQuestionCards({
|
|
|
412
414
|
const line = doc.createElement("div");
|
|
413
415
|
line.className = "question-said";
|
|
414
416
|
line.setAttribute("aria-hidden", "true");
|
|
415
|
-
line.textContent = asked;
|
|
417
|
+
line.textContent = plainText(asked);
|
|
416
418
|
summary.appendChild(line);
|
|
417
419
|
full = doc.createElement("div");
|
|
418
420
|
full.className = "question-said-full";
|
|
@@ -7,6 +7,7 @@ import { splitSentences } from "./sentences.js";
|
|
|
7
7
|
import { steerSender } from "./format.js";
|
|
8
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
9
9
|
import { askingText } from "./turn_model.js";
|
|
10
|
+
import { plainLine } from "./plain_line.js";
|
|
10
11
|
|
|
11
12
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
12
13
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -19,37 +20,6 @@ const QUIET_MS = 1500;
|
|
|
19
20
|
// A touch, wheel or scroll counts as use this long (phones have no hover).
|
|
20
21
|
const TOUCH_MS = 4000;
|
|
21
22
|
|
|
22
|
-
// One headline sentence as plain text: the markdown a streaming answer
|
|
23
|
-
// carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
|
|
24
|
-
// links) goes, and a line that is only syntax (a code fence, a table's
|
|
25
|
-
// separator row, a rule) is empty. A small pass, no renderer: a live line
|
|
26
|
-
// only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
27
|
-
export function plainHeadline(line) {
|
|
28
|
-
let s = String(line ?? "").trim();
|
|
29
|
-
if (/^(```|~~~)/.test(s)) return "";
|
|
30
|
-
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
31
|
-
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
32
|
-
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
|
|
33
|
-
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
34
|
-
if (/^\||\|$/.test(s)) {
|
|
35
|
-
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
36
|
-
}
|
|
37
|
-
// Code spans keep their text as is; an unclosed tick just goes.
|
|
38
|
-
const code = [];
|
|
39
|
-
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
40
|
-
s = s
|
|
41
|
-
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
42
|
-
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
43
|
-
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
44
|
-
.replace(/`+/g, "")
|
|
45
|
-
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
46
|
-
.replace(/(\*\*|~~)/g, "")
|
|
47
|
-
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
48
|
-
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
49
|
-
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
50
|
-
return s.replace(/\s+/g, " ").trim();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
23
|
// The step's newest narration sentence, as plain text: the end of a
|
|
54
24
|
// finished text is a boundary (its last line may have no period), a live
|
|
55
25
|
// one's not yet. A sentence that is only markdown syntax is skipped, and so
|
|
@@ -61,9 +31,9 @@ export function headlineOf(text, done) {
|
|
|
61
31
|
const plain = [];
|
|
62
32
|
for (const sentence of sentences) {
|
|
63
33
|
if (/^(```|~~~)/.test(sentence)) fenced = !fenced;
|
|
64
|
-
else if (!fenced &&
|
|
34
|
+
else if (!fenced && plainLine(sentence)) plain.push(plainLine(sentence));
|
|
65
35
|
}
|
|
66
|
-
const fragment = fenced ? "" :
|
|
36
|
+
const fragment = fenced ? "" : plainLine(rest);
|
|
67
37
|
if (done && fragment) return fragment;
|
|
68
38
|
if (plain.length) return plain[plain.length - 1];
|
|
69
39
|
return fragment.length > 160 ? fragment : "";
|
|
@@ -83,8 +83,9 @@ function llmEditTemplate(row) {
|
|
|
83
83
|
// A gate-corrected call's `ran as:` line shows on hover only.
|
|
84
84
|
const INLINE_CHARS = 300;
|
|
85
85
|
// The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
|
|
86
|
-
// signal-killed command's has no number ("exit: ").
|
|
87
|
-
|
|
86
|
+
// signal-killed command's has no number ("exit: "). spec/fixtures/exit_lines.json holds the cases both
|
|
87
|
+
// must answer alike (exported for turn_html.test.js).
|
|
88
|
+
export const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
|
|
88
89
|
// A bare success exit line; group 1 is the hint line after it, if any.
|
|
89
90
|
const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
|
|
90
91
|
const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
|
|
27
27
|
import { tallyText } from "./tally.js";
|
|
28
28
|
import { clip, leadTrimmed } from "./format.js";
|
|
29
|
+
import { plainLine } from "./plain_line.js";
|
|
29
30
|
|
|
30
31
|
const LABEL_MAX = 80;
|
|
31
32
|
const LABEL_TOOLS = 3;
|
|
@@ -210,9 +211,11 @@ export function failedCount(rows) {
|
|
|
210
211
|
return rows.filter((r) => r.status === "error").length;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// The collapsed row of a gen: its narration's first line
|
|
214
|
-
//
|
|
215
|
-
//
|
|
214
|
+
// The collapsed row of a gen: its narration's first line as plain text
|
|
215
|
+
// (plain_line.js: "## Plan" reads "Plan"; a line that is only syntax, a rule
|
|
216
|
+
// or a fence, gives way to the next), else its first call's title ("edit
|
|
217
|
+
// lib/a.rb"), else the tools it worked with, else "thinking"; then its call
|
|
218
|
+
// count and how many failed.
|
|
216
219
|
export function genLabel(gen) {
|
|
217
220
|
const { head, calls } = genLabelParts(gen);
|
|
218
221
|
return calls ? `${head} · ${calls}` : head;
|
|
@@ -223,7 +226,7 @@ export function genLabel(gen) {
|
|
|
223
226
|
// starts with (its narration, its first call's title), so an open step's
|
|
224
227
|
// summary can say only its calls (turn_view.js labelHtml).
|
|
225
228
|
export function genLabelParts(gen) {
|
|
226
|
-
const line = String(gen.text || "").
|
|
229
|
+
const line = String(gen.text || "").split("\n").map(plainLine).find(Boolean) || "";
|
|
227
230
|
const calls = gen.tools.length ? toolCallsText(gen.tools) : "";
|
|
228
231
|
const first = gen.tools[0];
|
|
229
232
|
if (line) return { head: cut(line), calls, repeats: true };
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.74.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -617,6 +617,7 @@ files:
|
|
|
617
617
|
- lib/samagotchi/web/public/notice_links.js
|
|
618
618
|
- lib/samagotchi/web/public/notifications.js
|
|
619
619
|
- lib/samagotchi/web/public/notify.js
|
|
620
|
+
- lib/samagotchi/web/public/plain_line.js
|
|
620
621
|
- lib/samagotchi/web/public/prompt_history.js
|
|
621
622
|
- lib/samagotchi/web/public/question_card.js
|
|
622
623
|
- lib/samagotchi/web/public/question_cards.js
|