samagotchi 0.95.0 → 0.96.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 +14 -1
- data/docs/cli.md +8 -0
- data/docs/plugins.md +4 -3
- data/docs/sessions.md +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -3
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +13 -1
- data/lib/samagotchi/session_commands.rb +13 -0
- data/lib/samagotchi/session_continue.rb +8 -8
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_manager.rb +13 -7
- data/lib/samagotchi/sessions_command.rb +8 -6
- data/lib/samagotchi/title_command.rb +61 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -4
- data/lib/samagotchi/web/public/app.js +12 -0
- data/lib/samagotchi/web/public/chains.js +21 -8
- data/lib/samagotchi/web/public/format.js +4 -3
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/info_bar_model.js +2 -0
- data/lib/samagotchi/web/public/session_rename.js +86 -0
- data/lib/samagotchi/web/public/session_state.js +4 -2
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +5 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 919ca9857f9debe0c23587e3061cc7d7723966067a236054d06acc68bb811b35
|
|
4
|
+
data.tar.gz: 3b5588e7c7de075d1dc7a7d84d57a39222ddd63342931aef4764a5422cc1959b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2928c6af604c2e9e33728949ae701d814ca61c510bec264004c56b00986c46ec6b2a23dfc449e832eed18246b33c57bc04b3d422e02dc3fe21667fd890b29fce
|
|
7
|
+
data.tar.gz: 4171544e495614b7f7ff23ef92b484d67dc9aa34c3624b2ac7459d8bf1e9fd93b36fc7ca329ca11e1dead80da44a032146c617900484b934fc11f2d3c4fbf159
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,18 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.96.0] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Session titles: `/title Coordinator` names a session (`/title` shows it, `/title --clear` drops it), from any UI or
|
|
16
|
+
`chi send`, mid-turn too. Every list, card, chain row, top bar, tab and notification shows it in place of the first
|
|
17
|
+
message, `chi sessions list` too (json has `title`), and a continue's next link keeps it. The web's info bar has `✎`
|
|
18
|
+
to rename the open session. See [docs/sessions.md](docs/sessions.md#session-titles).
|
|
19
|
+
|
|
20
|
+
Update with `chi update` (the system bundle 0.96.0), then restart `chi web`: a `chi web` or session worker from
|
|
21
|
+
before the update drops a session's title when it saves the session.
|
|
22
|
+
|
|
11
23
|
## [0.95.0] - 2026-10-21
|
|
12
24
|
|
|
13
25
|
### Removed
|
|
@@ -3691,7 +3703,8 @@ and long-lived sessions.
|
|
|
3691
3703
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3692
3704
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3693
3705
|
|
|
3694
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3706
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.96.0...HEAD
|
|
3707
|
+
[0.96.0]: https://github.com/dm1try/samagotchi/compare/v0.95.0...v0.96.0
|
|
3695
3708
|
[0.95.0]: https://github.com/dm1try/samagotchi/compare/v0.94.0...v0.95.0
|
|
3696
3709
|
[0.94.0]: https://github.com/dm1try/samagotchi/compare/v0.93.0...v0.94.0
|
|
3697
3710
|
[0.93.0]: https://github.com/dm1try/samagotchi/compare/v0.92.0...v0.93.0
|
data/docs/cli.md
CHANGED
|
@@ -653,6 +653,14 @@ attach and `chi -p --non-interactive` are refused. `/parent-answers` alone answe
|
|
|
653
653
|
mid-turn too; a change waits for the turn's end. No chip: its reply is where the
|
|
654
654
|
web shows it. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
|
|
655
655
|
|
|
656
|
+
`/title` shows the session's title (`title: Coordinator`, or `title: none (shows
|
|
657
|
+
the first message: …)`); `/title TEXT` names the session (whitespace collapsed,
|
|
658
|
+
cut at 80 characters), `/title --clear` drops the name (anything after `--clear`
|
|
659
|
+
is refused with the usage line). It is saved at once,
|
|
660
|
+
mid-turn too, and the lists, cards, top bar and tab show it in place of the first
|
|
661
|
+
message; a continue's next link keeps it. The web's info bar has `✎` for it. See
|
|
662
|
+
[Session titles](sessions.md#session-titles).
|
|
663
|
+
|
|
656
664
|
A `/thinking`, `/reasoning-history` or `/toolkit` change that restarts the prompt cache
|
|
657
665
|
asks first: the reply says what it costs, changes nothing, and names the line
|
|
658
666
|
that applies it (`/thinking low --yes`). `--yes` (or `-y`) applies it at once.
|
data/docs/plugins.md
CHANGED
|
@@ -966,7 +966,7 @@ id = ctx.sessions.fork(messages: ctx.messages + [{ role: "user", content: q }, {
|
|
|
966
966
|
title: "btw: #{q}")
|
|
967
967
|
ctx.sessions.send(id, "go on from here")
|
|
968
968
|
ctx.sessions.send(id, "then the docs", delivery: :queue) # after its running turn
|
|
969
|
-
ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:}
|
|
969
|
+
ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:} (title: its /title, else its preview)
|
|
970
970
|
ctx.sessions.children # => [{id:, short_id:, state:, branch:, last_reply:, reported:, ...}]
|
|
971
971
|
ctx.sessions.stop(id) # one of this session's own children
|
|
972
972
|
```
|
|
@@ -977,8 +977,9 @@ ctx.sessions.stop(id) # one of this session's own children
|
|
|
977
977
|
attach to it. It returns the child's id.
|
|
978
978
|
- Without `prompt:` the child waits idle. With one, it runs it as its first
|
|
979
979
|
turn, and counts against `session.max_children` (like `delegate`).
|
|
980
|
-
- `title:` is what the lists show
|
|
981
|
-
the first user message).
|
|
980
|
+
- `title:` is its preview: what the lists show for it (else the prompt, or
|
|
981
|
+
the first user message). It stays after the first turn; it is not a
|
|
982
|
+
`/title` of its own (see [Session titles](sessions.md#session-titles)).
|
|
982
983
|
- An image a message names is copied into the child. One whose file is
|
|
983
984
|
gone is dropped, with `[image x.png was not copied]` in its message.
|
|
984
985
|
- `send(id, text)` sends a user message to a session (an id or a unique
|
data/docs/sessions.md
CHANGED
|
@@ -217,13 +217,19 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
217
217
|
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
218
218
|
- A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
219
219
|
|
|
220
|
+
## Session titles
|
|
221
|
+
|
|
222
|
+
A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send ID "/title Coordinator"`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it (`title: none (shows the first message: hey chi …)` without one); `/title --clear` drops it, and the first message names the session again. A session whose first line was the command itself (`chi send --new "/title X"`) takes its preview from its first real message.
|
|
223
|
+
|
|
224
|
+
The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search. The web's info bar has `✎` (Rename this session): a small input prefilled with the title; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent. A session a chi REPL owns has no `✎`. A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
|
|
225
|
+
|
|
220
226
|
## Session chains
|
|
221
227
|
|
|
222
228
|
A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
|
|
223
229
|
|
|
224
230
|
`SessionManager.continue_session` makes the next link:
|
|
225
231
|
|
|
226
|
-
- **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
|
|
232
|
+
- **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
|
|
227
233
|
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
228
234
|
- **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
|
|
229
235
|
- **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
|
|
@@ -231,7 +237,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
231
237
|
- **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.
|
|
232
238
|
- **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.
|
|
233
239
|
- **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`.
|
|
234
|
-
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its recap's first sentence
|
|
240
|
+
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
235
241
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
236
242
|
- **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.
|
|
237
243
|
|
|
@@ -81,7 +81,7 @@ module Samagotchi
|
|
|
81
81
|
def child(session, row, cursors, state_dir:)
|
|
82
82
|
reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
|
|
83
83
|
cursor = Tools::DelegateCursors.get_from(cursors, session.id)
|
|
84
|
-
Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
|
|
84
|
+
Child.new(id: session.id, short_id: session.id[0, 8], title: (session.title || row[:preview]).to_s,
|
|
85
85
|
state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
|
|
86
86
|
delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
|
|
87
87
|
last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
|
|
@@ -53,7 +53,7 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
child = SessionManager.spawn_session(
|
|
55
55
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
56
|
-
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) },
|
|
56
|
+
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
|
|
57
57
|
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
58
58
|
setup: @host.setup&.call || SessionSetup.new
|
|
59
59
|
)
|
|
@@ -105,7 +105,8 @@ module Samagotchi
|
|
|
105
105
|
# Session +id+ now: from its worker when one runs (with a running turn
|
|
106
106
|
# so far), else as saved.
|
|
107
107
|
# @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
|
|
108
|
-
# (
|
|
108
|
+
# (title: its own name, /title, else its first message's preview;
|
|
109
|
+
# messages without the system prompt, symbol keys)
|
|
109
110
|
# @raise [Error] no such session
|
|
110
111
|
def read(id)
|
|
111
112
|
state_dir = self.state_dir
|
|
@@ -120,7 +121,7 @@ module Samagotchi
|
|
|
120
121
|
messages += Bridge::TurnAccumulator.messages_of(deep_symbolize(turn))
|
|
121
122
|
end
|
|
122
123
|
end
|
|
123
|
-
{ id: sid, title: session.
|
|
124
|
+
{ id: sid, title: session.display_title, status: session.status.to_s, parent_id: session.parent_id,
|
|
124
125
|
running: running, messages: without_system_head(messages) }
|
|
125
126
|
end
|
|
126
127
|
|
|
@@ -416,7 +416,7 @@ module Samagotchi
|
|
|
416
416
|
# says whether it ran as one.
|
|
417
417
|
through_worker = images || command_like?(prompt)
|
|
418
418
|
begin
|
|
419
|
-
start = through_worker ? { prompt: nil,
|
|
419
|
+
start = through_worker ? { prompt: nil, preview: prompt } : { prompt: prompt }
|
|
420
420
|
session = if options[:continues]
|
|
421
421
|
SessionManager.continue_session(options[:continues], **start, model: options[:model],
|
|
422
422
|
state_dir: @state_dir)
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -56,6 +56,10 @@ module Samagotchi
|
|
|
56
56
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
57
57
|
# Names as given; the engine normalizes them.
|
|
58
58
|
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
59
|
+
# The name the user gave the session (/title, TitleCommand), else nil:
|
|
60
|
+
# every list, card and tab shows it in place of #first_preview (see
|
|
61
|
+
# #display_title). A continue's new link inherits it.
|
|
62
|
+
attr_accessor :title
|
|
59
63
|
# The files chi --append-system-prompt put into the session's system
|
|
60
64
|
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
61
65
|
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
@@ -276,7 +280,7 @@ module Samagotchi
|
|
|
276
280
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
277
281
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
278
282
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
279
|
-
toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil)
|
|
283
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil, title: nil)
|
|
280
284
|
@id = id
|
|
281
285
|
@metadata_version = metadata_version
|
|
282
286
|
@mode = mode
|
|
@@ -289,6 +293,7 @@ module Samagotchi
|
|
|
289
293
|
@status = status
|
|
290
294
|
@last_prompt = last_prompt
|
|
291
295
|
@first_preview = first_preview
|
|
296
|
+
@title = title
|
|
292
297
|
@test_run = !!test_run
|
|
293
298
|
self.pending_question = pending_question
|
|
294
299
|
@used_memory_names = self.class.name_list(used_memory_names)
|
|
@@ -455,6 +460,7 @@ module Samagotchi
|
|
|
455
460
|
"status" => STATUS_IDLE,
|
|
456
461
|
"last_prompt" => "",
|
|
457
462
|
"first_preview" => "",
|
|
463
|
+
"title" => nil,
|
|
458
464
|
"test_run" => false,
|
|
459
465
|
"pending_question" => nil,
|
|
460
466
|
"used_memory_names" => [],
|
|
@@ -719,6 +725,12 @@ module Samagotchi
|
|
|
719
725
|
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
720
726
|
end
|
|
721
727
|
|
|
728
|
+
# What names the session: its #title when it has one, else its
|
|
729
|
+
# #first_preview.
|
|
730
|
+
def display_title
|
|
731
|
+
title.to_s.strip.empty? ? first_preview.to_s : title
|
|
732
|
+
end
|
|
733
|
+
|
|
722
734
|
# Derive and cache the first user-message preview in the session record.
|
|
723
735
|
# Returns true if the cached value was set or updated.
|
|
724
736
|
# A session with only context notes so far previews by its first note
|
|
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
|
|
|
14
14
|
require_relative "thinking_command"
|
|
15
15
|
require_relative "toolkit_command"
|
|
16
16
|
require_relative "parent_answers_command"
|
|
17
|
+
require_relative "title_command"
|
|
17
18
|
require_relative "reasoning_history_command"
|
|
18
19
|
require_relative "guardrails/parent_policy"
|
|
19
20
|
|
|
@@ -37,6 +38,7 @@ module Samagotchi
|
|
|
37
38
|
THINKING_COMMAND = ThinkingCommand::NAME
|
|
38
39
|
TOOLKIT_COMMAND = ToolkitCommand::NAME
|
|
39
40
|
PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
|
|
41
|
+
TITLE_COMMAND = TitleCommand::NAME
|
|
40
42
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
41
43
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
42
44
|
# shows this many per host; /models <text> lists every match.
|
|
@@ -112,6 +114,9 @@ module Samagotchi
|
|
|
112
114
|
"never looser than config.yml's)",
|
|
113
115
|
id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
|
|
114
116
|
match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
|
|
117
|
+
# Only a name: renaming a running session doesn't wait for its turn.
|
|
118
|
+
registry.register(TITLE_COMMAND, "show or set this session's title (--clear: back to its first message)",
|
|
119
|
+
id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
|
|
115
120
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
116
121
|
registry.register("/stats", "show the session's stats", local: true)
|
|
117
122
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -522,6 +527,14 @@ module Samagotchi
|
|
|
522
527
|
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
523
528
|
end
|
|
524
529
|
|
|
530
|
+
# /title (TitleCommand); :title in changed when it named the session or
|
|
531
|
+
# cleared its name. A plain save: no setting moved, and it may run on
|
|
532
|
+
# an anytime thread beside a turn.
|
|
533
|
+
def title(text)
|
|
534
|
+
output, changed, status = TitleCommand.new(engine: @engine, save: @save).run(text.delete_prefix(TITLE_COMMAND))
|
|
535
|
+
reply(output, status: status, changed: changed ? [:title] : [])
|
|
536
|
+
end
|
|
537
|
+
|
|
525
538
|
# A session setting changed (/llm-context, /thinking, /reasoning-history):
|
|
526
539
|
# saved with its model.
|
|
527
540
|
def save_setup(session)
|
|
@@ -83,7 +83,7 @@ module Samagotchi
|
|
|
83
83
|
# follows the chain to its new end instead.
|
|
84
84
|
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
85
85
|
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
86
|
-
# @param
|
|
86
|
+
# @param preview [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
89
|
# #moved_children: the delegates it took over, #opener:
|
|
@@ -92,7 +92,7 @@ module Samagotchi
|
|
|
92
92
|
# @raise [Refused] continued already, delegates open that can't
|
|
93
93
|
# move, or its folder is gone
|
|
94
94
|
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
95
|
-
def self.run(id_or_ref, prompt: nil,
|
|
95
|
+
def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
96
96
|
sd = state_dir || Session.default_state_dir
|
|
97
97
|
TRIES.times do |try|
|
|
98
98
|
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
@@ -100,7 +100,7 @@ module Samagotchi
|
|
|
100
100
|
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
|
|
101
101
|
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
102
102
|
|
|
103
|
-
run_locked(id, prompt: prompt,
|
|
103
|
+
run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
|
|
104
104
|
archive_wait: archive_wait)
|
|
105
105
|
end
|
|
106
106
|
return result unless result == :moved
|
|
@@ -116,7 +116,7 @@ module Samagotchi
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
private_class_method def self.run_locked(id, prompt:,
|
|
119
|
+
private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
|
|
120
120
|
previous = Session.load(id, state_dir: state_dir)
|
|
121
121
|
checked = check!(previous, state_dir, model: model)
|
|
122
122
|
plan = checked.plan
|
|
@@ -132,7 +132,7 @@ module Samagotchi
|
|
|
132
132
|
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
133
133
|
archived = archive[:archived] - already
|
|
134
134
|
begin
|
|
135
|
-
spawn_continuation(previous, prompt: prompt,
|
|
135
|
+
spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
|
|
136
136
|
move: plan.move)
|
|
137
137
|
rescue StandardError
|
|
138
138
|
# Nothing started: the delegates go back, what this archived goes
|
|
@@ -194,16 +194,16 @@ module Samagotchi
|
|
|
194
194
|
|
|
195
195
|
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
196
196
|
# while +move+ goes to it.
|
|
197
|
-
private_class_method def self.spawn_continuation(previous, prompt:,
|
|
197
|
+
private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
|
|
198
198
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
199
199
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
200
|
-
|
|
200
|
+
preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
|
|
201
201
|
before_spawn = lambda do |link|
|
|
202
202
|
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
203
203
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
204
204
|
end
|
|
205
205
|
link = SessionManager.spawn_session(
|
|
206
|
-
prompt: start, title: title, working_directory: previous.working_directory,
|
|
206
|
+
prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
|
|
207
207
|
model_name: choice.model, setup: SessionSetup.of(previous),
|
|
208
208
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
209
209
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
@@ -80,7 +80,7 @@ module Samagotchi
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def preview_of(id)
|
|
83
|
-
text = Session.load(id, state_dir: @state_dir).
|
|
83
|
+
text = Session.load(id, state_dir: @state_dir).display_title.gsub(/\s+/, " ").strip
|
|
84
84
|
return nil if text.empty?
|
|
85
85
|
|
|
86
86
|
text.length > PREVIEW_LIMIT ? "#{text[0, PREVIEW_LIMIT - 1]}…" : text
|
|
@@ -138,8 +138,11 @@ module Samagotchi
|
|
|
138
138
|
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
139
139
|
# ctx.sessions.fork); its image refs are copied from +images_from+
|
|
140
140
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
141
|
-
# @param
|
|
141
|
+
# @param preview [String, nil] what the lists show before the first turn
|
|
142
142
|
# (the prompt's preview by default, else the seed's first user message)
|
|
143
|
+
# @param title [String, nil] the session's own name (Session#title; a
|
|
144
|
+
# continue's link inherits its previous link's), saved before the
|
|
145
|
+
# worker starts: the worker owns the file from then on
|
|
143
146
|
# @param setup [SessionSetup] the session's own settings (chi
|
|
144
147
|
# --llm-context; a fork and a continue copy their session's; a delegate
|
|
145
148
|
# child the toolkit its delegate call named); without a toolkit the
|
|
@@ -156,7 +159,7 @@ module Samagotchi
|
|
|
156
159
|
# and #model_warning: an id its host's saved list doesn't have
|
|
157
160
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
158
161
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
159
|
-
title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
162
|
+
preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
160
163
|
note_source: "chi", before_spawn: nil)
|
|
161
164
|
sd = state_dir || Session.default_state_dir
|
|
162
165
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
@@ -189,7 +192,8 @@ module Samagotchi
|
|
|
189
192
|
session.last_prompt = prompt
|
|
190
193
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
191
194
|
# the turn's end: until then this is the only preview a list has.
|
|
192
|
-
session.first_preview = Session.preview_of(
|
|
195
|
+
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
196
|
+
session.title = title
|
|
193
197
|
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
194
198
|
unless session.messages.empty?
|
|
195
199
|
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
@@ -321,7 +325,7 @@ module Samagotchi
|
|
|
321
325
|
owned = !!owner&.worker?
|
|
322
326
|
next if live && !owned
|
|
323
327
|
|
|
324
|
-
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
328
|
+
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
|
|
325
329
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
326
330
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
327
331
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
@@ -464,10 +468,12 @@ module Samagotchi
|
|
|
464
468
|
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
465
469
|
end
|
|
466
470
|
|
|
467
|
-
# A session as one line of text for the lists: its
|
|
468
|
-
# first message it was started with (a session
|
|
469
|
-
# or a first turn not saved yet).
|
|
471
|
+
# A session as one line of text for the lists: its title (/title), else
|
|
472
|
+
# its last prompt, else the first message it was started with (a session
|
|
473
|
+
# command like `/model x`, or a first turn not saved yet).
|
|
470
474
|
def self.summary_text(session)
|
|
475
|
+
return one_line(session.title) unless session.title.to_s.strip.empty?
|
|
476
|
+
|
|
471
477
|
one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
|
|
472
478
|
end
|
|
473
479
|
|
|
@@ -198,11 +198,12 @@ module Samagotchi
|
|
|
198
198
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
199
199
|
# leftover one at the next sweep)
|
|
200
200
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
201
|
+
# title: the session's own name (/title; desc shows it too), or nil
|
|
201
202
|
# waiting: the kind of question it waits on (question, approval,
|
|
202
203
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
203
204
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
204
205
|
# id) whose card it also waits in (the approval relay), or nil
|
|
205
|
-
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
206
|
+
keys = %i[id short_id desc title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
206
207
|
waiting waiting_id relayed_to stopped_by]
|
|
207
208
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
208
209
|
when "tsv"
|
|
@@ -215,9 +216,10 @@ module Samagotchi
|
|
|
215
216
|
else summary[:live] ? "live" : summary[:status].to_s
|
|
216
217
|
end
|
|
217
218
|
# The saved recap's first sentence says more than the last prompt
|
|
218
|
-
# (text only: tsv and json keep desc for pickers)
|
|
219
|
+
# (text only: tsv and json keep desc for pickers); a title (in
|
|
220
|
+
# desc) says what the user named it.
|
|
219
221
|
desc = summary[:desc]
|
|
220
|
-
if summary[:recap]
|
|
222
|
+
if summary[:recap] && !summary[:title]
|
|
221
223
|
desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
|
|
222
224
|
desc = "#{desc[0, 59]}…" if desc.length > 60
|
|
223
225
|
end
|
|
@@ -245,11 +247,11 @@ module Samagotchi
|
|
|
245
247
|
order ||= "desc"
|
|
246
248
|
sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
|
|
247
249
|
include_archived: include_archived)
|
|
248
|
-
# The saved recap's first sentence, else the
|
|
249
|
-
# message, before any turn), cut as before.
|
|
250
|
+
# The session's title, else the saved recap's first sentence, else the
|
|
251
|
+
# last prompt (or the first message, before any turn), cut as before.
|
|
250
252
|
state_dir = Samagotchi::Session.default_state_dir
|
|
251
253
|
list_text = lambda do |s|
|
|
252
|
-
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
254
|
+
recap = s.title ? nil : Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
253
255
|
(recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
254
256
|
end
|
|
255
257
|
# How full the context was after the last turn: "ctx 12%", counted
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# /title, the session's own name (Session#title): with no argument, the
|
|
5
|
+
# title and what shows without one; `/title TEXT` names the session
|
|
6
|
+
# (whitespace collapsed, cut at 80 characters as a preview is), saved at
|
|
7
|
+
# once; `/title --clear` drops it, and the lists show the first message
|
|
8
|
+
# again. Anyone who may send the session a command may name it (a parent
|
|
9
|
+
# agent its delegates too). It runs mid-turn as well (anytime): the save
|
|
10
|
+
# takes the session's lock and writes a running turn's checkpoint, as a
|
|
11
|
+
# turn's own saves do.
|
|
12
|
+
class TitleCommand
|
|
13
|
+
NAME = "/title"
|
|
14
|
+
CLEAR = "--clear"
|
|
15
|
+
USAGE = "usage: #{NAME} [TEXT|#{CLEAR}]".freeze
|
|
16
|
+
# As long as a preview (Session.preview_of) may be.
|
|
17
|
+
LIMIT = 80
|
|
18
|
+
|
|
19
|
+
# @param engine [Engine] #session
|
|
20
|
+
# @param save [#call] saves the session after a change
|
|
21
|
+
def initialize(engine:, save:)
|
|
22
|
+
@engine = engine
|
|
23
|
+
@save = save
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param args [String] what follows /title
|
|
27
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
28
|
+
# title changed, and :ok or :error
|
|
29
|
+
def run(args)
|
|
30
|
+
session = @engine.session or return ["no session yet", false, :error]
|
|
31
|
+
text = args.to_s.gsub(/\s+/, " ").strip
|
|
32
|
+
return [listing(session), false, :ok] if text.empty?
|
|
33
|
+
# "--clear and more" is a slip, not a title.
|
|
34
|
+
return [USAGE, false, :error] if text.start_with?("#{CLEAR} ")
|
|
35
|
+
|
|
36
|
+
title = if text == CLEAR then nil
|
|
37
|
+
elsif text.length > LIMIT then "#{text[0, LIMIT]}…"
|
|
38
|
+
else text
|
|
39
|
+
end
|
|
40
|
+
return ["#{listing(session)}\n(no change)", false, :ok] if title == session.title
|
|
41
|
+
|
|
42
|
+
session.title = title
|
|
43
|
+
# The command itself as the session's first line (chi send --new
|
|
44
|
+
# "/title X", a web first message) became its preview: it goes, and
|
|
45
|
+
# the save derives the preview from the first user message again.
|
|
46
|
+
session.first_preview = "" if session.first_preview.to_s.start_with?(NAME)
|
|
47
|
+
@save.call(session)
|
|
48
|
+
[listing(session), true, :ok]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# "title: Coordinator", or "title: none (shows the first message: hey chi …)"
|
|
54
|
+
def listing(session)
|
|
55
|
+
return "title: #{session.title}" if session.title
|
|
56
|
+
|
|
57
|
+
preview = session.first_preview.to_s
|
|
58
|
+
"title: none (shows #{preview.empty? ? "the first message" : "the first message: #{preview}"})"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -681,7 +681,7 @@ module Samagotchi
|
|
|
681
681
|
# next; the list and the notifications name the session by it
|
|
682
682
|
# until that turn is saved.
|
|
683
683
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
684
|
-
folder[:
|
|
684
|
+
folder[:preview] = preview unless preview.empty?
|
|
685
685
|
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
|
|
686
686
|
folder[:setup] = setup unless setup.empty?
|
|
687
687
|
begin
|
|
@@ -725,7 +725,7 @@ module Samagotchi
|
|
|
725
725
|
|
|
726
726
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
727
727
|
model = (body["model"] || body["model_name"]).to_s.strip
|
|
728
|
-
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s,
|
|
728
|
+
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
|
|
729
729
|
model: model.empty? ? nil : model)
|
|
730
730
|
return error if error
|
|
731
731
|
|
|
@@ -742,8 +742,8 @@ module Samagotchi
|
|
|
742
742
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
743
743
|
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
744
744
|
# the new link started is not a refusal of it.
|
|
745
|
-
def continue_from(given, prompt:,
|
|
746
|
-
[@manager.continue_session(given, prompt: prompt,
|
|
745
|
+
def continue_from(given, prompt:, preview:, model:)
|
|
746
|
+
[@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
|
|
747
747
|
rescue SessionContinue::Refused => e
|
|
748
748
|
extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
|
|
749
749
|
[nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
|
|
@@ -85,6 +85,7 @@ import { createToasts } from "./toasts.js";
|
|
|
85
85
|
import { createUnqueue } from "./unqueue.js";
|
|
86
86
|
import { createStartPage } from "./start_page.js";
|
|
87
87
|
import { createSessionActions } from "./session_actions.js";
|
|
88
|
+
import { createRename } from "./session_rename.js";
|
|
88
89
|
import { createSendTurn } from "./send_turn.js";
|
|
89
90
|
|
|
90
91
|
const $ = (s) => document.querySelector(s);
|
|
@@ -495,6 +496,14 @@ const sessionActions = createSessionActions({
|
|
|
495
496
|
closeLiveStream: () => streamControl?.close(),
|
|
496
497
|
workerGone,
|
|
497
498
|
});
|
|
499
|
+
// ✎: /title through the chips' path (session_rename.js).
|
|
500
|
+
const rename = createRename({
|
|
501
|
+
button: $("#infoRenameBtn"),
|
|
502
|
+
session: () => selected,
|
|
503
|
+
titleOf: (id) => lists.sessionsById().get(id)?.title || null,
|
|
504
|
+
run: (line) => runSessionCommand(line),
|
|
505
|
+
});
|
|
506
|
+
|
|
498
507
|
const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
|
|
499
508
|
|
|
500
509
|
// All sessions: its own view at #/sessions, so the browser's back button
|
|
@@ -1046,6 +1055,9 @@ function updateInfoBar() {
|
|
|
1046
1055
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
1047
1056
|
const archiveBtn = document.getElementById("infoArchiveBtn");
|
|
1048
1057
|
const continueBtn = document.getElementById("infoContinueBtn");
|
|
1058
|
+
const renameBtn = document.getElementById("infoRenameBtn");
|
|
1059
|
+
if (renameBtn) renameBtn.style.display = buttons.rename.shown ? "" : "none";
|
|
1060
|
+
rename.follow(buttons.rename.shown ? selected : null);
|
|
1049
1061
|
if (continueBtn) {
|
|
1050
1062
|
continueBtn.style.display = buttons.continue.shown ? "" : "none";
|
|
1051
1063
|
continueBtn.disabled = buttons.continue.disabled;
|
|
@@ -36,9 +36,11 @@ export function isOpener(text) {
|
|
|
36
36
|
// reads its chain's first link's title instead, so the links of a chain
|
|
37
37
|
// keep its name. Its own title when the first link isn't in +byId+ (id →
|
|
38
38
|
// session; deleted, or a walk a missing link cut short) or is the opener too.
|
|
39
|
+
// A link with a title of its own (/title; a new link inherits it) reads it,
|
|
40
|
+
// and a titled first link names its chain.
|
|
39
41
|
export function chainTitle(s, byId) {
|
|
40
42
|
const own = previewForCard(s);
|
|
41
|
-
if (!s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
|
|
43
|
+
if (s?.title || !s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
|
|
42
44
|
const seen = new Set([s.id]);
|
|
43
45
|
let at = s;
|
|
44
46
|
while (at.continues) {
|
|
@@ -47,7 +49,7 @@ export function chainTitle(s, byId) {
|
|
|
47
49
|
seen.add(prev.id);
|
|
48
50
|
at = prev;
|
|
49
51
|
}
|
|
50
|
-
return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
|
|
52
|
+
return !at.title && isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// The open session's title (app.js updateSessionTitle: the top bar and the
|
|
@@ -55,9 +57,10 @@ export function chainTitle(s, byId) {
|
|
|
55
57
|
// (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
|
|
56
58
|
// its first message: a web link inherits its chain's title, and its first
|
|
57
59
|
// message is an opener (the constant or the user's own). Else +first+,
|
|
58
|
-
// unless it is the opener.
|
|
60
|
+
// unless it is the opener. A titled session (/title) reads its card's too.
|
|
61
|
+
// +shown+ null: not listed yet, +first+.
|
|
59
62
|
export function openTitle({ shown, first, cardTitle }) {
|
|
60
|
-
if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
|
|
63
|
+
if (shown && (shown.title || shown.continues || isOpener(first))) return cardTitle(shown);
|
|
61
64
|
return first;
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -213,13 +216,23 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
213
216
|
|
|
214
217
|
// The chain's list (the chip's popover): one row per other link, newest
|
|
215
218
|
// first: status dot, "#N", its date (with its HH:MM when another link of
|
|
216
|
-
// the chain, the head too, started the same day: chainStamp), its
|
|
217
|
-
// its
|
|
219
|
+
// the chain, the head too, started the same day: chainStamp), its own title (/title) when it
|
|
220
|
+
// differs from the head's (a link inherits its chain's: the card says it), else its recap's
|
|
221
|
+
// first sentence, else its preview, the waiting / stopped badge, archived. A row's click opens
|
|
218
222
|
// that link (families_view.js's popover binds .family-row[data-id]).
|
|
219
223
|
// @param activeId the open session's id (its row is .active)
|
|
224
|
+
// A chain row's words: a name of its own (not the one it shares with the
|
|
225
|
+
// head), else what tells the link apart: its recap, else its preview (an
|
|
226
|
+
// opener's read as the chain's, chainTitle, with its own title left out).
|
|
227
|
+
function rowTitle(s, headTitle, byId) {
|
|
228
|
+
if (s.title && s.title !== headTitle) return s.title;
|
|
229
|
+
return s.recap || chainTitle({ ...s, title: null }, byId);
|
|
230
|
+
}
|
|
231
|
+
|
|
220
232
|
export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
|
|
221
|
-
const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
|
|
222
233
|
const links = f?.chain?.links || [];
|
|
234
|
+
const byId = new Map(links.map((s) => [s.id, s]));
|
|
235
|
+
const headTitle = (byId.get(f?.head?.id) || f?.head)?.title || null;
|
|
223
236
|
const rows = chainOthers(f).map(({ session: s, n }) => {
|
|
224
237
|
const cls = ["family-row", "chain-row"];
|
|
225
238
|
if (activeId && s.id === activeId) cls.push("active");
|
|
@@ -231,7 +244,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
231
244
|
const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
|
|
232
245
|
return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
|
|
233
246
|
title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${n}</span>${
|
|
234
|
-
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${
|
|
247
|
+
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
|
|
235
248
|
rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
236
249
|
});
|
|
237
250
|
return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
|
|
@@ -58,8 +58,9 @@ export function firstPreview(text) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function previewForCard(session) {
|
|
61
|
-
// Unified card preview:
|
|
62
|
-
|
|
61
|
+
// Unified card preview: its title (/title), else its first message (80),
|
|
62
|
+
// else its last prompt.
|
|
63
|
+
const raw = session.title || session.first_preview || session.last_prompt || "";
|
|
63
64
|
return firstPreview(raw) || "\u2014";
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -69,7 +70,7 @@ const DELETE_PREVIEW_CHARS = 40;
|
|
|
69
70
|
// list's owner can be stale (a worker idle-exits), so a worker is only
|
|
70
71
|
// "stopped if it's still running", never promised.
|
|
71
72
|
export function deleteConfirmText(session) {
|
|
72
|
-
const n = normalize(session.first_preview || session.last_prompt || "");
|
|
73
|
+
const n = normalize(session.title || session.first_preview || session.last_prompt || "");
|
|
73
74
|
const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
|
|
74
75
|
const shortId = session.short_id || String(session.id).slice(0, 8);
|
|
75
76
|
const lines = [
|
|
@@ -887,6 +887,10 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
887
887
|
#infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
|
|
888
888
|
#infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
|
|
889
889
|
#infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
|
|
890
|
+
#infoBar #infoRenameBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
891
|
+
#infoBar #infoRenameBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
892
|
+
/* ✎'s input (session_rename.js), in the button's place. */
|
|
893
|
+
#infoBar .rename-input{flex:0 1 18em;min-width:6em;font:inherit;font-size:12px;padding:3px 8px;border-radius:8px;border:1px solid var(--accent-border);background:var(--bg-raised);color:var(--text);outline:none}
|
|
890
894
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
891
895
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
892
896
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
@@ -976,6 +980,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
976
980
|
#infoBar>button{font-size:0;line-height:0;padding:5px 7px}
|
|
977
981
|
#infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
978
982
|
#infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
|
|
983
|
+
#infoBar #infoRenameBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.5 2.5l3 3L5.5 13.5H2.5v-3z'/%3E%3C/svg%3E")}
|
|
984
|
+
#infoBar .rename-input{flex:1 1 8em;font-size:16px}
|
|
979
985
|
#infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
|
|
980
986
|
#infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
|
|
981
987
|
#infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
|
|
@@ -1300,6 +1306,7 @@ a{color:var(--accent)}
|
|
|
1300
1306
|
<div class="info" id="infoText"></div>
|
|
1301
1307
|
<div id="contextChips" aria-label="Attached context" hidden></div>
|
|
1302
1308
|
<button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1309
|
+
<button class="ghost" id="infoRenameBtn" title="Rename this session" aria-label="Rename this session" style="display:none">✎</button>
|
|
1303
1310
|
<button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
|
|
1304
1311
|
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
1305
1312
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
|
|
@@ -42,6 +42,8 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
|
|
|
42
42
|
const busy = !!id && continuing;
|
|
43
43
|
return {
|
|
44
44
|
continue: { shown: !!id && !chain.next && state.owner !== "tui", disabled: busy, text: busy ? "continuing…" : "continue →" },
|
|
45
|
+
// ✎: /title from the web (a plain terminal's session takes no commands).
|
|
46
|
+
rename: { shown: !!id && state.owner !== "tui" },
|
|
45
47
|
delete: { shown: !!id },
|
|
46
48
|
archive: {
|
|
47
49
|
shown: !!id,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The info bar's ✎ (Rename this session): a small input in its place,
|
|
2
|
+
// prefilled with the session's title ("" for none). Enter sends
|
|
3
|
+
// `/title TEXT` (blank: `/title --clear`) through the chips' command path
|
|
4
|
+
// (composer.js run: the reply shows as a command bubble, a refusal in the
|
|
5
|
+
// failed bubble, the composer untouched); leaving the field saves the same
|
|
6
|
+
// way (a phone's keyboard dismissed keeps what was typed). Esc, or opening
|
|
7
|
+
// another session, closes it unsent. The cards follow from the saved file
|
|
8
|
+
// (the hub's scan), as for any title.
|
|
9
|
+
import { normalize } from "./format.js";
|
|
10
|
+
|
|
11
|
+
// As long as a title may be (TitleCommand::LIMIT).
|
|
12
|
+
const TITLE_CHARS = 80;
|
|
13
|
+
|
|
14
|
+
// The line the input's +text+ sends.
|
|
15
|
+
export function titleLine(text) {
|
|
16
|
+
const title = normalize(text);
|
|
17
|
+
return title ? `/title ${title}` : "/title --clear";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// @param button the ✎ button (it hides while the input shows)
|
|
21
|
+
// @param session () => the open session's id, or null
|
|
22
|
+
// @param titleOf (id) => that session's title, or null
|
|
23
|
+
// @param run (line) => composer.js run's promise
|
|
24
|
+
export function createRename({ button, session, titleOf, run }) {
|
|
25
|
+
let input = null;
|
|
26
|
+
let openFor = null;
|
|
27
|
+
|
|
28
|
+
function close() {
|
|
29
|
+
if (!input) return;
|
|
30
|
+
const el = input;
|
|
31
|
+
input = null;
|
|
32
|
+
openFor = null;
|
|
33
|
+
el.remove();
|
|
34
|
+
button.hidden = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Enter's and blur's: once (closing it blurs it again), and only to the
|
|
38
|
+
// session it was opened for.
|
|
39
|
+
function submit() {
|
|
40
|
+
if (!input) return null;
|
|
41
|
+
const id = openFor;
|
|
42
|
+
const before = normalize(input.dataset.before);
|
|
43
|
+
const text = input.value;
|
|
44
|
+
close();
|
|
45
|
+
if (!id || session() !== id || normalize(text) === before) return null;
|
|
46
|
+
return run(titleLine(text));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function open() {
|
|
50
|
+
const id = session();
|
|
51
|
+
if (input || !id) return;
|
|
52
|
+
const el = document.createElement("input");
|
|
53
|
+
el.type = "text";
|
|
54
|
+
el.className = "rename-input";
|
|
55
|
+
el.maxLength = TITLE_CHARS;
|
|
56
|
+
el.placeholder = "Session title";
|
|
57
|
+
el.setAttribute("aria-label", "Session title (Enter saves, empty clears, Esc cancels)");
|
|
58
|
+
el.value = titleOf(id) || "";
|
|
59
|
+
el.dataset.before = el.value;
|
|
60
|
+
el.addEventListener("keydown", (e) => {
|
|
61
|
+
if (e.key === "Enter") {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
submit();
|
|
64
|
+
} else if (e.key === "Escape") {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
el.addEventListener("blur", () => submit());
|
|
70
|
+
input = el;
|
|
71
|
+
openFor = id;
|
|
72
|
+
button.hidden = true;
|
|
73
|
+
button.after(el);
|
|
74
|
+
el.focus();
|
|
75
|
+
el.select();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The info bar redrew for +id+ (app.js updateInfoBar): an input opened
|
|
79
|
+
// for another session closes.
|
|
80
|
+
function follow(id) {
|
|
81
|
+
if (input && openFor !== id) close();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
button.addEventListener("click", () => open());
|
|
85
|
+
return { open, close, follow, isOpen: () => !!input };
|
|
86
|
+
}
|
|
@@ -118,10 +118,12 @@ export function sessionStateFrom(data) {
|
|
|
118
118
|
return state;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// The info bar's title: the
|
|
122
|
-
// messages, else the card's preview, else
|
|
121
|
+
// The info bar's title: the session's own title (/title), else the first
|
|
122
|
+
// user message when the payload has messages, else the card's preview, else
|
|
123
|
+
// the first prompt of its history.
|
|
123
124
|
function sessionFirstPreview(data) {
|
|
124
125
|
const s = data.session;
|
|
126
|
+
if (s.title) return firstPreview(s.title);
|
|
125
127
|
let preview = s.first_preview || previewForCard(s);
|
|
126
128
|
if (data.messages && data.messages.length) {
|
|
127
129
|
const first = data.messages.find((m) => m.role === "user");
|
|
@@ -67,6 +67,9 @@ module Samagotchi
|
|
|
67
67
|
# chain), else nil: the lists draw chains from it.
|
|
68
68
|
continues: session.continues,
|
|
69
69
|
first_preview: first_preview_for(session),
|
|
70
|
+
# The session's own name (/title), else nil: the pages show it in
|
|
71
|
+
# place of the first message.
|
|
72
|
+
title: session.title,
|
|
70
73
|
owner: owner&.kind,
|
|
71
74
|
# The saved recap's first sentence, for the session card.
|
|
72
75
|
recap: RecapStore.preview(session_dir),
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -933,8 +933,11 @@ module Samagotchi
|
|
|
933
933
|
# finishes) comes after its command_queued. The cards it shows go out at
|
|
934
934
|
# once (btw's "thinking…" card), after its command_queued, which the
|
|
935
935
|
# UIs draw its line at (Engine#running_anytime). Its handler reads
|
|
936
|
-
# copies (ctx.messages) and shows things through ctx only
|
|
937
|
-
#
|
|
936
|
+
# copies (ctx.messages) and shows things through ctx only. One saves:
|
|
937
|
+
# /title, a field no turn writes, through Session#save, which takes the
|
|
938
|
+
# session's lock and writes a running turn's checkpoint (as
|
|
939
|
+
# WorkerSignals' save off the turn thread does), so the turn's own saves
|
|
940
|
+
# keep it.
|
|
938
941
|
def start_anytime_command(command)
|
|
939
942
|
@engine.spawn_anytime do
|
|
940
943
|
result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
|
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.96.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -582,6 +582,7 @@ files:
|
|
|
582
582
|
- lib/samagotchi/thinking_tails.rb
|
|
583
583
|
- lib/samagotchi/thinking_ticker.rb
|
|
584
584
|
- lib/samagotchi/thought_stream_splitter.rb
|
|
585
|
+
- lib/samagotchi/title_command.rb
|
|
585
586
|
- lib/samagotchi/token_usage.rb
|
|
586
587
|
- lib/samagotchi/tool_activity.rb
|
|
587
588
|
- lib/samagotchi/tool_call_parser.rb
|
|
@@ -717,6 +718,7 @@ files:
|
|
|
717
718
|
- lib/samagotchi/web/public/send_turn.js
|
|
718
719
|
- lib/samagotchi/web/public/sentences.js
|
|
719
720
|
- lib/samagotchi/web/public/session_actions.js
|
|
721
|
+
- lib/samagotchi/web/public/session_rename.js
|
|
720
722
|
- lib/samagotchi/web/public/session_state.js
|
|
721
723
|
- lib/samagotchi/web/public/session_title.js
|
|
722
724
|
- lib/samagotchi/web/public/sessions_list.js
|