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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc980123c32fef67a2b47defad94735286ba983537b227ba79961cf6b15f1f1a
4
- data.tar.gz: 5768a7d054c2a36abd746b6a19b7126fa6e992915bbae6040d2990b1903754bf
3
+ metadata.gz: a59aa63d1ccf6a6114afcaf5b1cf19088cb482989c08acb3aa5bfede3a67358d
4
+ data.tar.gz: 3fe5245e7c864ca0b814d9b187b402d5658ffd88aa5d895881482f4b40e9391f
5
5
  SHA512:
6
- metadata.gz: 906dcb1ea43aeb750b201f75ef585648c609c89e77b0a8ff2bc06e11aa06ece5bec4b7734a1ecff888099952c90e7898c1dc91d64fbe52b4ddbbf102ddefff3a
7
- data.tar.gz: 05a0edaf338b7a33bb3ffa73a4b590bff8498d789004c12c3f85a7e52ccb2516c4a58c332ae7fb203b9e04131fb36d1b5bb8c1f0f2fc2373e03ef6ded9ccd366
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.113.0...HEAD
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 `title: none (shows
714
- the first message: …)`); `/title TEXT` names the session (whitespace collapsed,
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
- The setting also supports `SAMAGOTCHI_WEB_MARKDOWN=true` or the global config:
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
@@ -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. On a current
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, the web names a session by its recap's first sentence once it has one (see [Session recap](cli.md#session-recap); its subject phrase stripped), else by its first message: on its card (which then leaves out its recap line, the same sentence), the families and chain rows, the top bar and the browser tab, the delete confirm and the search; `chi sessions list` already reads title, else recap, else the first message. The notifications keep the first message. A participant's page is unchanged (the title or the review's subjects).
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 something failed.
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"], bundles]
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}")
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.113.0
3
+ version: 0.114.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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, written as a quoted string
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
- def scalar(value) = JSON.generate(value.to_s)
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: File.join(RbConfig::CONFIG["bindir"], "gem"))
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. Reply with the recap only: %<plain>s, no heading, " \
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
- def build(transcript, tool_count: nil, tool_names: [], previous: nil, sentences: DEFAULT_SENTENCES, offer: false)
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
- system = format(SYSTEM, range) + (previous ? UPDATE + format(UPDATE_LENGTH, range) : "") + tools
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:}, where covered counts the
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
- sentences: @sentences, offer: offer_open?)
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 = recap.to_s
503
- saved = { text: text, covered: job[:covered], covered_digest: job[:covered_digest],
504
- model: served || job[:model], created_at: Time.now.utc.iso8601 }
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
- text = read(session_dir)&.dig(:text).to_s.gsub(/\s+/, " ").strip
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, project =
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, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
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 saved recap's first sentence says more than the last prompt
238
- # (text only: tsv and json keep desc for pickers); a title (in
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
- if summary[:recap] && !summary[:title]
242
- desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
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 saved recap's first sentence, else the
269
- # last prompt (or the first message, before any turn), cut as before.
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
- recap = s.title ? nil : Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
276
- (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
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", or "title: none (shows the first message: hey chi …)"
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.113.0"
4
+ VERSION = "0.114.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
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
- /* Every row keeps its content's height: a card shorter than its rows (seen on iOS) cut the clamped preview and the
1506
- single-line title and recap through the middle. */
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]].find { |v| present?(v) }) ||
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
- recap: RecapStore.preview(session_dir),
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.113.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