samagotchi 0.72.0 → 0.73.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 +26 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +9 -3
- data/docs/hooks.md +4 -2
- data/docs/internals/background-tasks.md +2 -2
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_chain.rb +24 -0
- data/lib/samagotchi/session_continue.rb +3 -1
- data/lib/samagotchi/tools/execute.rb +1 -15
- data/lib/samagotchi/tools/task_runtime.rb +69 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -3
- data/lib/samagotchi/web/public/app.js +33 -10
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/chains.js +21 -1
- data/lib/samagotchi/web/public/data.js +9 -0
- data/lib/samagotchi/web/public/index.html +39 -2
- data/lib/samagotchi/web/public/notice_links.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +33 -1
- data/lib/samagotchi/web/public/stage_slots.js +35 -3
- data/lib/samagotchi/web/public/stage_view.js +9 -4
- data/lib/samagotchi/web/public/turn_view.js +3 -2
- 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: 71fb1fa81145a92b955f7beaa2a9a1eabc37d29b9c8336dd65e27d5d616f23fa
|
|
4
|
+
data.tar.gz: b68963f09b363c1b03f8a32f14ebc41cf36297df3c290c594acef6735d0bb863
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1752a44dd7c444534218f23c00ecc1746047875e4dac427b8395cd6cfb06169b38c456ff2bcbf4ca4000d68ce467454f52c2f4564547824ddb5c83dd586f5f2f
|
|
7
|
+
data.tar.gz: 66d5fcfd2b0a62957011653911edc44774843ac13837203cf1211e0523ae8d834025d0145324cd955d2f5c827f8b4a1e52c8b9c7db6a3735c6c24e4442eea7c0
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.73.0] - 2026-10-16
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: the stage's asking text (a question's or approval's reply) renders as markdown with clickable links when
|
|
16
|
+
`web.markdown` is on, and so does a resolved question card's copy of it once the card is opened; their
|
|
17
|
+
`path:line` refs are links with markdown off too.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Web: `continue →` fills the new link's composer with the previous link's own first message when that link was a
|
|
22
|
+
continuation itself (else `Continue where we left off.`), so a chain's opener comes back on every link; a chain
|
|
23
|
+
link's top bar and tab show its card's title whatever opener it was sent.
|
|
24
|
+
- Web: http(s) URLs in a hook's notice line (source-links' `sources:` line with markdown off) are links; they open
|
|
25
|
+
in a new tab.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- A `task_create` task given a `cwd`, or running `cd`, recorded `failed` / `process_ended_without_exit_code` even
|
|
30
|
+
when it succeeded (its exit code was written relative to the shell's directory); a task killed by a signal now
|
|
31
|
+
records 128 + the signal.
|
|
32
|
+
|
|
33
|
+
Update with `chi update` (the system bundle 0.73.0), then restart `chi web`.
|
|
34
|
+
|
|
11
35
|
## [0.72.0] - 2026-10-16
|
|
12
36
|
|
|
13
37
|
### Added
|
|
@@ -3109,7 +3133,8 @@ and long-lived sessions.
|
|
|
3109
3133
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3110
3134
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3111
3135
|
|
|
3112
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3136
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.73.0...HEAD
|
|
3137
|
+
[0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
|
|
3113
3138
|
[0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
|
|
3114
3139
|
[0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
|
|
3115
3140
|
[0.70.0]: https://github.com/dm1try/samagotchi/compare/v0.69.0...v0.70.0
|
data/docs/architecture.md
CHANGED
|
@@ -148,7 +148,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
148
148
|
| UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
|
|
149
149
|
| Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
|
|
150
150
|
| Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
|
|
151
|
-
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. `bin/chi web` entrypoint. |
|
|
151
|
+
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
|
|
152
152
|
| Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
|
|
153
153
|
|
|
154
154
|
- `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
|
data/docs/cli.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
|
-
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
|
|
16
|
+
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
|
|
17
17
|
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
18
18
|
- `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
@@ -698,8 +698,14 @@ web:
|
|
|
698
698
|
```
|
|
699
699
|
|
|
700
700
|
Only finalized assistant messages are rendered; user messages and live streaming
|
|
701
|
-
chunks remain escaped text.
|
|
702
|
-
|
|
701
|
+
chunks remain escaped text. The stage's asking text (the whole reply that asked
|
|
702
|
+
a waiting question or approval) shows raw at once, then as markdown, its links
|
|
703
|
+
opening in a new tab; a resolved question card's copy of that reply renders when
|
|
704
|
+
the card is first opened (its summary line stays plain). A text the page already shows and renders later goes
|
|
705
|
+
through `POST /api/render` (`{text}` → `{html}`, up to 64 KB; `html` is null
|
|
706
|
+
with markdown off), the same renderer and sanitizer. Generated HTML is
|
|
707
|
+
sanitized, raw HTML in model output is not trusted, and unsafe links are
|
|
708
|
+
removed. If Markdown is enabled without
|
|
703
709
|
commonmarker installed, Chi Web keeps the normal escaped-text display and shows a
|
|
704
710
|
warning explaining how to install the optional gem.
|
|
705
711
|
|
data/docs/hooks.md
CHANGED
|
@@ -124,7 +124,8 @@ links), for a UI that doesn't render that display. The hook never names a UI;
|
|
|
124
124
|
each decides by what it renders. The web with markdown on (`web.markdown` and
|
|
125
125
|
the commonmarker gem) leaves such a line out, live and after a reload; with
|
|
126
126
|
markdown off it shows, as it does in the REPL, the attached TUI and
|
|
127
|
-
`chi -p`, which print the answer as text
|
|
127
|
+
`chi -p`, which print the answer as text (in the web its http(s) URLs are
|
|
128
|
+
links, as in any hook's notice line). The log keeps the line with
|
|
128
129
|
`fallback_for=display`. `:editor` marks a line that names a file the viewer
|
|
129
130
|
could open from a ref in its editor: the web leaves it out for a viewer on
|
|
130
131
|
this machine with `web.editor` set, and every other UI and viewer (a phone,
|
|
@@ -660,7 +661,8 @@ keeps the web links. With markdown on, the web leaves the line out when the
|
|
|
660
661
|
answer links every URL it names, since it would only repeat them as plain
|
|
661
662
|
text ([`fallback_for: :display`](#what-a-hook-can-do-the-runtime)); it shows
|
|
662
663
|
when it names one the answer doesn't link (a ref only in code, two sources on
|
|
663
|
-
one ref) and whenever markdown is off
|
|
664
|
+
one ref) and whenever markdown is off (its URLs are links in the web). Bundle
|
|
665
|
+
0.4.0 needs chi 0.35.0 for that.
|
|
664
666
|
|
|
665
667
|
Each ref the answer links is also a [ref on the
|
|
666
668
|
answer](#refs-on-the-answer-display-only): kind `issue` (or the source's
|
|
@@ -23,9 +23,9 @@ Behavior:
|
|
|
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
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.
|
|
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. A command that sets its own EXIT trap replaces it: `execute` still reports the shell's status, and a handed-over
|
|
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
|
-
- 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
|
|
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.
|
|
29
29
|
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
|
|
30
30
|
- Which tasks are a session's own (a cancelled turn's note, the turn-end note, a worker's idle exit, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
data/docs/sessions.md
CHANGED
|
@@ -215,8 +215,8 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
|
|
|
215
215
|
- **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
216
|
- **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
217
|
- **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
|
-
- **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); 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 `Continue where we left off
|
|
218
|
+
- **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 preview and id in the tooltip. 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 `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, 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 `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
220
220
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
221
221
|
- **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
222
|
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -220,6 +220,11 @@ module Samagotchi
|
|
|
220
220
|
# SessionManager.continue_session sets it); not saved.
|
|
221
221
|
attr_accessor :moved_children
|
|
222
222
|
|
|
223
|
+
# The text web `continue →` prefills in this new link
|
|
224
|
+
# (SessionChain.opener_for; SessionManager.continue_session sets it); not
|
|
225
|
+
# saved.
|
|
226
|
+
attr_accessor :opener
|
|
227
|
+
|
|
223
228
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
224
229
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
225
230
|
first_preview: "", test_run: false, pending_question: nil,
|
|
@@ -78,6 +78,30 @@ module Samagotchi
|
|
|
78
78
|
"#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# What web `continue →` prefills in +previous+'s next link: +previous+'s
|
|
82
|
+
# own first typed message when +previous+ is itself a continuation (so a
|
|
83
|
+
# chain's opener comes back on every link, edited once for all later
|
|
84
|
+
# ones), else nil (a chain's first link's first message is its task; the
|
|
85
|
+
# page falls back to its constant). Typed means a user message with text
|
|
86
|
+
# and no kind: Engine#turn_messages' (turn_id), or a checkpointed prompt
|
|
87
|
+
# (none); a stored `!shell` line ("!(cmd)\n<output>", no turn_id) is not.
|
|
88
|
+
# @param previous [Session]
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
def opener_for(previous)
|
|
91
|
+
return nil unless previous.continues
|
|
92
|
+
|
|
93
|
+
previous.messages.each do |msg|
|
|
94
|
+
next unless msg[:role] == "user" && msg[:kind].nil?
|
|
95
|
+
|
|
96
|
+
text = msg[:content]
|
|
97
|
+
next unless text.is_a?(String) && !text.strip.empty?
|
|
98
|
+
next if msg[:turn_id].nil? && text.start_with?("!(")
|
|
99
|
+
|
|
100
|
+
return text.strip
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
81
105
|
def day(iso)
|
|
82
106
|
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
83
107
|
rescue ArgumentError
|
|
@@ -86,7 +86,8 @@ module Samagotchi
|
|
|
86
86
|
# @param title [String, nil] what the lists show before the first turn;
|
|
87
87
|
# by default the prompt, else the previous link's preview
|
|
88
88
|
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
89
|
-
# #moved_children: the delegates it took over
|
|
89
|
+
# #moved_children: the delegates it took over, #opener:
|
|
90
|
+
# SessionChain.opener_for the previous link)
|
|
90
91
|
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
91
92
|
# @raise [Refused] continued already, delegates open that can't
|
|
92
93
|
# move, or its folder is gone
|
|
@@ -208,6 +209,7 @@ module Samagotchi
|
|
|
208
209
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
209
210
|
)
|
|
210
211
|
link.moved_children = move
|
|
212
|
+
link.opener = SessionChain.opener_for(previous)
|
|
211
213
|
# The fallback's warning first: spawn_session set its own.
|
|
212
214
|
warnings = [choice.warning, link.model_warning].compact
|
|
213
215
|
link.model_warning = warnings.join("; ") unless warnings.empty?
|
|
@@ -350,7 +350,7 @@ module Samagotchi
|
|
|
350
350
|
return nil if error
|
|
351
351
|
|
|
352
352
|
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
|
|
353
|
-
|
|
353
|
+
TaskRuntime.record_exit_status(waiter, record.exit_code_path, task_id: record.id)
|
|
354
354
|
# Ended between the deadline and the adopt: the record says how.
|
|
355
355
|
TaskRuntime.get_record(record.id) unless waiter.alive?
|
|
356
356
|
lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
|
|
@@ -363,20 +363,6 @@ module Samagotchi
|
|
|
363
363
|
end
|
|
364
364
|
private_class_method :hand_over
|
|
365
365
|
|
|
366
|
-
# A command that set its own EXIT trap replaced the wrapper's, so it
|
|
367
|
-
# writes no exit_code: while this chi lives, the waiter's status fills
|
|
368
|
-
# it in (a refresh in between may still read "no exit code").
|
|
369
|
-
def self.write_missing_exit_code(waiter, path)
|
|
370
|
-
Thread.new do
|
|
371
|
-
Thread.current.report_on_exception = false
|
|
372
|
-
code = waiter.value&.exitstatus
|
|
373
|
-
File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
374
|
-
rescue StandardError => e
|
|
375
|
-
Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
private_class_method :write_missing_exit_code
|
|
379
|
-
|
|
380
366
|
# <state dir>/execute: every command's dir (LOGS_DIRNAME).
|
|
381
367
|
def self.logs_root = File.join(Paths.state_dir, LOGS_DIRNAME)
|
|
382
368
|
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "../log"
|
|
9
10
|
require_relative "../process_group"
|
|
10
11
|
require_relative "shell"
|
|
11
12
|
require_relative "task_record"
|
|
@@ -55,7 +56,9 @@ module Samagotchi
|
|
|
55
56
|
task_id = generate_task_id
|
|
56
57
|
task_dir = task_dir_for(task_id)
|
|
57
58
|
output_path = File.join(task_dir, OUTPUT_FILENAME)
|
|
58
|
-
|
|
59
|
+
# Absolute: the trap writes it wherever the shell is when it exits
|
|
60
|
+
# (a +cwd+, or a cd in the command). Ruby opens output_path here.
|
|
61
|
+
exit_code_path = File.expand_path(File.join(task_dir, EXIT_CODE_FILENAME))
|
|
59
62
|
|
|
60
63
|
FileUtils.mkdir_p(task_dir)
|
|
61
64
|
|
|
@@ -72,7 +75,7 @@ module Samagotchi
|
|
|
72
75
|
out: output_io,
|
|
73
76
|
err: output_io
|
|
74
77
|
)
|
|
75
|
-
Process.detach(pid)
|
|
78
|
+
waiter = Process.detach(pid)
|
|
76
79
|
# Read now, compared before each signal: a pid reused later is not this task.
|
|
77
80
|
pid_started = ProcessGroup.start_time(pid)
|
|
78
81
|
|
|
@@ -81,6 +84,7 @@ module Samagotchi
|
|
|
81
84
|
output_path: output_path, exit_code_path: exit_code_path)
|
|
82
85
|
|
|
83
86
|
write_record(record)
|
|
87
|
+
record_exit_status(waiter, exit_code_path, task_id: task_id)
|
|
84
88
|
[record, nil]
|
|
85
89
|
rescue StandardError => e
|
|
86
90
|
[nil, "Error: #{e.message}"]
|
|
@@ -88,6 +92,60 @@ module Samagotchi
|
|
|
88
92
|
output_io&.close
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
# The shell's status from chi's waiter (+waiter+, a Process.detach
|
|
96
|
+
# thread), for a shell whose EXIT trap wrote nothing: one killed by a
|
|
97
|
+
# signal (zsh's sh mode skips the trap on TERM, INT and KILL) gets
|
|
98
|
+
# 128 + the signal, one whose command replaced the trap gets its exit
|
|
99
|
+
# status. Only while this chi runs. A record a refresh finished in
|
|
100
|
+
# between (failed, no exit code) is corrected; a stopped task (its
|
|
101
|
+
# stop flag on disk) is left as task_stop records it.
|
|
102
|
+
# @return [Thread]
|
|
103
|
+
def record_exit_status(waiter, path, task_id:)
|
|
104
|
+
# Absolute now: safe against a later chdir.
|
|
105
|
+
record_path = File.expand_path(metadata_path(task_id))
|
|
106
|
+
path = File.expand_path(path)
|
|
107
|
+
Thread.new do
|
|
108
|
+
Thread.current.report_on_exception = false
|
|
109
|
+
code = status_code(waiter.value)
|
|
110
|
+
next if code.nil? || record_at(record_path)&.stop_requested_by
|
|
111
|
+
|
|
112
|
+
File.write(path, code.to_s) if !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
113
|
+
correct_finished_record(record_path, read_exit_code(path))
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A Process::Status as a shell reports one: its exit status, or
|
|
120
|
+
# 128 + the signal that killed it.
|
|
121
|
+
def status_code(status)
|
|
122
|
+
return nil unless status
|
|
123
|
+
|
|
124
|
+
status.exitstatus || (status.termsig && (128 + status.termsig))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Reasons a refresh finishes a record with when it read no exit code.
|
|
128
|
+
MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
|
|
129
|
+
|
|
130
|
+
# A record a refresh finished before the exit code was there (failed,
|
|
131
|
+
# exit_code nil, no stop) gets +code+; finished_at is kept.
|
|
132
|
+
def correct_finished_record(record_path, code)
|
|
133
|
+
record = record_at(record_path)
|
|
134
|
+
return unless code && record && !record.running? && record.exit_code.nil? && !record.stop_requested_by
|
|
135
|
+
return unless MISSING_CODE_REASONS.include?(record.stop_reason)
|
|
136
|
+
|
|
137
|
+
status = code.zero? ? "completed" : "failed"
|
|
138
|
+
AtomicFile.write(record_path, record_json(record.with(status: status, exit_code: code, stop_reason: nil)))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def record_at(path)
|
|
142
|
+
return nil unless File.file?(path)
|
|
143
|
+
|
|
144
|
+
TaskRecord.from_file(JSON.parse(File.read(path)))
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
91
149
|
# A record for a command chi already runs: an execute still running at
|
|
92
150
|
# its limit, handed over as a task (Execute::HANDED_OVER), given as a
|
|
93
151
|
# TaskRecord.running whose +started_at+ is the spawn's. Its logs and
|
|
@@ -321,9 +379,11 @@ module Samagotchi
|
|
|
321
379
|
FileUtils.mkdir_p(dir)
|
|
322
380
|
|
|
323
381
|
path = File.join(dir, METADATA_FILENAME)
|
|
324
|
-
AtomicFile.write(path,
|
|
382
|
+
AtomicFile.write(path, record_json(record))
|
|
325
383
|
end
|
|
326
384
|
|
|
385
|
+
def record_json(record) = JSON.pretty_generate(record.to_file) + "\n"
|
|
386
|
+
|
|
327
387
|
def metadata_path(task_id)
|
|
328
388
|
File.join(task_dir_for(task_id), METADATA_FILENAME)
|
|
329
389
|
end
|
|
@@ -352,9 +412,12 @@ module Samagotchi
|
|
|
352
412
|
# exit status to +exit_code_path+. A prefix, not lines after the
|
|
353
413
|
# command: a trailing "\\" or "|" in the command would swallow those,
|
|
354
414
|
# and an `exit N` or a `set -e` failure would skip them. A command that
|
|
355
|
-
# sets its own EXIT trap replaces this one
|
|
356
|
-
#
|
|
357
|
-
#
|
|
415
|
+
# sets its own EXIT trap replaces this one, and a shell killed by a
|
|
416
|
+
# signal skips it (zsh's sh mode, on TERM, INT and KILL): no exit_code
|
|
417
|
+
# file from the shell then. The waiter of the chi that started it
|
|
418
|
+
# writes the code instead (record_exit_status: the status, or
|
|
419
|
+
# 128 + the signal); with that chi gone too, the task ends failed with
|
|
420
|
+
# process_ended_without_exit_code.
|
|
358
421
|
def wrapped_shell_command(command, exit_code_path)
|
|
359
422
|
write = %(printf "%s" "$?" > #{Shellwords.escape(exit_code_path)})
|
|
360
423
|
"trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -66,6 +66,9 @@ module Samagotchi
|
|
|
66
66
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
67
67
|
# An uploaded image may be this big before it is downscaled.
|
|
68
68
|
MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
69
|
+
# A POST /api/render body may be this big: a decision message is a few
|
|
70
|
+
# KB, and the cap bounds the work a page can ask for.
|
|
71
|
+
MAX_RENDER_BYTES = 64 * 1024
|
|
69
72
|
SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
|
|
70
73
|
IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
|
|
71
74
|
IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
|
|
@@ -158,6 +161,7 @@ module Samagotchi
|
|
|
158
161
|
["GET", %r{\A/api/models\z}, :handle_models],
|
|
159
162
|
["GET", %r{\A/api/history\z}, :handle_history],
|
|
160
163
|
["GET", %r{\A/api/events\z}, :handle_events],
|
|
164
|
+
["POST", %r{\A/api/render\z}, :handle_render],
|
|
161
165
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
162
166
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
163
167
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
@@ -679,8 +683,9 @@ module Samagotchi
|
|
|
679
683
|
# refused beside it (a create takes no reasoning_history). It runs on
|
|
680
684
|
# model (or model_name) when given, else on the previous link's: one
|
|
681
685
|
# whose host is gone falls back to the default, and the 201's
|
|
682
|
-
# model_warning (null when none) says so.
|
|
683
|
-
#
|
|
686
|
+
# model_warning (null when none) says so. Its opener is the text the
|
|
687
|
+
# page prefills (SessionChain.opener_for; null: the page's constant).
|
|
688
|
+
# A model it can't start on is 400 invalid_model, nothing archived.
|
|
684
689
|
# 409 continued (with next_id, the link that continues it already: the
|
|
685
690
|
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
686
691
|
# or owned_by_tui.
|
|
@@ -707,7 +712,8 @@ module Samagotchi
|
|
|
707
712
|
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
708
713
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
709
714
|
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
|
|
710
|
-
model_warning: session.model_warning
|
|
715
|
+
model_warning: session.model_warning,
|
|
716
|
+
opener: session.opener))
|
|
711
717
|
end
|
|
712
718
|
|
|
713
719
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
@@ -1006,6 +1012,24 @@ module Samagotchi
|
|
|
1006
1012
|
html || "<pre>#{CGI.escapeHTML(body)}</pre>"
|
|
1007
1013
|
end
|
|
1008
1014
|
|
|
1015
|
+
# POST /api/render {text}: the page's markdown read for a text it
|
|
1016
|
+
# already shows (the stage's asking text, a resolved question card's
|
|
1017
|
+
# reply), session-free. {html: nil} when markdown is off or
|
|
1018
|
+
# commonmarker is missing: the page keeps the raw text.
|
|
1019
|
+
def handle_render(req)
|
|
1020
|
+
too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
|
|
1021
|
+
return too_large if req.content_length.to_i > MAX_RENDER_BYTES
|
|
1022
|
+
|
|
1023
|
+
raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
|
|
1024
|
+
return too_large if raw.bytesize > MAX_RENDER_BYTES
|
|
1025
|
+
|
|
1026
|
+
body = parse_json(raw)
|
|
1027
|
+
return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
|
|
1028
|
+
|
|
1029
|
+
html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
|
|
1030
|
+
json_response(200, { html: html })
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1009
1033
|
def handle_question_answer(req, id)
|
|
1010
1034
|
session = @session_class.load(id, state_dir: default_state_dir) rescue nil
|
|
1011
1035
|
return error_response(404, "not_found", "Session not found: #{id}") unless session
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getContext,
|
|
21
21
|
detachContext,
|
|
22
22
|
addContext,
|
|
23
|
+
renderText,
|
|
23
24
|
} from "./data.js";
|
|
24
25
|
import { createContextChips } from "./context_chips.js";
|
|
25
26
|
import {
|
|
@@ -45,7 +46,7 @@ import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "
|
|
|
45
46
|
import { createCardsPlace } from "./cards_place.js";
|
|
46
47
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
47
48
|
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
48
|
-
import {
|
|
49
|
+
import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
|
|
49
50
|
import { createTaskStopper } from "./task_stop.js";
|
|
50
51
|
import { appendQuote } from "./annotations.js";
|
|
51
52
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -231,8 +232,25 @@ const viewArgs = {
|
|
|
231
232
|
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
232
233
|
};
|
|
233
234
|
const turnDom = view === "stage"
|
|
234
|
-
? createStageView({
|
|
235
|
+
? createStageView({
|
|
236
|
+
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
|
|
237
|
+
renderMarkdown, linkRefs: linkRefsIn,
|
|
238
|
+
})
|
|
235
239
|
: createTurnView(viewArgs);
|
|
240
|
+
// A text the page already shows as its markdown HTML (POST /api/render,
|
|
241
|
+
// sanitised there), or null: markdown off here (no request), or a failed
|
|
242
|
+
// read (the raw text stays).
|
|
243
|
+
function renderMarkdown(text) {
|
|
244
|
+
if (pageCapabilities.display !== true) return Promise.resolve(null);
|
|
245
|
+
return renderText(text)
|
|
246
|
+
.then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
|
|
247
|
+
.catch(() => null);
|
|
248
|
+
}
|
|
249
|
+
// The `path:line` refs in one rendered text (the stage's asking slot, a
|
|
250
|
+
// kept reply), as linkTextRefs links the answers'.
|
|
251
|
+
function linkRefsIn(el) {
|
|
252
|
+
linkRefs(el, touchedFiles(turnRoots()), current.dir);
|
|
253
|
+
}
|
|
236
254
|
// Where a running turn's rows can be: the history, and the stage while it
|
|
237
255
|
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
238
256
|
function turnRoots() {
|
|
@@ -293,6 +311,8 @@ const questionCards = createQuestionCards({
|
|
|
293
311
|
scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
294
312
|
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
295
313
|
askingText: () => turnDom.askingText(),
|
|
314
|
+
renderMarkdown,
|
|
315
|
+
linkRefs: linkRefsIn,
|
|
296
316
|
});
|
|
297
317
|
// The history's bubbles (bubbles.js): output, end lines, every client's
|
|
298
318
|
// prompts, the recap, warnings and notices, command and note bubbles.
|
|
@@ -1107,12 +1127,12 @@ function updateInfoBar() {
|
|
|
1107
1127
|
|
|
1108
1128
|
// The open session's title, once on screen (session_title.js): its strip
|
|
1109
1129
|
// card says it when the strip is shown and has one; else the top bar. The
|
|
1110
|
-
// browser tab names it too. It follows the card's: a chain link
|
|
1111
|
-
//
|
|
1130
|
+
// browser tab names it too. It follows the card's (openTitle): a chain link
|
|
1131
|
+
// reads its card's title (cardTitle), whatever opener it was sent.
|
|
1112
1132
|
function updateSessionTitle() {
|
|
1113
1133
|
const shown = selected ? lists.sessionsById().get(selected) : null;
|
|
1114
1134
|
const first = current.firstPreview || "";
|
|
1115
|
-
const raw = !selected ? "" : (shown
|
|
1135
|
+
const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
|
|
1116
1136
|
const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
|
|
1117
1137
|
const text = topBarTitle({
|
|
1118
1138
|
title,
|
|
@@ -1888,7 +1908,8 @@ $("#infoArchiveBtn").addEventListener("click", () => {
|
|
|
1888
1908
|
// ── Continue (session chains) ───────────────────────────────────────────────
|
|
1889
1909
|
// Continue → starts the next link of the open session's chain (the server
|
|
1890
1910
|
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
1891
|
-
// recap as a note) and opens it with the opener in the composer, not sent
|
|
1911
|
+
// recap as a note) and opens it with the opener in the composer, not sent
|
|
1912
|
+
// (fillOpener: the previous link's own first message when it continues one).
|
|
1892
1913
|
// A link continued already opens that next link; its open delegates move
|
|
1893
1914
|
// to the new link (the toast counts them); a refusal for open delegates
|
|
1894
1915
|
// that can't move names them, each a link.
|
|
@@ -1914,7 +1935,7 @@ async function continueSelected() {
|
|
|
1914
1935
|
return;
|
|
1915
1936
|
}
|
|
1916
1937
|
await select(next);
|
|
1917
|
-
if (selected === next) fillOpener();
|
|
1938
|
+
if (selected === next) fillOpener(created);
|
|
1918
1939
|
showToast(continuedText(id, created), null, { sticky });
|
|
1919
1940
|
} catch (e) {
|
|
1920
1941
|
const refusal = continueRefusal(e, allSessions);
|
|
@@ -1932,10 +1953,12 @@ async function continueSelected() {
|
|
|
1932
1953
|
}
|
|
1933
1954
|
}
|
|
1934
1955
|
|
|
1935
|
-
// The new link's composer: the opener
|
|
1936
|
-
|
|
1956
|
+
// The new link's composer: the opener (openerText: the previous link's own,
|
|
1957
|
+
// else the constant), unless a draft is there (it stays). +created+: the
|
|
1958
|
+
// continue's 201.
|
|
1959
|
+
function fillOpener(created) {
|
|
1937
1960
|
if (promptEl.value.trim() || chips.length) return;
|
|
1938
|
-
promptEl.value =
|
|
1961
|
+
promptEl.value = openerText(created);
|
|
1939
1962
|
fitPrompt();
|
|
1940
1963
|
promptEl.focus();
|
|
1941
1964
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
@@ -9,6 +9,7 @@ import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemp
|
|
|
9
9
|
import { thumbsTemplate } from "./images.js";
|
|
10
10
|
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
11
11
|
import { keepFollowing } from "./scroll.js";
|
|
12
|
+
import { linkedLine } from "./notice_links.js";
|
|
12
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
13
14
|
import { drawCommandBubble } from "./commands.js";
|
|
14
15
|
|
|
@@ -53,10 +54,11 @@ export function guardrailWarningTemplate(text, label = "guardrails") {
|
|
|
53
54
|
|
|
54
55
|
// A hook's line to the user (event[:notify]), in the warning colour for
|
|
55
56
|
// level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
|
|
56
|
-
// one of the history's own, else a row of a turn's step.
|
|
57
|
+
// one of the history's own, else a row of a turn's step. Its http(s) URLs
|
|
58
|
+
// are links (notice_links.js).
|
|
57
59
|
export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
|
|
58
60
|
const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
|
|
59
|
-
return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
|
|
61
|
+
return html`<div class=${klass} title=${title || nothing}>${linkedLine(line)}</div>`;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
|
|
@@ -13,9 +13,18 @@ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_vie
|
|
|
13
13
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
14
14
|
|
|
15
15
|
// The opener a new link's composer is filled with (never sent: the user
|
|
16
|
-
// edits or sends it)
|
|
16
|
+
// edits or sends it) when the continue brings none of its own.
|
|
17
17
|
export const CONTINUE_OPENER = "Continue where we left off.";
|
|
18
18
|
|
|
19
|
+
// What a new link's composer is filled with: the continue's own opener
|
|
20
|
+
// (+created+, the 201: the previous link's first typed message, when that
|
|
21
|
+
// link continues one), else CONTINUE_OPENER. So whatever the user sent on
|
|
22
|
+
// the last link comes back on the next one.
|
|
23
|
+
export function openerText(created) {
|
|
24
|
+
const own = created?.opener;
|
|
25
|
+
return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
// Whether +text+ (a title or a first message) is the opener and nothing
|
|
20
29
|
// else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
|
|
21
30
|
export function isOpener(text) {
|
|
@@ -41,6 +50,17 @@ export function chainTitle(s, byId) {
|
|
|
41
50
|
return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
// The open session's title (app.js updateSessionTitle: the top bar and the
|
|
54
|
+
// tab) from +first+, its first message: it follows its card. A chain link
|
|
55
|
+
// (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
|
|
56
|
+
// its first message: a web link inherits its chain's title, and its first
|
|
57
|
+
// message is an opener (the constant or the user's own). Else +first+,
|
|
58
|
+
// unless it is the opener. +shown+ null: not listed yet, +first+.
|
|
59
|
+
export function openTitle({ shown, first, cardTitle }) {
|
|
60
|
+
if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
|
|
61
|
+
return first;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
const createdMs = (s) => {
|
|
45
65
|
const ms = Date.parse(s?.created_at || "");
|
|
46
66
|
return Number.isNaN(ms) ? 0 : ms;
|
|
@@ -120,6 +120,15 @@ export function sendCommand(id, line, { clientId, card = false, ...opts } = {})
|
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// A text's markdown as sanitised HTML ({ html }; null with markdown off).
|
|
124
|
+
export function renderText(text, opts = {}) {
|
|
125
|
+
return api("/api/render", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({ text }),
|
|
128
|
+
...opts,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
123
132
|
export function cancelTurn(id, opts = {}) {
|
|
124
133
|
return api(`/api/sessions/${id}/cancel`, {
|
|
125
134
|
method: "POST",
|
|
@@ -326,6 +326,22 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
326
326
|
.bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
|
|
327
327
|
.bubble.question[open]>summary .question-said{display:none}
|
|
328
328
|
.question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
329
|
+
/* Its markdown once the card is first opened (question_cards.js renderSaidOnOpen), as compact as the stage's. */
|
|
330
|
+
.question-said-full.markdown{white-space:normal}
|
|
331
|
+
.question-said-full.markdown :is(h1,h2,h3,h4,h5,h6){font-size:13.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-2)}
|
|
332
|
+
.question-said-full.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
|
|
333
|
+
.question-said-full.markdown>:first-child{margin-top:0}
|
|
334
|
+
.question-said-full.markdown>:last-child{margin-bottom:0}
|
|
335
|
+
.question-said-full.markdown :is(ul,ol){padding-left:1.3em}
|
|
336
|
+
.question-said-full.markdown li>:is(ul,ol){margin:0}
|
|
337
|
+
.question-said-full.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
|
|
338
|
+
.question-said-full.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px}
|
|
339
|
+
.question-said-full.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
|
|
340
|
+
.question-said-full.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px}
|
|
341
|
+
.question-said-full.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12px;line-height:1.45;white-space:pre}
|
|
342
|
+
.question-said-full.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
|
|
343
|
+
.question-said-full.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:12.5px}
|
|
344
|
+
.question-said-full.markdown th{background:var(--bg-inset);font-weight:600}
|
|
329
345
|
.bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
|
|
330
346
|
.bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
|
|
331
347
|
.note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
|
|
@@ -438,6 +454,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
438
454
|
.gen .activity-body>.hook-notice:first-child{border-top:none}
|
|
439
455
|
.gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
440
456
|
.hook-notice.llm-context{border-left-style:dashed;cursor:help}
|
|
457
|
+
.hook-notice a{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
458
|
+
.hook-notice a:hover{text-decoration-thickness:2px}
|
|
441
459
|
/* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
|
|
442
460
|
.steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
|
|
443
461
|
.steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
|
|
@@ -708,9 +726,28 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
708
726
|
.ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
|
|
709
727
|
.ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
|
|
710
728
|
.ts-headline.fresh{animation:ts-fade .3s ease}
|
|
711
|
-
/* While a question or approval waits: the whole reply that asked it, raw as a closed step's
|
|
712
|
-
|
|
729
|
+
/* While a question or approval waits: the whole reply that asked it, in the headline's place; raw as a closed step's
|
|
730
|
+
text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
|
|
731
|
+
its room, read from the top. */
|
|
713
732
|
.ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
|
|
733
|
+
/* Its markdown, compact: about the raw text's metrics, so the swap barely moves the slot. */
|
|
734
|
+
.ts-asking.markdown{white-space:normal}
|
|
735
|
+
.ts-asking.markdown :is(h1,h2,h3,h4,h5,h6){font-size:14.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-bright)}
|
|
736
|
+
.ts-asking.markdown :is(h4,h5,h6){font-size:14px;color:var(--text-2)}
|
|
737
|
+
.ts-asking.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
|
|
738
|
+
.ts-asking.markdown>:first-child{margin-top:0}
|
|
739
|
+
.ts-asking.markdown>:last-child{margin-bottom:0}
|
|
740
|
+
.ts-asking.markdown :is(ul,ol){padding-left:1.3em}
|
|
741
|
+
.ts-asking.markdown li>:is(ul,ol){margin:0}
|
|
742
|
+
.ts-asking.markdown strong{color:var(--text-bright)}
|
|
743
|
+
.ts-asking.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
|
|
744
|
+
.ts-asking.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-muted)}
|
|
745
|
+
.ts-asking.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
|
|
746
|
+
.ts-asking.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--text)}
|
|
747
|
+
.ts-asking.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12.5px;line-height:1.45;white-space:pre}
|
|
748
|
+
.ts-asking.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
|
|
749
|
+
.ts-asking.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:13px}
|
|
750
|
+
.ts-asking.markdown th{background:var(--bg-inset);font-weight:600}
|
|
714
751
|
.turn-stage.asking .ts-headline{display:none}
|
|
715
752
|
@keyframes ts-fade{from{opacity:.35}}
|
|
716
753
|
.ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A hook notice line's http(s) URLs as links (a source-links `sources:`
|
|
2
|
+
// line with markdown off): the terminals need the line as text, the web can
|
|
3
|
+
// click it. Only http(s) URLs that refs.js httpUrl accepts (no userinfo, not
|
|
4
|
+
// over-long) are linked; everything else stays text, escaped by lit (no
|
|
5
|
+
// unsafeHTML). A link opens in a new tab: refs.js installRefClicks leaves a
|
|
6
|
+
// plain <a> (no data-ref-kind) to the browser.
|
|
7
|
+
import { html } from "./vendor/lit-html.js";
|
|
8
|
+
import { httpUrl } from "./refs.js";
|
|
9
|
+
|
|
10
|
+
// A candidate: the scheme, then anything up to a space or an angle bracket
|
|
11
|
+
// (`<https://x/a>` links https://x/a).
|
|
12
|
+
const CANDIDATE = /https?:\/\/[^\s<>]+/gi;
|
|
13
|
+
const TRAILING = /[.,;:!?'"]+$/;
|
|
14
|
+
|
|
15
|
+
// +url+ cut at its first ) that no ( inside it balances: `(https://x/a)`
|
|
16
|
+
// ends before the ), `…/Foo_(bar)` keeps it (source_links.rb trim_url_end).
|
|
17
|
+
function cutUnbalancedParen(url) {
|
|
18
|
+
let depth = 0;
|
|
19
|
+
for (let i = 0; i < url.length; i++) {
|
|
20
|
+
if (url[i] === "(") depth++;
|
|
21
|
+
else if (url[i] === ")") {
|
|
22
|
+
if (depth === 0) return url.slice(0, i);
|
|
23
|
+
depth--;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A candidate without what ends the sentence around it: trailing
|
|
30
|
+
// punctuation (the sources line's ", "), an unbalanced ), a ] no [ opens.
|
|
31
|
+
function trimUrl(candidate) {
|
|
32
|
+
let url = cutUnbalancedParen(candidate.replace(TRAILING, ""));
|
|
33
|
+
if (url.endsWith("]") && !url.includes("[")) url = url.replace(/\]+$/, "");
|
|
34
|
+
return url.replace(TRAILING, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// +text+ with each http(s) URL a link (a lit template's array of strings
|
|
38
|
+
// and <a> parts); +text+ itself when it links nothing, so a URL-less line
|
|
39
|
+
// renders exactly as before.
|
|
40
|
+
export function linkedLine(text) {
|
|
41
|
+
if (typeof text !== "string" || !text) return text;
|
|
42
|
+
const parts = [];
|
|
43
|
+
let last = 0;
|
|
44
|
+
for (const match of text.matchAll(CANDIDATE)) {
|
|
45
|
+
const shown = trimUrl(match[0]);
|
|
46
|
+
const href = httpUrl(shown);
|
|
47
|
+
if (!href) continue;
|
|
48
|
+
if (match.index > last) parts.push(text.slice(last, match.index));
|
|
49
|
+
parts.push(html`<a href=${href} target="_blank" rel="noopener noreferrer">${shown}</a>`);
|
|
50
|
+
last = match.index + shown.length;
|
|
51
|
+
}
|
|
52
|
+
if (!parts.length) return text;
|
|
53
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
@@ -31,6 +31,9 @@ import { continueLine } from "./commands.js";
|
|
|
31
31
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
32
32
|
// @param askingText () => the running turn's asking reply (turn_model.js
|
|
33
33
|
// askingText via the page's view), kept by a question or own approval card
|
|
34
|
+
// @param renderMarkdown (text) => Promise<html|null>: the kept reply's
|
|
35
|
+
// markdown (the page's POST /api/render; null keeps the raw text)
|
|
36
|
+
// @param linkRefs (el) the page's path:line ref pass on it
|
|
34
37
|
// @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
|
|
35
38
|
// @param doc the page's document
|
|
36
39
|
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
@@ -38,6 +41,7 @@ import { continueLine } from "./commands.js";
|
|
|
38
41
|
// renderContinue(offer), resolveContinue(data), forgetContinue()}
|
|
39
42
|
export function createQuestionCards({
|
|
40
43
|
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
44
|
+
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
|
|
41
45
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
42
46
|
}) {
|
|
43
47
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
@@ -403,13 +407,14 @@ export function createQuestionCards({
|
|
|
403
407
|
// the toggle's label), and whole, selectable, at the top of the body
|
|
404
408
|
// when it is open (a drag in the summary would toggle the card).
|
|
405
409
|
const asked = saidOf.get(questionEl);
|
|
410
|
+
let full = null;
|
|
406
411
|
if (summary && asked) {
|
|
407
412
|
const line = doc.createElement("div");
|
|
408
413
|
line.className = "question-said";
|
|
409
414
|
line.setAttribute("aria-hidden", "true");
|
|
410
415
|
line.textContent = asked;
|
|
411
416
|
summary.appendChild(line);
|
|
412
|
-
|
|
417
|
+
full = doc.createElement("div");
|
|
413
418
|
full.className = "question-said-full";
|
|
414
419
|
full.textContent = asked;
|
|
415
420
|
questionEl.insertBefore(full, summary.nextSibling);
|
|
@@ -421,9 +426,36 @@ export function createQuestionCards({
|
|
|
421
426
|
const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
|
|
422
427
|
if (summary && !keepNote) note.classList.add("hidden");
|
|
423
428
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
429
|
+
// After the collapse: a card left open by hand renders at once.
|
|
430
|
+
if (full) renderSaidOnOpen(questionEl, full, asked);
|
|
424
431
|
pending = null;
|
|
425
432
|
}
|
|
426
433
|
|
|
434
|
+
// The body's copy as markdown, from the card's first open on (a reload
|
|
435
|
+
// draws every card in the history: rendering them all would ask once per
|
|
436
|
+
// card). Raw until then, and when the render answers null (then the next
|
|
437
|
+
// open asks again).
|
|
438
|
+
function renderSaidOnOpen(card, full, asked) {
|
|
439
|
+
let state = "raw";
|
|
440
|
+
const draw = () => {
|
|
441
|
+
if (!card.open || state !== "raw") return;
|
|
442
|
+
state = "asking";
|
|
443
|
+
Promise.resolve()
|
|
444
|
+
.then(() => renderMarkdown(asked))
|
|
445
|
+
.catch(() => null)
|
|
446
|
+
.then((html) => {
|
|
447
|
+
if (!html) { state = "raw"; return; }
|
|
448
|
+
state = "done";
|
|
449
|
+
// Sanitised on the server (POST /api/render).
|
|
450
|
+
full.innerHTML = html;
|
|
451
|
+
full.classList.add("markdown");
|
|
452
|
+
linkRefs(full);
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
card.addEventListener("toggle", draw);
|
|
456
|
+
draw();
|
|
457
|
+
}
|
|
458
|
+
|
|
427
459
|
function markQuestionSelection(sel) {
|
|
428
460
|
if (!questionEl || !sel.length) return;
|
|
429
461
|
questionEl.querySelectorAll(".question-choice").forEach((button) => {
|
|
@@ -19,10 +19,19 @@ export function node(tag, cls, parent = null) {
|
|
|
19
19
|
// +taskStop+: the page's stopper (task_stop.js), for the stop-task button
|
|
20
20
|
// on a running task_wait; +gens()+ the running turn's gens (the button's
|
|
21
21
|
// command, read at its click); +onChange+ redraws (the stage's
|
|
22
|
-
// scheduleRefresh).
|
|
23
|
-
|
|
22
|
+
// scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
|
|
23
|
+
// (Promise<string|null>; null keeps the raw text: markdown off, a failed
|
|
24
|
+
// read); +linkRefs(el)+: the page's path:line ref pass on the slot.
|
|
25
|
+
export function createStageSlots({
|
|
26
|
+
headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
|
|
27
|
+
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
|
|
28
|
+
}) {
|
|
24
29
|
// What each slot shows, so an unchanged slot is left alone.
|
|
25
30
|
const shown = { headline: null, now: null, trail: null, asking: null };
|
|
31
|
+
// The last asking text sent to render and its answer (in flight or
|
|
32
|
+
// done, HTML only): the same question drawn again (after a reset, a
|
|
33
|
+
// blank between) asks no second time.
|
|
34
|
+
let rendering = { text: null, html: null };
|
|
26
35
|
|
|
27
36
|
function drawHeadline({ text, thinking }) {
|
|
28
37
|
const key = `${thinking ? "t" : "n"}|${text}`;
|
|
@@ -42,13 +51,35 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
|
|
|
42
51
|
|
|
43
52
|
// Patched only when the text changes (a late chunk to the closed gen
|
|
44
53
|
// rewrites the same text): a selection in it survives. A new text reads
|
|
45
|
-
// from its top
|
|
54
|
+
// from its top: drawn raw at once, then swapped for its markdown when
|
|
55
|
+
// that comes back while the slot still shows the same text (the text is
|
|
56
|
+
// final: it shows only once the call that asks runs).
|
|
46
57
|
function drawAsking(text) {
|
|
47
58
|
asking.hidden = !text;
|
|
48
59
|
if (shown.asking === text) return;
|
|
49
60
|
shown.asking = text;
|
|
61
|
+
asking.classList.remove("markdown");
|
|
50
62
|
asking.textContent = text;
|
|
51
63
|
asking.scrollTop = 0;
|
|
64
|
+
if (!text) return;
|
|
65
|
+
linkRefs(asking);
|
|
66
|
+
if (rendering.text !== text) {
|
|
67
|
+
let html;
|
|
68
|
+
try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
|
|
69
|
+
rendering = { text, html };
|
|
70
|
+
}
|
|
71
|
+
const pending = rendering;
|
|
72
|
+
pending.html.then((html) => {
|
|
73
|
+
// A null isn't kept: the next draw of this text asks again.
|
|
74
|
+
if (!html && rendering === pending) rendering = { text: null, html: null };
|
|
75
|
+
// Swapped once: a blank-then-same draw while in flight waits twice.
|
|
76
|
+
if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
|
|
77
|
+
// Sanitised on the server (POST /api/render), as an answer's HTML is.
|
|
78
|
+
asking.innerHTML = html;
|
|
79
|
+
asking.classList.add("markdown");
|
|
80
|
+
asking.scrollTop = 0;
|
|
81
|
+
linkRefs(asking);
|
|
82
|
+
});
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
function drawNow(tool, quiet) {
|
|
@@ -123,6 +154,7 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
|
|
|
123
154
|
function reset() {
|
|
124
155
|
for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
|
|
125
156
|
asking.hidden = true;
|
|
157
|
+
asking.classList.remove("markdown");
|
|
126
158
|
shown.headline = shown.now = shown.trail = shown.asking = null;
|
|
127
159
|
}
|
|
128
160
|
|
|
@@ -48,8 +48,12 @@ function readCollapsed() {
|
|
|
48
48
|
// the history only for a reader at its end). +dockEl+, +centerEl+ and
|
|
49
49
|
// +composerEl+ size the stage's cap.
|
|
50
50
|
// +taskStop+: the page's stopper (task_stop.js), for the stop-task button
|
|
51
|
-
// on a running task_wait.
|
|
52
|
-
|
|
51
|
+
// on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
|
|
52
|
+
// slot's markdown read and ref pass (stage_slots.js).
|
|
53
|
+
export function createStageView({
|
|
54
|
+
historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null,
|
|
55
|
+
renderMarkdown, linkRefs,
|
|
56
|
+
}) {
|
|
53
57
|
// The dock becomes a clear column: the stage's card, then the composer's
|
|
54
58
|
// (its rows moved into .dock-card, which wears the glass and the focus ring).
|
|
55
59
|
document.body.classList.add("stage-view");
|
|
@@ -82,7 +86,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
82
86
|
headline.setAttribute("role", "button");
|
|
83
87
|
headline.title = "Show this step's reasoning";
|
|
84
88
|
// While a question or approval waits: the whole reply that asked it, in
|
|
85
|
-
// the headline's place (the card says what to answer, this says why)
|
|
89
|
+
// the headline's place (the card says what to answer, this says why),
|
|
90
|
+
// as markdown when web.markdown is on.
|
|
86
91
|
const asking = node("div", "ts-asking", live);
|
|
87
92
|
asking.hidden = true;
|
|
88
93
|
const now = node("div", "ts-now", live);
|
|
@@ -111,7 +116,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
111
116
|
// "queued"), each to run as a turn of its own; they hand off last.
|
|
112
117
|
const queuedSlot = node("div", "ts-queued", stage);
|
|
113
118
|
const slotsView = createStageSlots({
|
|
114
|
-
headline, asking, now, trail, ticks, taskStop,
|
|
119
|
+
headline, asking, now, trail, ticks, taskStop, renderMarkdown, linkRefs,
|
|
115
120
|
gens: () => view.current().turn?.gens,
|
|
116
121
|
onChange: () => scheduleRefresh(),
|
|
117
122
|
});
|
|
@@ -37,6 +37,7 @@ import { createSentenceFeed } from "./sentences.js";
|
|
|
37
37
|
import { createTicker } from "./thinking_ticker.js";
|
|
38
38
|
import * as model from "./turn_model.js";
|
|
39
39
|
import { createHold } from "./hold.js";
|
|
40
|
+
import { linkedLine } from "./notice_links.js";
|
|
40
41
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
41
42
|
|
|
42
43
|
// ---- The templates: one step, its thinking, its label ----
|
|
@@ -99,10 +100,10 @@ export function stepTemplate(gen, st = {}) {
|
|
|
99
100
|
|
|
100
101
|
// A hook's line (hook_notice) as a step's row. data.line is a whole row of
|
|
101
102
|
// chi's own (an empty-answer retry), shown as is; data.cls a class of its
|
|
102
|
-
// own and data.title its hover (a ✂ row's).
|
|
103
|
+
// own and data.title its hover (a ✂ row's). Its http(s) URLs are links.
|
|
103
104
|
function noticeTemplate(data) {
|
|
104
105
|
const cls = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
105
|
-
return html`<div class=${cls} title=${data.title || nothing}>${data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`}</div>`;
|
|
106
|
+
return html`<div class=${cls} title=${data.title || nothing}>${linkedLine(data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`)}</div>`;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// +template+ rendered as detached nodes (a fragment).
|
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.73.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -614,6 +614,7 @@ files:
|
|
|
614
614
|
- lib/samagotchi/web/public/llm_context_chip.js
|
|
615
615
|
- lib/samagotchi/web/public/model_pick.js
|
|
616
616
|
- lib/samagotchi/web/public/model_picker.js
|
|
617
|
+
- lib/samagotchi/web/public/notice_links.js
|
|
617
618
|
- lib/samagotchi/web/public/notifications.js
|
|
618
619
|
- lib/samagotchi/web/public/notify.js
|
|
619
620
|
- lib/samagotchi/web/public/prompt_history.js
|