samagotchi 0.113.0 → 0.114.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 +24 -1
- data/docs/cli.md +20 -4
- data/docs/configuration.md +2 -1
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap_command.rb +70 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +24 -0
- data/lib/samagotchi/config_text_edit.rb +10 -3
- data/lib/samagotchi/gem_update.rb +5 -1
- data/lib/samagotchi/idle_recap.rb +38 -10
- data/lib/samagotchi/recap_store.rb +20 -3
- data/lib/samagotchi/recap_title.rb +78 -0
- data/lib/samagotchi/session_manager.rb +10 -3
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/title_command.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/markdown_renderer.rb +20 -0
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/index.html +12 -4
- data/lib/samagotchi/web/public/markdown_tip.js +33 -0
- data/lib/samagotchi/web/push/attention.rb +2 -1
- data/lib/samagotchi/web/session_show.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +7 -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: a59aa63d1ccf6a6114afcaf5b1cf19088cb482989c08acb3aa5bfede3a67358d
|
|
4
|
+
data.tar.gz: 3fe5245e7c864ca0b814d9b187b402d5658ffd88aa5d895881482f4b40e9391f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 412b5e6a15d73f4817729879e71aa3f2813ae73705039a8db561cbafffa0e246f6cf0f37760ebf346144c88369174a42cd902f36d31f3c02703345c99d18505f
|
|
7
|
+
data.tar.gz: 25d265a240a37194b5670afeb6d4784b55b43ddb33a857039da16ffc1a1184d79a6a4b30e7111a8c0698f538c130cd2c930545c03e3474a9bbf814bd22dbb48f
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,28 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.114.0] - 2026-10-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Auto titles: the idle recap also writes a 2-6 word title for the session, in the same request, and a session
|
|
16
|
+
without a `/title` is named by it on the web's cards, top bar, tab and notifications and in `chi sessions list`
|
|
17
|
+
(json: `auto_title`). Later recaps keep it unless the focus moved; a hand title always wins.
|
|
18
|
+
- chi web tells you Markdown rendering exists: while `web.markdown` is off, a session shows a one-line tip (restart
|
|
19
|
+
chi web with `--web-markdown`, or `web.markdown: true` in config.yml; plus `gem install commonmarker` when the gem
|
|
20
|
+
is missing). Its × hides it for that browser.
|
|
21
|
+
- `chi bootstrap` on a terminal asks "Render answers as Markdown in chi web? [Y/n]": yes installs the commonmarker
|
|
22
|
+
gem when it is missing and writes `web: markdown: true` into config.yml, its comments kept; a failed install says
|
|
23
|
+
why in one line and writes nothing. Without a terminal, a new config's next steps name `chi web --web-markdown`.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- chi web's all sessions list: once it held more cards than fit, every card was cut to 72px whatever it held, so a
|
|
28
|
+
two-line title's meta row ran over the card's border and a parent's "▸ N delegates" chip was drawn under the next
|
|
29
|
+
card, out of reach.
|
|
30
|
+
|
|
31
|
+
Update with `chi update` (the system bundle 0.114.0), then restart `chi web`.
|
|
32
|
+
|
|
11
33
|
## [0.113.0] - 2026-10-25
|
|
12
34
|
|
|
13
35
|
### Added
|
|
@@ -4314,7 +4336,8 @@ and long-lived sessions.
|
|
|
4314
4336
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4315
4337
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4316
4338
|
|
|
4317
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4339
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.114.0...HEAD
|
|
4340
|
+
[0.114.0]: https://github.com/dm1try/samagotchi/compare/v0.113.0...v0.114.0
|
|
4318
4341
|
[0.113.0]: https://github.com/dm1try/samagotchi/compare/v0.112.0...v0.113.0
|
|
4319
4342
|
[0.112.0]: https://github.com/dm1try/samagotchi/compare/v0.111.1...v0.112.0
|
|
4320
4343
|
[0.111.1]: https://github.com/dm1try/samagotchi/compare/v0.111.0...v0.111.1
|
data/docs/cli.md
CHANGED
|
@@ -99,6 +99,16 @@ chi bootstrap # try localhost 8080, 11434, 1234, 8000
|
|
|
99
99
|
you uninstalled stays out ([Bundle profiles](memory.md#bundle-profiles-core-and-dev)).
|
|
100
100
|
A bundle that fails to install makes the exit 1. `--dry-run` says what it
|
|
101
101
|
would install.
|
|
102
|
+
- **Markdown in chi web.** Then, on a terminal and unless config.yml has
|
|
103
|
+
`web: markdown: true` already, it asks `Render answers as Markdown in chi web? [Y/n]`
|
|
104
|
+
(a closed stdin is no). Yes installs the `commonmarker` gem when it is
|
|
105
|
+
missing (`gem install commonmarker` through the Ruby chi runs on; it can take
|
|
106
|
+
a while where no precompiled build exists) and writes `web: markdown: true`
|
|
107
|
+
into config.yml, its comments kept ("web: markdown on (config.yml)"). A failed
|
|
108
|
+
install says so in one line with gem's last output line, writes nothing and
|
|
109
|
+
leaves the exit code as it was. Without a terminal it doesn't ask; a new
|
|
110
|
+
config's next steps then name `chi web --web-markdown`. `--dry-run` says it
|
|
111
|
+
would ask.
|
|
102
112
|
|
|
103
113
|
### Writing a bundle: new, test, check
|
|
104
114
|
|
|
@@ -710,8 +720,9 @@ attach and `chi -p --non-interactive` are refused. `/parent-answers` alone answe
|
|
|
710
720
|
mid-turn too; a change waits for the turn's end. No chip: its reply is where the
|
|
711
721
|
web shows it. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
|
|
712
722
|
|
|
713
|
-
`/title` shows the session's title (`title: Coordinator`, or
|
|
714
|
-
the
|
|
723
|
+
`/title` shows the session's title (`title: Coordinator`, or without one what names
|
|
724
|
+
the session: `title: none (shows the auto title: …)`, the recap's, or `title: none
|
|
725
|
+
(shows the first message: …)`); `/title TEXT` names the session (whitespace collapsed,
|
|
715
726
|
cut at 80 characters), `/title --clear` drops the name (anything after `--clear`
|
|
716
727
|
is refused with the usage line). It is saved at once,
|
|
717
728
|
mid-turn too, and the lists, cards, top bar and tab show it in place of the first
|
|
@@ -897,7 +908,8 @@ Bundler, `gem install commonmarker`):
|
|
|
897
908
|
chi web --web-markdown
|
|
898
909
|
```
|
|
899
910
|
|
|
900
|
-
|
|
911
|
+
`chi bootstrap` offers to turn it on (and install commonmarker) on a terminal
|
|
912
|
+
(see [First setup](#first-setup)). The setting also supports `SAMAGOTCHI_WEB_MARKDOWN=true` or the global config:
|
|
901
913
|
|
|
902
914
|
```yaml
|
|
903
915
|
web:
|
|
@@ -914,7 +926,11 @@ with markdown off), the same renderer and sanitizer. Generated HTML is
|
|
|
914
926
|
sanitized, raw HTML in model output is not trusted, and unsafe links are
|
|
915
927
|
removed. If Markdown is enabled without
|
|
916
928
|
commonmarker installed, Chi Web keeps the normal escaped-text display and shows a
|
|
917
|
-
warning explaining how to install the optional gem.
|
|
929
|
+
warning explaining how to install the optional gem. While Markdown is off, the
|
|
930
|
+
owner's session view shows a one-line tip saying how to turn it on (and, when
|
|
931
|
+
commonmarker isn't installed, how to install it; the text is the server's,
|
|
932
|
+
`markdown_tip` in the session payloads, never on a participant's page); its ×
|
|
933
|
+
hides it for that browser (`chi_markdown_tip_dismissed` in localStorage).
|
|
918
934
|
|
|
919
935
|
Every prompt and answer has a copy button (on hover; always shown, dimmed, on a
|
|
920
936
|
touch screen), and so does each code block of a rendered answer. An answer
|
data/docs/configuration.md
CHANGED
|
@@ -50,7 +50,8 @@ hosts:
|
|
|
50
50
|
# alias is applied, and its host:/the alias's host picks the host when host_ref
|
|
51
51
|
# is left out; a model naming no host goes where a bare --model goes (the
|
|
52
52
|
# default host). A model naming another host than host_ref turns recap off with
|
|
53
|
-
# a warning. recap: false turns it off.
|
|
53
|
+
# a warning. recap: false turns it off. Each recap also names the session with
|
|
54
|
+
# a short auto title (shown when it has no /title), in the same request.
|
|
54
55
|
recap:
|
|
55
56
|
# host_ref: small-box
|
|
56
57
|
# model: your-small-model-id
|
|
@@ -124,7 +124,8 @@ right-hand column happens.
|
|
|
124
124
|
only when the first participant joins and when the last leaves or expires: one history re-read each, then stable.
|
|
125
125
|
Under D3(a) the declared tools stay the session's, so an author switch costs nothing: the review boundary is a
|
|
126
126
|
gate on the calls, not a toolkit switch. The forged-header mark (`(quoted) `) is a pure function of the text too.
|
|
127
|
-
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt
|
|
127
|
+
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt (the recap's also
|
|
128
|
+
asks for its auto title line, `RecapPrompt::TITLE`: static text, still that one request). On a current
|
|
128
129
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
129
130
|
|
|
130
131
|
## The turn-end warm-up
|
data/docs/sessions.md
CHANGED
|
@@ -237,9 +237,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
237
237
|
|
|
238
238
|
## Session titles
|
|
239
239
|
|
|
240
|
-
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 -m "/title Coordinator" ID`; 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 -m "/title X"`) takes its preview from its first real message.
|
|
240
|
+
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 -m "/title Coordinator" ID`; 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 auto title: Parser tab handling)` or `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 -m "/title X"`) takes its preview from its first real message.
|
|
241
241
|
|
|
242
|
-
Without a title,
|
|
242
|
+
Without a title, a session is named by its **auto title** once its recap has one, else by its recap's first sentence (see [Session recap](cli.md#session-recap); its subject phrase stripped), else by its first message: on its card (which leaves out its recap line when the name is that same sentence), the families and chain rows, the top bar and the browser tab, the delete confirm and the search, the tab's and the phone's notifications, and `chi sessions list` (plain and `--live`; json has `auto_title`, tsv and json's `desc` keep the first message). The auto title is 2-6 words the recap's model writes on the line before the recap, in the same request (`Title: …`, split off by `RecapTitle`; the recap text never holds it). It is saved in the session's `recap.json` (`title`), so it goes when a stale recap is dropped; each later recap is shown the earlier title and keeps it unless the focus moved, and a reply without a usable one (not 2-6 words, or naming the user or the assistant) keeps the earlier title. A hand title always wins and is never overwritten. With the recap off no new auto title is written; a saved one still shows. The auto title is for display only: never in a model request (the `list_sessions` tool, `ctx.sessions`, a parent's children, `chi broadcast` keep the title, else the first message), nor on the terminal's status row, nor on a participant's page (the title or the review's subjects).
|
|
243
243
|
|
|
244
244
|
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 terminal's status row too (the REPL's and an attached one's, first, see [Status Line](cli.md#status-line)). In the web, `✎` (Rename this session) sits next to the session's title, on its active strip card or after the top bar's title: a small input over the title, prefilled with it; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent, and so does the title leaving the screen (All sessions opened, the card swiped out of a phone's strip). A session a chi REPL owns has no `✎`, nor one that shows no title yet (`/title` still works from the composer). 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.
|
|
245
245
|
|
|
@@ -257,7 +257,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
257
257
|
- **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.
|
|
258
258
|
- **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.
|
|
259
259
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 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`.
|
|
260
|
-
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a 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 with a recap of its own reads its recap's first sentence instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "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.
|
|
260
|
+
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a 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 with a recap of its own reads its auto title, else its recap's first sentence, instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "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.
|
|
261
261
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
262
262
|
- **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.
|
|
263
263
|
|
|
@@ -5,6 +5,7 @@ require_relative "config"
|
|
|
5
5
|
require_relative "model_profile"
|
|
6
6
|
require_relative "host_registry"
|
|
7
7
|
require_relative "host_keys"
|
|
8
|
+
require_relative "gem_update"
|
|
8
9
|
require_relative "bootstrap/probe"
|
|
9
10
|
require_relative "bootstrap/config_writer"
|
|
10
11
|
require_relative "bootstrap/bundles"
|
|
@@ -18,7 +19,8 @@ module Samagotchi
|
|
|
18
19
|
# Anthropic's Messages API, Bootstrap::Probe), picks the model, sends one
|
|
19
20
|
# test request and writes config.yml: a fresh file, or a hosts: entry
|
|
20
21
|
# added to an existing one. Then it installs the
|
|
21
|
-
# system bundle and the core profile, and on a terminal asks about dev
|
|
22
|
+
# system bundle and the core profile, and on a terminal asks about dev
|
|
23
|
+
# and Markdown in chi web (installing commonmarker when it is missing).
|
|
22
24
|
# Prompts only on a terminal; a script gets the list and exit 2 instead.
|
|
23
25
|
class BootstrapCommand
|
|
24
26
|
include CLI::Command
|
|
@@ -53,7 +55,7 @@ module Samagotchi
|
|
|
53
55
|
entry added (after a backup), and its other lines stay as they are.
|
|
54
56
|
Then it installs the system bundle and the core bundles (loop-guard,
|
|
55
57
|
check-in, guardrails, slow-commands, agents-md), and on a terminal
|
|
56
|
-
offers the dev bundles.
|
|
58
|
+
offers the dev bundles and Markdown in chi web.
|
|
57
59
|
TEXT
|
|
58
60
|
|
|
59
61
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
@@ -75,13 +77,18 @@ module Samagotchi
|
|
|
75
77
|
DEV = "dev"
|
|
76
78
|
# The config outcomes after which the bundles are installed.
|
|
77
79
|
INSTALLS_BUNDLES = %i[new appended exists snippet].freeze
|
|
80
|
+
# The optional gem chi web renders Markdown with (web.markdown).
|
|
81
|
+
MARKDOWN_GEM = "commonmarker"
|
|
82
|
+
MARKDOWN_HINT = "answers stay plain text — gem install #{MARKDOWN_GEM}, then chi web --web-markdown".freeze
|
|
78
83
|
|
|
79
84
|
# @param argv [Array<String>] the arguments after "bootstrap"
|
|
80
85
|
# @param probe [Bootstrap::Probe, nil] (specs)
|
|
81
86
|
# @param bundles [Bootstrap::Bundles, nil] (specs: the bundles go where
|
|
82
87
|
# the process ENV points, not +env+)
|
|
88
|
+
# @param gem_runner [#run] runs `gem install commonmarker` (GemUpdate::Runner)
|
|
89
|
+
# @param gem_present [#call, nil] → whether commonmarker is installed
|
|
83
90
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
|
|
84
|
-
platform: RbConfig::CONFIG["host_os"], bundles: nil)
|
|
91
|
+
platform: RbConfig::CONFIG["host_os"], bundles: nil, gem_runner: GemUpdate::Runner.new, gem_present: nil)
|
|
85
92
|
@argv = argv.dup
|
|
86
93
|
@stdin = stdin
|
|
87
94
|
@stdout = stdout
|
|
@@ -91,6 +98,9 @@ module Samagotchi
|
|
|
91
98
|
@config_path = config_path || ConfigFile.global_path(env: env)
|
|
92
99
|
@platform = platform
|
|
93
100
|
@bundles = bundles || Bootstrap::Bundles.new
|
|
101
|
+
@gem_runner = gem_runner
|
|
102
|
+
# Installed, without loading its native extension into this process.
|
|
103
|
+
@gem_present = gem_present || -> { Gem::Specification.find_all_by_name(MARKDOWN_GEM).any? }
|
|
94
104
|
end
|
|
95
105
|
|
|
96
106
|
# @return [Integer] 0 written (or already there), 1 failed (a failed
|
|
@@ -358,7 +368,8 @@ module Samagotchi
|
|
|
358
368
|
end
|
|
359
369
|
|
|
360
370
|
# The system bundle, core, and dev when a terminal says yes: one line
|
|
361
|
-
# each. 1 when
|
|
371
|
+
# each. Then Markdown in chi web (#offer_markdown). 1 when a bundle
|
|
372
|
+
# failed; the Markdown step never changes it.
|
|
362
373
|
def install_bundles(dry_run: false)
|
|
363
374
|
ok = system_bundle_line(@bundles.sync_system(dry_run: dry_run), dry_run)
|
|
364
375
|
ok = install_profile(CORE, dry_run) && ok
|
|
@@ -366,9 +377,61 @@ module Samagotchi
|
|
|
366
377
|
@stdout.print("Also install #{DEV} (#{members.join(", ")})? [y/N] ")
|
|
367
378
|
ok = install_profile(DEV, false) && ok if @stdin.gets.to_s.strip.downcase.start_with?("y")
|
|
368
379
|
end
|
|
380
|
+
offer_markdown(dry_run)
|
|
369
381
|
ok ? 0 : 1
|
|
370
382
|
end
|
|
371
383
|
|
|
384
|
+
# On a terminal, unless config.yml has web.markdown on already: ask, and
|
|
385
|
+
# on yes install commonmarker when it is missing, then write
|
|
386
|
+
# web.markdown: true. A closed stdin is no, whatever the default.
|
|
387
|
+
# Sets @markdown_on for #next_steps.
|
|
388
|
+
def offer_markdown(dry_run)
|
|
389
|
+
@markdown_on = markdown_on?
|
|
390
|
+
return if @markdown_on || !tty?
|
|
391
|
+
return @stdout.puts("web: would ask about Markdown in chi web") if dry_run
|
|
392
|
+
|
|
393
|
+
@stdout.print("Render answers as Markdown in chi web? [Y/n] ")
|
|
394
|
+
answer = @stdin.gets
|
|
395
|
+
return if answer.nil?
|
|
396
|
+
|
|
397
|
+
answer = answer.strip.downcase
|
|
398
|
+
return unless answer.empty? || answer.start_with?("y")
|
|
399
|
+
|
|
400
|
+
@markdown_on = install_markdown_gem && write_markdown
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Whether @config_path (not the process's Config) has web.markdown on.
|
|
404
|
+
def markdown_on?
|
|
405
|
+
data = ConfigFile.read_yaml(path: @config_path)
|
|
406
|
+
raw = data["web"]["markdown"] if data.is_a?(Hash) && data["web"].is_a?(Hash)
|
|
407
|
+
Config.coerce!(Config.find_by_key("web.markdown"), raw) == true
|
|
408
|
+
rescue SessionConfig::Error
|
|
409
|
+
false
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# true when commonmarker is there or `gem install` (this Ruby's own gem
|
|
413
|
+
# command) put it there; else one line with gem's last word on it.
|
|
414
|
+
def install_markdown_gem
|
|
415
|
+
return true if @gem_present.call
|
|
416
|
+
|
|
417
|
+
@stdout.puts("#{MARKDOWN_GEM}: installing…")
|
|
418
|
+
output, ok = @gem_runner.run([GemUpdate.gem_bin, "install", MARKDOWN_GEM, "--no-document"])
|
|
419
|
+
return true if ok
|
|
420
|
+
|
|
421
|
+
last = output.to_s.lines.map(&:strip).reject(&:empty?).last || "no output"
|
|
422
|
+
@stdout.puts("#{MARKDOWN_GEM}: install failed (#{last}); #{MARKDOWN_HINT}")
|
|
423
|
+
false
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def write_markdown
|
|
427
|
+
ConfigFile.write_setting!("web.markdown", true, path: @config_path)
|
|
428
|
+
@stdout.puts("web: markdown on (config.yml)")
|
|
429
|
+
true
|
|
430
|
+
rescue StandardError => e
|
|
431
|
+
@stdout.puts("web: markdown not written (#{e.message.lines.first.to_s.strip}); #{MARKDOWN_HINT}")
|
|
432
|
+
false
|
|
433
|
+
end
|
|
434
|
+
|
|
372
435
|
# One line for the profile; false when it (or a member) failed.
|
|
373
436
|
def install_profile(name, dry_run)
|
|
374
437
|
profile_line(@bundles.install(name, dry_run: dry_run), dry_run)
|
|
@@ -477,7 +540,9 @@ module Samagotchi
|
|
|
477
540
|
def next_steps
|
|
478
541
|
dev = dev_to_install
|
|
479
542
|
bundles = dev.empty? ? ["chi bundle list", "the installed bundles"] : ["chi bundle install #{DEV}", "more bundles (#{dev.join(", ")})"]
|
|
480
|
-
lines = [["chi", "a session"], ["chi web --open", "the web UI"]
|
|
543
|
+
lines = [["chi", "a session"], ["chi web --open", "the web UI"]]
|
|
544
|
+
lines << ["chi web --web-markdown", "Markdown answers#{" (needs gem install #{MARKDOWN_GEM})" unless @gem_present.call}"] unless @markdown_on
|
|
545
|
+
lines << bundles
|
|
481
546
|
lines << ["chi desktop install", "macOS \"Send to chi\" helper"] if @platform.to_s.include?("darwin")
|
|
482
547
|
lines.each_with_index do |(command, what), i|
|
|
483
548
|
@stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(22)} # #{what}")
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -1245,6 +1245,30 @@ module Samagotchi
|
|
|
1245
1245
|
true
|
|
1246
1246
|
end
|
|
1247
1247
|
|
|
1248
|
+
# Sets one registry key (a Config::Entry with a section.key yaml_path,
|
|
1249
|
+
# e.g. "web.markdown") in config.yml, its value typed as the entry
|
|
1250
|
+
# reads it (true is written bare); the rest of the file, comments
|
|
1251
|
+
# included, stays as written (ConfigTextEdit). No Config.reload!: a
|
|
1252
|
+
# caller reading Config.get afterwards reloads itself.
|
|
1253
|
+
# @raise [ArgumentError] an unknown key, or one config.yml doesn't hold
|
|
1254
|
+
# as section.key
|
|
1255
|
+
# @raise [SessionConfig::Error] a value the entry's type doesn't take
|
|
1256
|
+
def write_setting!(key, value, env: ENV, path: global_path(env: env))
|
|
1257
|
+
entry = Config.find_by_key(key)
|
|
1258
|
+
raise ArgumentError, "unknown config key: #{key}" unless entry
|
|
1259
|
+
raise ArgumentError, "#{key} isn't a section.key setting of config.yml" unless entry.config_exposed? && entry.yaml_path.size == 2
|
|
1260
|
+
|
|
1261
|
+
typed = Config.coerce!(entry, value)
|
|
1262
|
+
raise ArgumentError, "#{key} needs a value" if typed.nil?
|
|
1263
|
+
|
|
1264
|
+
section, leaf = entry.yaml_path
|
|
1265
|
+
expected = deep_copy(read_yaml(env: env, path: path) || {})
|
|
1266
|
+
expected[section] = {} unless expected[section].is_a?(Hash)
|
|
1267
|
+
expected[section][leaf] = typed
|
|
1268
|
+
write_config_key(path, section, leaf, typed, expected)
|
|
1269
|
+
typed
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1248
1272
|
def model_aliases(env: ENV, path: global_path(env: env))
|
|
1249
1273
|
data = read_yaml(env: env, path: path)
|
|
1250
1274
|
return {} unless data.is_a?(Hash)
|
|
@@ -25,7 +25,8 @@ module Samagotchi
|
|
|
25
25
|
# @param text [String, nil] the file's text (nil: no file)
|
|
26
26
|
# @param section [String] a top-level key ("default")
|
|
27
27
|
# @param key [String] the key under it; an existing key matches in any case
|
|
28
|
-
# @param value [String] the value
|
|
28
|
+
# @param value [String, Integer, Boolean] the value: a string quoted, an
|
|
29
|
+
# Integer or true/false bare (as the file's types read them back)
|
|
29
30
|
# @param expected [Hash] the whole file's data after the change
|
|
30
31
|
# @return [String] the new text
|
|
31
32
|
def set(text, section:, key:, value:, expected:)
|
|
@@ -77,7 +78,13 @@ module Samagotchi
|
|
|
77
78
|
line[/\A\s*(["']?)([^"':#\s][^"':#]*?)\1:(?:\s|\z)/, 2]
|
|
78
79
|
end
|
|
79
80
|
|
|
80
|
-
# JSON's quoting is valid YAML: model ids hold "/" and ":".
|
|
81
|
-
|
|
81
|
+
# JSON's quoting is valid YAML: model ids hold "/" and ":". true, false
|
|
82
|
+
# and Integers go bare, so they read back as themselves, not as text.
|
|
83
|
+
def scalar(value)
|
|
84
|
+
case value
|
|
85
|
+
when true, false, Integer then value.to_s
|
|
86
|
+
else JSON.generate(value.to_s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
82
89
|
end
|
|
83
90
|
end
|
|
@@ -39,8 +39,12 @@ module Samagotchi
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# This Ruby's own gem command (chi bootstrap installs commonmarker with
|
|
43
|
+
# it too).
|
|
44
|
+
def self.gem_bin = File.join(RbConfig::CONFIG["bindir"], "gem")
|
|
45
|
+
|
|
42
46
|
# @param fetcher [#call, nil] → the latest.json body (specs)
|
|
43
|
-
def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin:
|
|
47
|
+
def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin)
|
|
44
48
|
@env = env
|
|
45
49
|
@fetcher = fetcher || method(:fetch)
|
|
46
50
|
@runner = runner
|
|
@@ -11,6 +11,7 @@ require_relative "image_store"
|
|
|
11
11
|
require_relative "output_formatter"
|
|
12
12
|
require_relative "steer"
|
|
13
13
|
require_relative "recap_store"
|
|
14
|
+
require_relative "recap_title"
|
|
14
15
|
require_relative "log"
|
|
15
16
|
|
|
16
17
|
module Samagotchi
|
|
@@ -143,8 +144,17 @@ module Samagotchi
|
|
|
143
144
|
# user decisions) — tolerated. The "we" variant was 4/4 consistent.
|
|
144
145
|
# %<plain>s is the range, e.g. "2-4 plain sentences"; %<shape>s is SHAPE
|
|
145
146
|
# or ONE_SHAPE.
|
|
147
|
+
# Auto-title spike (eval 0004, 2026-10-25; local Qwen3.8-Flash-Next,
|
|
148
|
+
# the 4 recap-spike sessions, first recap + update, N=5): a first-line
|
|
149
|
+
# title parsed in 50/50, 2-6 words and no actor in all, no recap
|
|
150
|
+
# regression against "Reply with the recap only" (one reply echoed
|
|
151
|
+
# "Blank line follows title." before the recap: RecapTitle drops it).
|
|
152
|
+
# Asked last, it was there in 15/50 and 4 replies were the title alone.
|
|
153
|
+
# A quoted example title (probe p0) was copied as the title: none here.
|
|
154
|
+
TITLE = "Reply with a title line, a blank line, then the recap. The title line is \"#{RecapTitle::LABEL} \" " \
|
|
155
|
+
"followed by 2-6 words naming the task, no actors. The recap: "
|
|
146
156
|
SYSTEM = "You write short recaps of a chat between a user and an assistant, for the user " \
|
|
147
|
-
"coming back to it later.
|
|
157
|
+
"coming back to it later. #{TITLE}%<plain>s, no heading, " \
|
|
148
158
|
"no preamble, no notes about the task or the transcript. Do not mention what the " \
|
|
149
159
|
"transcript lacks or does not say; leave it out. Centre it on outcomes, not on " \
|
|
150
160
|
"the order of events. %<shape>s Keep project names " \
|
|
@@ -169,6 +179,10 @@ module Samagotchi
|
|
|
169
179
|
UPDATE = " You are given the earlier recap and the conversation since the earlier recap: " \
|
|
170
180
|
"write an updated recap of the whole session that also covers what happened since " \
|
|
171
181
|
"(do not just repeat the earlier recap)."
|
|
182
|
+
# With an earlier title (the user message's "Earlier title:" line): the
|
|
183
|
+
# spike kept it in 14/15 updates on the same task and changed it on 4/5
|
|
184
|
+
# where the focus moved, so a card doesn't rename at every idle window.
|
|
185
|
+
UPDATE_TITLE = " Keep the earlier title unless the focus moved."
|
|
172
186
|
# Holds the length once the recap covers more (S0: an updated recap ran
|
|
173
187
|
# to 6 sentences for 2-4 without it, within range+1 in 95%+ with it).
|
|
174
188
|
# The first-sentence line keeps the list preview on the task.
|
|
@@ -210,7 +224,9 @@ module Samagotchi
|
|
|
210
224
|
# transcript to summarize
|
|
211
225
|
# @param sentences [Array(Integer, Integer)] the range, from #sentences_range
|
|
212
226
|
# @param offer [Boolean] a continue offer is open: add OFFER_LINE
|
|
213
|
-
|
|
227
|
+
# @param previous_title [String, nil] the earlier recap's title (with +previous+)
|
|
228
|
+
def build(transcript, tool_count: nil, tool_names: [], previous: nil, previous_title: nil, sentences: DEFAULT_SENTENCES,
|
|
229
|
+
offer: false)
|
|
214
230
|
body = cap(transcript.to_s.strip)
|
|
215
231
|
return nil if body.empty?
|
|
216
232
|
|
|
@@ -227,8 +243,11 @@ module Samagotchi
|
|
|
227
243
|
one = sentences.last == 1
|
|
228
244
|
range = { plain: sentences_text(sentences, "plain "), sentences: sentences_text(sentences),
|
|
229
245
|
shape: one ? ONE_SHAPE : SHAPE, focus: one ? "it" : "the first sentence" }
|
|
230
|
-
|
|
246
|
+
earlier_title = previous && !previous_title.to_s.strip.empty? ? previous_title.to_s.strip : nil
|
|
247
|
+
update = "#{UPDATE}#{UPDATE_TITLE if earlier_title}#{format(UPDATE_LENGTH, range)}"
|
|
248
|
+
system = format(SYSTEM, range) + (previous ? update : "") + tools
|
|
231
249
|
user = +""
|
|
250
|
+
user << "Earlier title: #{earlier_title}\n\n" if earlier_title
|
|
232
251
|
user << "Earlier recap:\n#{previous}\n\n" if previous
|
|
233
252
|
user << "Transcript#{" since the earlier recap" if previous} (the system prompt and tool " \
|
|
234
253
|
"internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
|
|
@@ -269,8 +288,9 @@ module Samagotchi
|
|
|
269
288
|
# answer (the Worker's or the REPL's TurnFlow); read at each attempt
|
|
270
289
|
attr_writer :awaiting_continue
|
|
271
290
|
|
|
272
|
-
# @return [Hash, nil] the last recap written: {text:, covered:,
|
|
273
|
-
# covered_digest:, model:, created_at:}
|
|
291
|
+
# @return [Hash, nil] the last recap written: {text:, title:, covered:,
|
|
292
|
+
# covered_digest:, model:, created_at:} (title: the auto title, absent
|
|
293
|
+
# when no reply gave one), where covered counts the
|
|
274
294
|
# session messages it summarizes and covered_digest fingerprints the
|
|
275
295
|
# last of them. With a store, loaded from it for each new session.
|
|
276
296
|
def state
|
|
@@ -467,12 +487,13 @@ module Samagotchi
|
|
|
467
487
|
return :nothing_new if transcript.strip.empty?
|
|
468
488
|
|
|
469
489
|
prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
|
|
470
|
-
|
|
490
|
+
previous_title: previous&.dig(:title), sentences: @sentences, offer: offer_open?)
|
|
471
491
|
return :nothing_new if prompt.nil?
|
|
472
492
|
|
|
473
493
|
asked = target
|
|
474
494
|
@in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
|
|
475
|
-
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label
|
|
495
|
+
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label,
|
|
496
|
+
previous_title: previous&.dig(:title) }
|
|
476
497
|
:started
|
|
477
498
|
rescue StandardError
|
|
478
499
|
:failed
|
|
@@ -499,9 +520,16 @@ module Samagotchi
|
|
|
499
520
|
# An IdleClient::Summary names the model that answered; a plain String
|
|
500
521
|
# (a stubbed client) doesn't, and the asked label stands in.
|
|
501
522
|
served = recap.model if recap.respond_to?(:model)
|
|
502
|
-
text
|
|
503
|
-
|
|
504
|
-
|
|
523
|
+
# The title line off the recap (RecapTitle): the text is the recap
|
|
524
|
+
# alone. A reply without a title that passes keeps the earlier one, so
|
|
525
|
+
# a broken line doesn't blank the card; a title alone is no recap.
|
|
526
|
+
split = RecapTitle.split(recap.to_s)
|
|
527
|
+
text = split.body
|
|
528
|
+
return if text.empty?
|
|
529
|
+
|
|
530
|
+
title = split.title || job[:previous_title]
|
|
531
|
+
saved = { text: text, title: title, covered: job[:covered], covered_digest: job[:covered_digest],
|
|
532
|
+
model: served || job[:model], created_at: Time.now.utc.iso8601 }.compact
|
|
505
533
|
@mutex.synchronize { @state = saved }
|
|
506
534
|
save(saved)
|
|
507
535
|
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered], created_at: saved[:created_at])
|
|
@@ -8,7 +8,9 @@ require_relative "session"
|
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# The idle recap saved with its session, in <session dir>/recap.json:
|
|
11
|
-
# {text, covered, covered_digest, model, created_at}
|
|
11
|
+
# {text, title, covered, covered_digest, model, created_at}
|
|
12
|
+
# (title: the auto title the recap reply led with, RecapTitle; absent in
|
|
13
|
+
# a recap saved before it, or when no reply gave one).
|
|
12
14
|
# A separate file, so it never races the session-file save at the end of a
|
|
13
15
|
# turn. Follows the Engine's current session (the REPL can switch).
|
|
14
16
|
class RecapStore
|
|
@@ -77,11 +79,26 @@ module Samagotchi
|
|
|
77
79
|
nil
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# What a session card or list row reads from the saved recap.
|
|
83
|
+
# +preview+: #preview's sentence; +title+: the auto title, or nil.
|
|
84
|
+
Card = Data.define(:preview, :title)
|
|
85
|
+
|
|
86
|
+
# @return [Card] the saved recap's preview and auto title, from one
|
|
87
|
+
# read (both nil when there is no recap)
|
|
88
|
+
def self.card(session_dir)
|
|
89
|
+
data = read(session_dir)
|
|
90
|
+
title = data&.dig(:title).to_s.gsub(/\s+/, " ").strip
|
|
91
|
+
Card.new(preview: preview_of(data), title: title.empty? ? nil : title)
|
|
92
|
+
end
|
|
93
|
+
|
|
80
94
|
# @return [String, nil] the first sentence of the recap saved in
|
|
81
95
|
# +session_dir+, on one line, without the subject phrase (SUBJECT_RE)
|
|
82
96
|
# and cut to PREVIEW_CHARS. The full recap keeps its sentence.
|
|
83
|
-
def self.preview(session_dir)
|
|
84
|
-
|
|
97
|
+
def self.preview(session_dir) = preview_of(read(session_dir))
|
|
98
|
+
|
|
99
|
+
# #preview of a recap already read (RecapStore.read's hash, or nil).
|
|
100
|
+
def self.preview_of(data)
|
|
101
|
+
text = data&.dig(:text).to_s.gsub(/\s+/, " ").strip
|
|
85
102
|
return nil if text.empty?
|
|
86
103
|
|
|
87
104
|
first = text[/\A.*?[.!?](?=\s|\z)/] || text
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The auto title the recap reply leads with (IdleRecap::RecapPrompt::TITLE):
|
|
5
|
+
# "Title: <2-6 words>", a blank line, then the recap. #split takes the
|
|
6
|
+
# title line off, so the recap text the bubble, /recap, a continue's carry
|
|
7
|
+
# note and the next "Earlier recap:" read never holds it. A title line
|
|
8
|
+
# last is taken too (the spike's last-line arm put it there; a model may
|
|
9
|
+
# drift). A title that isn't 2-6 words or names an actor is no title, but
|
|
10
|
+
# its line still goes. Never raises.
|
|
11
|
+
module RecapTitle
|
|
12
|
+
LABEL = "Title:"
|
|
13
|
+
# As long as a card's title shows on a phone without wrapping twice.
|
|
14
|
+
MAX_CHARS = 60
|
|
15
|
+
WORDS = (2..6)
|
|
16
|
+
# "Title: Foo", "**Title:** Foo", "## Title: Foo" (a colon only: a
|
|
17
|
+
# recap may open "Title-casing the headings…").
|
|
18
|
+
LINE_RE = /\A[\s*_#>]*title[\s*_]*:[\s*_]*(?<title>.*)\z/i
|
|
19
|
+
# An instruction echoed between the title and the recap (the spike's
|
|
20
|
+
# "Blank line follows title."), or "(blank line)": dropped.
|
|
21
|
+
ECHO_RE = /\A[\s*_(\[]*(?:a |the )?blank line\b[^\n]{0,40}\z/i
|
|
22
|
+
# A "Recap:" label before the recap's first sentence: dropped, the
|
|
23
|
+
# sentence kept.
|
|
24
|
+
RECAP_LABEL_RE = /\A[\s*_#]*recap[\s*_]*:[\s*_]*/i
|
|
25
|
+
# The recap prompt's actors (RecapPrompt::SYSTEM: no "the user", no "the
|
|
26
|
+
# assistant", no "I", no "we"). A bare "User" or "Chi" stays: "User
|
|
27
|
+
# settings page", "Chi web push devices" name things.
|
|
28
|
+
ACTOR_RE = /\A(?:the\s+(?:user|assistant)(?:'s)?|(?:user|assistant)'s|i|we|you)(?=\s|\z)/i
|
|
29
|
+
QUOTES = "\"'`“”‘’«»"
|
|
30
|
+
|
|
31
|
+
# A reply split in two: +title+ nil when it has none (or none that
|
|
32
|
+
# passes), +body+ the recap without the title line.
|
|
33
|
+
Split = Data.define(:title, :body)
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# @param text [String] the recap model's reply
|
|
38
|
+
# @return [Split]
|
|
39
|
+
def split(text)
|
|
40
|
+
lines = text.to_s.strip.lines(chomp: true)
|
|
41
|
+
if lines.first && LINE_RE.match?(lines.first)
|
|
42
|
+
title = lines.shift[LINE_RE, :title]
|
|
43
|
+
lines = lead_cleaned(lines)
|
|
44
|
+
elsif lines.size > 1 && LINE_RE.match?(lines.last)
|
|
45
|
+
title = lines.pop[LINE_RE, :title]
|
|
46
|
+
else
|
|
47
|
+
return Split.new(title: nil, body: text.to_s.strip)
|
|
48
|
+
end
|
|
49
|
+
Split.new(title: clean(title), body: lines.join("\n").strip)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
Split.new(title: nil, body: text.to_s.strip)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The title as a card shows it: markup, quotes and a closing period
|
|
55
|
+
# gone, one line, cut at MAX_CHARS; nil when it isn't 2-6 words or
|
|
56
|
+
# starts with an actor.
|
|
57
|
+
def clean(raw)
|
|
58
|
+
title = raw.to_s.gsub(/[*_#]+/, " ").gsub(/\s+/, " ").strip
|
|
59
|
+
title = title.delete_prefix(title[0]).delete_suffix(title[-1]).strip while quoted?(title)
|
|
60
|
+
title = title.sub(/[.。]+\z/, "").strip
|
|
61
|
+
return nil unless WORDS.cover?(title.split.size)
|
|
62
|
+
return nil if ACTOR_RE.match?(title)
|
|
63
|
+
|
|
64
|
+
title.length > MAX_CHARS ? "#{title[0, MAX_CHARS - 1].rstrip}…" : title
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quoted?(title) = title.length > 1 && QUOTES.include?(title[0]) && QUOTES.include?(title[-1])
|
|
68
|
+
|
|
69
|
+
# Blank lines, echoed instructions and a "Recap:" label before the recap.
|
|
70
|
+
def lead_cleaned(lines)
|
|
71
|
+
lines = lines.drop_while { |line| line.strip.empty? || ECHO_RE.match?(line) }
|
|
72
|
+
return lines if lines.empty?
|
|
73
|
+
|
|
74
|
+
first = lines.first.sub(RECAP_LABEL_RE, "")
|
|
75
|
+
first.strip.empty? ? lead_cleaned(lines.drop(1)) : [first, *lines.drop(1)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -308,12 +308,13 @@ module Samagotchi
|
|
|
308
308
|
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
309
309
|
# @param include_archived [Boolean] archived sessions too
|
|
310
310
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
311
|
-
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
311
|
+
# updated_at:, status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
|
|
312
312
|
# parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
313
313
|
# waiting_id:, stopped_by:}; busy =
|
|
314
314
|
# live with a turn running, delegate = started by the delegate tool
|
|
315
315
|
# (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
|
|
316
|
-
# previous link of its chain, Session#continues), recap = the saved recap's first sentence,
|
|
316
|
+
# previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
|
|
317
|
+
# the auto title its reply led with (RecapTitle) or nil, project =
|
|
317
318
|
# Session#project_root, waiting = the kind of question it waits on
|
|
318
319
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
319
320
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
@@ -339,7 +340,7 @@ module Samagotchi
|
|
|
339
340
|
|
|
340
341
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
|
|
341
342
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
342
|
-
owner: owner&.kind,
|
|
343
|
+
owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
|
|
343
344
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
344
345
|
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
345
346
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
|
|
@@ -478,6 +479,12 @@ module Samagotchi
|
|
|
478
479
|
cut(desc, SUMMARY_DESC_LIMIT)
|
|
479
480
|
end
|
|
480
481
|
|
|
482
|
+
# A session row's recap: and auto_title: (RecapStore.card, one read).
|
|
483
|
+
private_class_method def self.recap_fields(session_dir)
|
|
484
|
+
card = RecapStore.card(session_dir)
|
|
485
|
+
{ recap: card.preview, auto_title: card.title }
|
|
486
|
+
end
|
|
487
|
+
|
|
481
488
|
private_class_method def self.summary_preview(session)
|
|
482
489
|
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
483
490
|
end
|
|
@@ -217,12 +217,13 @@ module Samagotchi
|
|
|
217
217
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
218
218
|
# leftover one at the next sweep)
|
|
219
219
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
220
|
-
# title: the session's own name (/title; desc shows it too), or nil
|
|
220
|
+
# title: the session's own name (/title; desc shows it too), or nil;
|
|
221
|
+
# auto_title: the one its recap gave it (RecapTitle), or nil
|
|
221
222
|
# waiting: the kind of question it waits on (question, approval,
|
|
222
223
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
223
224
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
224
225
|
# id) whose card it also waits in (the approval relay), or nil
|
|
225
|
-
keys = %i[id short_id desc title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
226
|
+
keys = %i[id short_id desc title auto_title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
226
227
|
waiting waiting_id relayed_to stopped_by]
|
|
227
228
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
228
229
|
when "tsv"
|
|
@@ -234,12 +235,13 @@ module Samagotchi
|
|
|
234
235
|
elsif summary[:busy] then "running"
|
|
235
236
|
else summary[:live] ? "live" : summary[:status].to_s
|
|
236
237
|
end
|
|
237
|
-
# The
|
|
238
|
-
# (text only: tsv and json keep desc for pickers);
|
|
239
|
-
# desc) says what the user named it.
|
|
238
|
+
# The recap's auto title, else its first sentence, says more than
|
|
239
|
+
# the last prompt (text only: tsv and json keep desc for pickers);
|
|
240
|
+
# a title (in desc) says what the user named it.
|
|
240
241
|
desc = summary[:desc]
|
|
241
|
-
|
|
242
|
-
|
|
242
|
+
named = summary[:auto_title] || summary[:recap]
|
|
243
|
+
if named && !summary[:title]
|
|
244
|
+
desc = [File.basename(summary[:cwd].to_s), named].reject(&:empty?).join(" · ")
|
|
243
245
|
desc = "#{desc[0, 59]}…" if desc.length > 60
|
|
244
246
|
end
|
|
245
247
|
# A delegated session points at its parent.
|
|
@@ -265,15 +267,16 @@ module Samagotchi
|
|
|
265
267
|
sort ||= "updated_at"
|
|
266
268
|
order ||= "desc"
|
|
267
269
|
sessions = @access.list(sort: sort, order: order, limit: limit, project_root: project, include_archived: include_archived)
|
|
268
|
-
# The session's title, else the
|
|
269
|
-
# last prompt (or the first message, before any
|
|
270
|
+
# The session's title, else the recap's auto title, else its first
|
|
271
|
+
# sentence, else the last prompt (or the first message, before any
|
|
272
|
+
# turn), cut as before.
|
|
270
273
|
# These rows read each session's folder here (its recap, saved
|
|
271
274
|
# context, owner): machine-local, the server's rows remotely (P2's
|
|
272
275
|
# list parity).
|
|
273
276
|
state_dir = Samagotchi::Session.default_state_dir
|
|
274
277
|
list_text = lambda do |s|
|
|
275
|
-
|
|
276
|
-
(
|
|
278
|
+
card = s.title ? nil : Samagotchi::RecapStore.card(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
279
|
+
(card&.title || card&.preview || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
277
280
|
end
|
|
278
281
|
# How full the context was after the last turn: "ctx 12%", counted
|
|
279
282
|
# against the session's llm_context budget when one is set and smaller
|
|
@@ -48,10 +48,15 @@ module Samagotchi
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
# "title: Coordinator",
|
|
51
|
+
# "title: Coordinator", "title: none (shows the auto title: Parser tab
|
|
52
|
+
# handling)" (the recap's, RecapTitle), or "title: none (shows the
|
|
53
|
+
# first message: hey chi …)"
|
|
52
54
|
def listing(session)
|
|
53
55
|
return "title: #{session.title}" if session.title
|
|
54
56
|
|
|
57
|
+
auto = @engine.recap&.state&.dig(:title)
|
|
58
|
+
return "title: none (shows the auto title: #{auto})" if auto
|
|
59
|
+
|
|
55
60
|
preview = session.first_preview.to_s
|
|
56
61
|
"title: none (shows #{preview.empty? ? "the first message" : "the first message: #{preview}"})"
|
|
57
62
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Samagotchi
|
|
|
7
7
|
module Web
|
|
8
8
|
class MarkdownRenderer
|
|
9
9
|
WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
|
|
10
|
+
# The session view's tip while rendering is off (#tip): how to turn it
|
|
11
|
+
# on, the gem first when it isn't installed. Each fits two lines on a
|
|
12
|
+
# phone (390px), hence the short lead and the gem one without config.yml.
|
|
13
|
+
TIP = "Markdown answers: restart chi web with --web-markdown (or web.markdown: true in config.yml)."
|
|
14
|
+
TIP_GEM = "Markdown answers: gem install commonmarker, then restart chi web with --web-markdown."
|
|
10
15
|
# NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
|
|
11
16
|
# emits classed <span>s; keeping it lets highlighting render.
|
|
12
17
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
@@ -29,6 +34,15 @@ module Samagotchi
|
|
|
29
34
|
WARNING if enabled? && !load_bundled_commonmarker!
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# The tip while rendering is off, nil when it is on. Whether the gem is
|
|
38
|
+
# installed is read from its gemspec, not by loading it: a server that
|
|
39
|
+
# won't render has no use for the native extension.
|
|
40
|
+
def tip
|
|
41
|
+
return nil if enabled?
|
|
42
|
+
|
|
43
|
+
commonmarker_installed? ? TIP : TIP_GEM
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
def render(markdown)
|
|
33
47
|
return nil unless available?
|
|
34
48
|
|
|
@@ -57,6 +71,12 @@ module Samagotchi
|
|
|
57
71
|
@commonmarker_available = false
|
|
58
72
|
end
|
|
59
73
|
|
|
74
|
+
def commonmarker_installed?
|
|
75
|
+
return @commonmarker_installed unless @commonmarker_installed.nil?
|
|
76
|
+
|
|
77
|
+
@commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def sanitize(html)
|
|
61
81
|
fragment = Nokogiri::HTML5.fragment(html)
|
|
62
82
|
fragment.traverse do |node|
|
|
@@ -99,6 +99,7 @@ import { createStartPage } from "./start_page.js";
|
|
|
99
99
|
import { createSessionActions } from "./session_actions.js";
|
|
100
100
|
import { createRename } from "./session_rename.js";
|
|
101
101
|
import { createSendTurn } from "./send_turn.js";
|
|
102
|
+
import { createMarkdownTip } from "./markdown_tip.js";
|
|
102
103
|
|
|
103
104
|
// A link's #code= (behind an auth proxy): traded for the cookie before
|
|
104
105
|
// anything else runs; the page reloads without it.
|
|
@@ -193,6 +194,8 @@ const startPage = createStartPage({
|
|
|
193
194
|
listModels,
|
|
194
195
|
});
|
|
195
196
|
const markdownWarningEl = $("#markdownWarning");
|
|
197
|
+
// While web.markdown is off: how to turn it on, until dismissed here.
|
|
198
|
+
const markdownTip = createMarkdownTip($("#markdownTip"));
|
|
196
199
|
// What the page renders that a notice may stand in for (the server's
|
|
197
200
|
// Web::Capabilities, next to markdown_warning); a notice it names isn't
|
|
198
201
|
// drawn (shownElsewhere). An older server sends none: every notice shows.
|
|
@@ -817,6 +820,7 @@ function clearSelection() {
|
|
|
817
820
|
setTurnRunning(false);
|
|
818
821
|
closeStream();
|
|
819
822
|
setMarkdownWarning(null);
|
|
823
|
+
markdownTip.show(null);
|
|
820
824
|
setCapabilities(null, null);
|
|
821
825
|
composerGrip.newVisit();
|
|
822
826
|
updateComposerMode();
|
|
@@ -944,6 +948,7 @@ async function resync({ endText = null } = {}) {
|
|
|
944
948
|
// then a pending question.
|
|
945
949
|
function renderSessionView(data, { kept = [] } = {}) {
|
|
946
950
|
setMarkdownWarning(data.markdown_warning);
|
|
951
|
+
markdownTip.show(data.markdown_tip);
|
|
947
952
|
setCapabilities(data.capabilities, data.editor_url);
|
|
948
953
|
// The saved fill (when there is one) goes in here too, so a reloaded or
|
|
949
954
|
// stopped session shows it before its next turn (a running turn's replay
|
|
@@ -1092,6 +1097,7 @@ async function readTurnEnd() {
|
|
|
1092
1097
|
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
1093
1098
|
liveTurn.timing.finishLine(line);
|
|
1094
1099
|
setMarkdownWarning(d.markdown_warning);
|
|
1100
|
+
markdownTip.show(d.markdown_tip);
|
|
1095
1101
|
setCapabilities(d.capabilities, d.editor_url);
|
|
1096
1102
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1097
1103
|
updateInfoBar();
|
|
@@ -109,6 +109,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
109
109
|
@keyframes init-spin{to{transform:rotate(360deg)}}
|
|
110
110
|
@media (prefers-reduced-motion:reduce){.init-spin{animation:none;border-top-color:var(--accent)}.init-task{transition:none}}
|
|
111
111
|
.markdown-warning{margin:0 16px 4px;padding:8px 10px;border:1px solid var(--warn);border-radius:8px;background:var(--warn-bg);color:var(--warn-bright);font-size:12px;line-height:1.4}
|
|
112
|
+
.markdown-tip{display:flex;align-items:flex-start;gap:8px;margin:0 16px 4px;padding:6px 6px 6px 10px;border:1px solid var(--accent-border);border-radius:8px;background:var(--accent-bg);color:var(--text-muted);font-size:12px;line-height:1.4}
|
|
113
|
+
.markdown-tip[hidden]{display:none}
|
|
114
|
+
.markdown-tip-text{flex:1;min-width:0}
|
|
115
|
+
.markdown-tip-close{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
|
|
116
|
+
.markdown-tip-close:hover{background:var(--bg-hover);color:var(--text)}
|
|
112
117
|
.bubble.user{border-left:3px solid var(--accent);background:var(--accent-bg);align-self:flex-end;max-width:85%;margin-left:auto;display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
113
118
|
.bubble.user.queued{border-left-style:dashed;opacity:.8}
|
|
114
119
|
.bubble.user.steered{border-left-style:dashed;border-left-color:var(--accent);opacity:.9}
|
|
@@ -1271,7 +1276,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1271
1276
|
/* Start page (no session): the composer alone, centered; no info bar. */
|
|
1272
1277
|
body:not(.has-session) #center{justify-content:safe center;background:transparent}
|
|
1273
1278
|
body:not(.has-session) #stage{flex:0 0 auto;min-height:0;background:transparent}
|
|
1274
|
-
body:not(.has-session) #history,body:not(.has-session) #markdownWarning{display:none}
|
|
1279
|
+
body:not(.has-session) #history,body:not(.has-session) #markdownWarning,body:not(.has-session) #markdownTip{display:none}
|
|
1275
1280
|
body:not(.has-session) #empty{position:static;background:transparent;padding:0}
|
|
1276
1281
|
body:not(.has-session) #dock{width:min(720px,calc(100% - 32px));margin:0 auto}
|
|
1277
1282
|
body:not(.has-session) #infoBar{display:none}
|
|
@@ -1488,7 +1493,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
1488
1493
|
#toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
1489
1494
|
#toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
|
|
1490
1495
|
#toast .toast-close:hover{color:var(--text)}
|
|
1491
|
-
|
|
1496
|
+
/* Each row is as tall as its tallest card. An `auto` row in a grid that overflows its
|
|
1497
|
+
height has no free space to grow into, so it collapses to each card's min-height. */
|
|
1498
|
+
#allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;grid-auto-rows:max-content;padding:2px}
|
|
1492
1499
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
1493
1500
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
1494
1501
|
.card.skeleton .line.long{width:78%}
|
|
@@ -1502,8 +1509,8 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
1502
1509
|
.card{padding:12px;border-radius:10px;background:var(--bg-panel);border:1px solid var(--border);cursor:pointer;display:flex;flex-direction:column;gap:6px;min-height:72px}
|
|
1503
1510
|
.card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
|
|
1504
1511
|
#allList .card{position:relative}
|
|
1505
|
-
/*
|
|
1506
|
-
|
|
1512
|
+
/* A card's lines are never squeezed to fit a short card: the flex side of the same overflow the rule above fixes
|
|
1513
|
+
(an overflowing grid collapsing its rows to min-height), seen on iOS as the title, recap and preview cut through. */
|
|
1507
1514
|
#allList .card > *{flex-shrink:0}
|
|
1508
1515
|
/* A card's tools in the all view: archive (or unarchive) and delete, shown on hover, always under hover:none. */
|
|
1509
1516
|
#allList .card-tools{position:absolute;top:6px;right:6px;display:flex;gap:2px}
|
|
@@ -1578,6 +1585,7 @@ a{color:var(--accent)}
|
|
|
1578
1585
|
<section id="center">
|
|
1579
1586
|
<div id="stage">
|
|
1580
1587
|
<div id="markdownWarning" class="markdown-warning" hidden></div>
|
|
1588
|
+
<div id="markdownTip" class="markdown-tip" hidden><span class="markdown-tip-text"></span><button type="button" class="markdown-tip-close" aria-label="Dismiss tip" title="Dismiss tip">×</button></div>
|
|
1581
1589
|
<div id="history"></div>
|
|
1582
1590
|
<div id="empty"></div>
|
|
1583
1591
|
<div id="notice" role="status" hidden></div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The session view's Markdown tip (the server's Web::MarkdownRenderer#tip,
|
|
2
|
+
// sent as markdown_tip while web.markdown is off): one calm line with a ×
|
|
3
|
+
// that hides it for this browser. The text is the server's; an older server
|
|
4
|
+
// sends none, so no tip.
|
|
5
|
+
import { readStored, writeStored } from "./storage.js";
|
|
6
|
+
|
|
7
|
+
export const TIP_DISMISSED_KEY = "chi_markdown_tip_dismissed";
|
|
8
|
+
|
|
9
|
+
// +el+ holds a .markdown-tip-text span and a .markdown-tip-close button
|
|
10
|
+
// (index.html's #markdownTip).
|
|
11
|
+
export function createMarkdownTip(el) {
|
|
12
|
+
const textEl = el.querySelector(".markdown-tip-text");
|
|
13
|
+
const closeBtn = el.querySelector(".markdown-tip-close");
|
|
14
|
+
|
|
15
|
+
function hide() {
|
|
16
|
+
el.hidden = true;
|
|
17
|
+
textEl.textContent = "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The tip for this payload: shown unless there is none or it was dismissed.
|
|
21
|
+
function show(text) {
|
|
22
|
+
if (!text || readStored(TIP_DISMISSED_KEY) === "1") return hide();
|
|
23
|
+
textEl.textContent = text;
|
|
24
|
+
el.hidden = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
closeBtn.addEventListener("click", () => {
|
|
28
|
+
writeStored(TIP_DISMISSED_KEY, "1");
|
|
29
|
+
hide();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return { show };
|
|
33
|
+
}
|
|
@@ -128,7 +128,8 @@ module Samagotchi
|
|
|
128
128
|
# reason, never the question or the command (a lock screen shows it).
|
|
129
129
|
# @return [Array(String, String)] title, body
|
|
130
130
|
def text(summary, reason)
|
|
131
|
-
name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]]
|
|
131
|
+
name = (summary && [summary[:title], summary[:auto_title], summary[:first_preview], summary[:last_prompt]]
|
|
132
|
+
.find { |v| present?(v) }) ||
|
|
132
133
|
"chi session"
|
|
133
134
|
body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
|
|
134
135
|
[clip(name.to_s.strip, TITLE_MAX), body]
|
|
@@ -81,6 +81,7 @@ module Samagotchi
|
|
|
81
81
|
# names its plugins' too.
|
|
82
82
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
83
83
|
markdown_warning: @markdown_renderer.warning,
|
|
84
|
+
markdown_tip: @markdown_renderer.tip,
|
|
84
85
|
**viewer_capabilities(req),
|
|
85
86
|
pending_question: pending,
|
|
86
87
|
last_event_seq: last_event_seq,
|
|
@@ -208,6 +209,7 @@ module Samagotchi
|
|
|
208
209
|
session: session_json,
|
|
209
210
|
messages: messages,
|
|
210
211
|
markdown_warning: @markdown_renderer.warning,
|
|
212
|
+
markdown_tip: @markdown_renderer.tip,
|
|
211
213
|
**viewer_capabilities(req),
|
|
212
214
|
timing: timing
|
|
213
215
|
})
|
|
@@ -40,6 +40,7 @@ module Samagotchi
|
|
|
40
40
|
up = bridge_up?(session_dir, owner)
|
|
41
41
|
sidecar = up ? WorkerSidecar.read(session_dir) : nil
|
|
42
42
|
saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
|
|
43
|
+
recap_card = RecapStore.card(session_dir)
|
|
43
44
|
{
|
|
44
45
|
id: session.id,
|
|
45
46
|
status: status,
|
|
@@ -74,8 +75,12 @@ module Samagotchi
|
|
|
74
75
|
# place of the first message.
|
|
75
76
|
title: session.title,
|
|
76
77
|
owner: owner&.kind,
|
|
77
|
-
# The saved recap's first sentence, for the session card
|
|
78
|
-
|
|
78
|
+
# The saved recap's first sentence, for the session card, and the
|
|
79
|
+
# auto title its reply led with (RecapTitle), else nil: an untitled
|
|
80
|
+
# session's name on the pages and its pushes (never in a model
|
|
81
|
+
# request).
|
|
82
|
+
recap: recap_card.preview,
|
|
83
|
+
auto_title: recap_card.title,
|
|
79
84
|
# How full the context was after the last turn (%), or nil, and
|
|
80
85
|
# the session's token sums and cost (the card's ctx tooltip), from
|
|
81
86
|
# one read of analytics.json.
|
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.114.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -513,6 +513,7 @@ files:
|
|
|
513
513
|
- lib/samagotchi/recap_command.rb
|
|
514
514
|
- lib/samagotchi/recap_setup.rb
|
|
515
515
|
- lib/samagotchi/recap_store.rb
|
|
516
|
+
- lib/samagotchi/recap_title.rb
|
|
516
517
|
- lib/samagotchi/redacted_inspect.rb
|
|
517
518
|
- lib/samagotchi/ref.rb
|
|
518
519
|
- lib/samagotchi/relay.rb
|
|
@@ -768,6 +769,7 @@ files:
|
|
|
768
769
|
- lib/samagotchi/web/public/llm_context_chip.js
|
|
769
770
|
- lib/samagotchi/web/public/manifest.webmanifest
|
|
770
771
|
- lib/samagotchi/web/public/markdown_place.js
|
|
772
|
+
- lib/samagotchi/web/public/markdown_tip.js
|
|
771
773
|
- lib/samagotchi/web/public/model_pick.js
|
|
772
774
|
- lib/samagotchi/web/public/model_picker.js
|
|
773
775
|
- lib/samagotchi/web/public/notice_links.js
|