samagotchi 0.74.0 → 0.75.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: baa839500eb96519376830a675e787eab121aab4c8de33fa72ffa3f007469643
4
- data.tar.gz: 17e32518eaf7348ec4a08514a302a14139d71369904602815662186d9dfa584d
3
+ metadata.gz: 583a10afc0042a87287fe62bb6e0e73aae26f69de5668c7926178d951c4fdc04
4
+ data.tar.gz: 9f2e0d659bf688b83b85fdff65a7f55e39bef32051af15a00abab893bb3d767e
5
5
  SHA512:
6
- metadata.gz: 4f52356f77fe80e1cb56f3d28c4c7844c08ca4291ee3090a89f18a13261b5ee8db5d2b77835da6b756a118e3a30000b5fe2160f3de255f90ee30294728b0edd9
7
- data.tar.gz: 88d10148534c0e9daaa6b5f97356b9b4f55311a25fa591ca48f70422f5c3cd128d9204b9bf7ea3f65e71c5fd26b7554de1571ddc650311ed1ab0af67eb4073fe
6
+ metadata.gz: 6cefe884285bab628351d25729aa036d076d002830e904e8362a335a50e47d83f8ba878b8ca0127b31943ca14715e8c962937566d22849a9af1658e94dab87ff
7
+ data.tar.gz: da32e58a43000c308c7fd88addfb66655baadd2f521a915d836cb4e60219a54117f603fed100166529fe0dbfab21801e6f0feae4b6ef8d026060f4747d6440b1
data/CHANGELOG.md CHANGED
@@ -8,6 +8,26 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.75.0] - 2026-10-17
12
+
13
+ ### Changed
14
+
15
+ - Web: a session chain's links are numbered `#N` instead of `day N` (the chip `↩ #3`, `#2 of 3`; its popover's rows),
16
+ since a chain may be continued several times a day. The info bar's ← / → show the neighbour's time (`← 09:12`) when
17
+ it started on the open link's day, else its date, with its full start in the tooltip; a popover row adds its time
18
+ when another link of the chain started the same day. The carry note names the previous link's start with its time
19
+ (`This session continues 3fa2c1d0 (2026-10-17 09:12), …`).
20
+
21
+ ### Fixed
22
+
23
+ - `chi bundle install` / `upgrade` no longer make a bundle repo's README, CHANGELOG, LICENSE, CONTRIBUTING,
24
+ CODE_OF_CONDUCT or SECURITY file a memory, and a manifest whose `files:` lists memories installs only those (an
25
+ unshipped name warns). An upgrade moves such a file an older install made a memory to the trash. Older chi still
26
+ installs every top-level `*.md` (`requires_chi` doesn't gate memories): a bundle for it too names its readme
27
+ `README.markdown`. `rake bundles:check` fails a shipped bundle's `*.md` that its `files:` doesn't list.
28
+
29
+ Update with `chi update` (the system bundle 0.75.0), then restart `chi web`.
30
+
11
31
  ## [0.74.0] - 2026-10-17
12
32
 
13
33
  ### Changed
@@ -3156,7 +3176,8 @@ and long-lived sessions.
3156
3176
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3157
3177
  hotkey panel that send selected text or the clipboard to your sessions.
3158
3178
 
3159
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.74.0...HEAD
3179
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.75.0...HEAD
3180
+ [0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
3160
3181
  [0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
3161
3182
  [0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
3162
3183
  [0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
@@ -49,7 +49,8 @@ The order matters: warnings print in it, and a plugin/script the manifest names
49
49
 
50
50
  1. `load_source`: normalize, read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning).
51
51
  2. `resolve_target`: scope = CLI > manifest > `system`.
52
- 3. `install_memories`, per `*.md`: an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
52
+ 3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
53
+ README, CHANGELOG, LICENSE and the like; a listed name not shipped warns): an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
53
54
  classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
54
55
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
55
56
  (`<name>.<key>.md`) get no index line (`note_overlay`).
data/docs/memory.md CHANGED
@@ -199,7 +199,7 @@ step instead of fixing the skill.
199
199
  ## What a bundle owns
200
200
 
201
201
  A bundle owns the memory files it wrote, nothing else. `chi bundle install`
202
- copies each of its `*.md` files into the scope dir; a file of the same name
202
+ copies each of its memories into the scope dir; a file of the same name
203
203
  that is already there is left as it is and reported `Skipped` (`--force`
204
204
  overwrites it, and then the bundle owns it). A skipped file stays yours: the
205
205
  bundle doesn't record it, so `chi bundle upgrade` never merges into it and
@@ -207,6 +207,19 @@ bundle doesn't record it, so `chi bundle upgrade` never merges into it and
207
207
  for `chi bundle build` then installing the result on the same machine, and
208
208
  for a file another bundle installed.
209
209
 
210
+ - **Which files are memories.** A manifest whose `files:` lists any: exactly
211
+ those (a listed name the bundle doesn't ship gets a warning). Without a
212
+ list (no `files:`, or `files: {}`): every top-level `*.md` except a repo's
213
+ meta files: README, CHANGELOG, LICENSE (or LICENCE), CONTRIBUTING,
214
+ CODE_OF_CONDUCT and SECURITY, in any case. So a bundle kept in its own git
215
+ repo can have a `README.md` without it becoming a memory. A listed file is
216
+ installed whatever its name (`chi bundle build` of your own `security.md`
217
+ lists it). Since chi 0.75.0. Older chi installs every top-level `*.md`
218
+ whatever `requires_chi` says (it gates hooks, rules and the plugin, not
219
+ memories), so a bundle for older chi too names its readme
220
+ `README.markdown` (or puts docs in a subdirectory).
221
+ `chi bundle upgrade` moves a README an older install made a memory to the
222
+ trash (unless you edited it, like any file a bundle stops shipping).
210
223
  - **The index says where a memory came from.** The `index.md` line of a file
211
224
  a bundle installed ends in `· from <bundle>`, before the description:
212
225
  `- **memory_guide** · system · 2026-10-03 · 5120 · from samagotchi-system —
data/docs/plugins.md CHANGED
@@ -15,6 +15,7 @@ my-bundle/
15
15
  manifest.yml
16
16
  plugin.rb
17
17
  identity.md # optional, like any bundle's memories, hooks/ and guardrails/
18
+ README.md # never a memory (nor CHANGELOG, LICENSE, ...): see memory.md#what-a-bundle-owns
18
19
  ```
19
20
 
20
21
  ```yaml
data/docs/releasing.md CHANGED
@@ -55,7 +55,7 @@ as the GitHub release body.
55
55
  | Task | What it does |
56
56
  | --- | --- |
57
57
  | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
58
- | `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`. The release workflow and `release:check` run it in full. |
58
+ | `rake bundles:check` | Sha lines match; every top-level `*.md` of a bundle is listed in its `files:` (an install takes only the listed ones); a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`. The release workflow and `release:check` run it in full. |
59
59
  | `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
60
60
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
61
61
  | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped). No commit. |
data/docs/sessions.md CHANGED
@@ -76,7 +76,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
76
76
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
77
77
  - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
78
78
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
79
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
79
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
80
80
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
81
81
 
82
82
  **Test-session hygiene:**
@@ -204,19 +204,19 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
204
204
 
205
205
  ## Session chains
206
206
 
207
- A session can **continue** an earlier one: "the next day of the same routine" (a daily coordinator, say). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
207
+ A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
208
208
 
209
209
  `SessionManager.continue_session` makes the next link:
210
210
 
211
211
  - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
212
- - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
212
+ - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
213
213
  - **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
214
214
  - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
215
215
  - **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
216
216
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
217
217
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
218
218
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
219
- - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
219
+ - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
220
220
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
221
221
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
222
222
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.74.0
3
+ version: 0.75.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:
@@ -11,6 +11,7 @@ require_relative "index_updater"
11
11
  require_relative "merger"
12
12
  require_relative "trash"
13
13
  require_relative "asset_installer"
14
+ require_relative "memory_files"
14
15
  require_relative "../bundle_needs"
15
16
  require_relative "../model_overlay"
16
17
 
@@ -167,7 +168,8 @@ module Samagotchi
167
168
  [scope, dir]
168
169
  end
169
170
 
170
- # Copies the bundle's *.md files into the target dir and refreshes
171
+ # Copies the bundle's memories (MemoryFiles: the files: list, else
172
+ # every *.md but README and the like) into the target dir and refreshes
171
173
  # their index lines: an upgrade merges (merge_memory), a dry run only
172
174
  # classifies (preview_memory), a plain install writes the new ones.
173
175
  # Sets @bundle_md (every file the bundle ships) and @owned: the bundle
@@ -178,8 +180,9 @@ module Samagotchi
178
180
  owned_before = @existing ? @existing.files.keys : []
179
181
  @bundle_md = []
180
182
  @owned = []
181
- Dir.glob(File.join(@bundle_dir, "*.md")).each do |file_path|
182
- file_key = File.basename(file_path)
183
+ warn_unshipped_files
184
+ memory_files.each do |file_key|
185
+ file_path = File.join(@bundle_dir, file_key)
183
186
  @bundle_md << file_key
184
187
  note_overlay(file_key, @target_dir)
185
188
  target_path = File.join(@target_dir, file_key)
@@ -206,6 +209,17 @@ module Samagotchi
206
209
  end
207
210
  end
208
211
 
212
+ # The bundle's memories' basenames (MemoryFiles.select), once.
213
+ def memory_files
214
+ @memory_files ||= MemoryFiles.select(@bundle_dir, @manifest)
215
+ end
216
+
217
+ # A files: entry with no such *.md in the bundle installs nothing.
218
+ def warn_unshipped_files
219
+ missing = MemoryFiles.unshipped(@bundle_dir, @manifest)
220
+ @warnings << "files: names #{missing.join(", ")}, which the bundle doesn't ship (not installed)" unless missing.empty?
221
+ end
222
+
209
223
  # An upgrade's 3-way merge of one file (Merger.classify against the
210
224
  # base the last install recorded); a conflict leaves the local file
211
225
  # and is recorded in @conflicts for the agent step.
@@ -431,7 +445,7 @@ module Samagotchi
431
445
  # An overlay-looking name with no base anywhere is an overlay too,
432
446
  # with a warning (a dry run warns as well).
433
447
  def note_overlay(file_key, target_dir)
434
- @bundle_files ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
448
+ @bundle_files ||= memory_files
435
449
  return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
436
450
 
437
451
  @overlays << file_key
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module MemoryBundle
5
+ # Which of a bundle dir's top-level *.md files are its memories (the
6
+ # ones an install copies into the scope dir, docs/memory.md):
7
+ # - a manifest whose files: lists any: exactly those (that exist);
8
+ # - else (no manifest, no files: key, or files: {}): every *.md but a
9
+ # repo's meta files (README, CHANGELOG, LICENSE, ...), so a bundle
10
+ # kept in its own git repo doesn't install its README as a memory.
11
+ # A listed file is installed whatever its name: `chi bundle build` of
12
+ # a user's own security.md lists it, and it is a memory.
13
+ # files: {} means "no list" rather than "no memories": shipped bundles
14
+ # with nothing to install write it (mcp), and an external bundle that
15
+ # left the map empty installed its *.md before this rule.
16
+ module MemoryFiles
17
+ # A repo's meta files, by name without extension (case-insensitive).
18
+ REPO_META = %w[readme changelog license licence contributing code_of_conduct security].freeze
19
+ META_EXTENSIONS = %w[.md .markdown .txt].freeze
20
+
21
+ module_function
22
+
23
+ # @return [Array<String>] the memories' basenames, sorted
24
+ def select(bundle_dir, manifest)
25
+ present = Dir.glob(File.join(bundle_dir, "*.md")).map { |f| File.basename(f) }.sort
26
+ listed = manifest&.files&.keys
27
+ return present & listed if listed && !listed.empty?
28
+
29
+ present.reject { |name| repo_meta?(name) }
30
+ end
31
+
32
+ # The files: entries that name no top-level *.md of the bundle.
33
+ def unshipped(bundle_dir, manifest)
34
+ listed = manifest&.files&.keys || []
35
+ listed.reject { |name| name.end_with?(".md") && File.file?(File.join(bundle_dir, name)) && !name.include?("/") }
36
+ end
37
+
38
+ # Whether +name+ is a repo's meta file: README, README.md,
39
+ # readme.markdown, LICENSE.txt, ...
40
+ def repo_meta?(name)
41
+ base = name.downcase
42
+ ext = META_EXTENSIONS.find { |e| base.end_with?(e) }
43
+ REPO_META.include?(ext ? base.delete_suffix(ext) : base)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -91,7 +91,7 @@ module Samagotchi
91
91
  # one): see #delegate?.
92
92
  attr_writer :delegate
93
93
  # The session this one continues (SessionManager.continue_session): the
94
- # previous link of a chain, "the next day of the same routine", else
94
+ # previous link of a chain, "the next run of the same routine", else
95
95
  # nil. Not a parent: archiving one link leaves the next alone. The chain
96
96
  # itself is derived (SessionChain), never stored.
97
97
  attr_accessor :continues
@@ -6,7 +6,7 @@ require_relative "recap_store"
6
6
 
7
7
  module Samagotchi
8
8
  # Session chains: a session that continues an earlier one (Session#continues),
9
- # "the next day of the same routine". The chain is derived, never stored:
9
+ # "the next run of the same routine". The chain is derived, never stored:
10
10
  # follow +continues+ back for the earlier links, forward (the session whose
11
11
  # +continues+ names this one) for the later ones. A link is continued at
12
12
  # most once (SessionManager.continue_session refuses a second), so the
@@ -69,7 +69,7 @@ module Samagotchi
69
69
  # @param recap [Hash, nil] RecapStore.read's
70
70
  # @return [String]
71
71
  def note_text(previous, recap:)
72
- head = "This session continues #{previous.id[0, 8]} (#{day(previous.created_at)}), the previous link of its chain."
72
+ head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
73
73
  text = recap && recap[:text].to_s.strip
74
74
  return "#{head} It left no recap." if text.nil? || text.empty?
75
75
 
@@ -102,12 +102,6 @@ module Samagotchi
102
102
  nil
103
103
  end
104
104
 
105
- def day(iso)
106
- Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
107
- rescue ArgumentError
108
- iso.to_s
109
- end
110
-
111
105
  def clock(iso)
112
106
  iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
113
107
  rescue ArgumentError
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.74.0"
4
+ VERSION = "0.75.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1,9 +1,9 @@
1
1
  // Session chains on the page (docs/sessions.md, Session chains): a session
2
- // that continues an earlier one (its summary's `continues`), "the next day of
2
+ // that continues an earlier one (its summary's `continues`), "the next run of
3
3
  // the same routine". The chain is derived from the list, never stored: back
4
4
  // through `continues`, forward through the session whose `continues` names
5
5
  // this one. The lists fold a chain into its latest listed link's card (a
6
- // `day N` chip, its earlier links in a popover); the session page links the
6
+ // `#N` chip, its earlier links in a popover); the session page links the
7
7
  // previous and the next link. Pure: lists_view.js places the chip and rows
8
8
  // (lit-html templates) and app.js the toast's markup, and they bind them.
9
9
  import { normalize, previewForCard, relativeTime } from "./format.js";
@@ -33,7 +33,7 @@ export function isOpener(text) {
33
33
 
34
34
  // A link's card title (format.js previewForCard), display only: a link
35
35
  // whose title is the opener, as every link's first message is once sent,
36
- // reads its chain's first link's title instead, so the days of a chain
36
+ // reads its chain's first link's title instead, so the links of a chain
37
37
  // keep its name. Its own title when the first link isn't in +byId+ (id →
38
38
  // session; deleted, or a walk a missing link cut short) or is the opener too.
39
39
  export function chainTitle(s, byId) {
@@ -112,13 +112,32 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
112
112
  return { prev, next };
113
113
  }
114
114
 
115
- // A link's day as the carry note writes it: the local YYYY-MM-DD of its
116
- // start; "" when it has none.
117
- export function chainDate(iso) {
115
+ const pad = (n) => String(n).padStart(2, "0");
116
+ const localDate = (iso) => {
118
117
  const d = new Date(iso || "");
119
- if (Number.isNaN(d.getTime())) return "";
120
- const pad = (n) => String(n).padStart(2, "0");
121
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
118
+ return Number.isNaN(d.getTime()) ? null : d;
119
+ };
120
+
121
+ // A link's day as the carry note writes its date: the local YYYY-MM-DD of
122
+ // its start; "" when it has none.
123
+ export function chainDate(iso) {
124
+ const d = localDate(iso);
125
+ return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : "";
126
+ }
127
+
128
+ // A link's start as a local HH:MM; "" when it has none.
129
+ export function chainClock(iso) {
130
+ const d = localDate(iso);
131
+ return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
132
+ }
133
+
134
+ // A link's start as a chain list shows it: its date (chainDate), with its
135
+ // HH:MM when one of +otherIsos+ (the chain's other links' starts) is on the
136
+ // same local day, so two links of one day tell apart. "" when it has none.
137
+ export function chainStamp(iso, otherIsos = []) {
138
+ const date = chainDate(iso);
139
+ if (!date) return "";
140
+ return otherIsos.some((o) => chainDate(o) === date) ? `${date} ${chainClock(iso)}` : date;
122
141
  }
123
142
 
124
143
  // The families (sessions_list.js families) with the chains folded in: a
@@ -129,7 +148,7 @@ export function chainDate(iso) {
129
148
  // index). +all+: every session the page holds, archived ones too (the links
130
149
  // the list leaves out). The same array when no chain is in it.
131
150
  // @param fold false: every family stays (each link's card carries its
132
- // chip, "day 1 of 3"; folded false), for a search or the archived ones
151
+ // chip, "#1 of 3"; folded false), for a search or the archived ones
133
152
  export function foldChains(fams, all, { fold = true } = {}) {
134
153
  if (!all.some((s) => s.continues)) return fams;
135
154
  const nexts = nextsOf(all);
@@ -160,15 +179,15 @@ export function allViewChains(fams, all, { query = "", includeArchived = false }
160
179
  }
161
180
 
162
181
  // The links a family's chain list shows: the chain but its head, newest
163
- // first, each {session, day} (day: its 1-based place in the chain).
182
+ // first, each {session, n} (n: its 1-based place in the chain).
164
183
  function chainOthers(f) {
165
184
  const links = f?.chain?.links || [];
166
- return links.map((session, i) => ({ session, day: i + 1 }))
185
+ return links.map((session, i) => ({ session, n: i + 1 }))
167
186
  .filter(({ session }) => session.id !== f.head.id)
168
187
  .reverse();
169
188
  }
170
189
 
171
- // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
190
+ // The chip in a chain's card: "#3" (the head's place; "#2 of 3" when
172
191
  // later links aren't listed), with a live dot / "waiting" when one of the
173
192
  // other links has a worker / waits on the user (typing in an archived link
174
193
  // brings it back, but it stays folded here). nothing for a family in no
@@ -180,12 +199,12 @@ export function chainChipTemplate(f, { open = false } = {}) {
180
199
  const others = chainOthers(f);
181
200
  const live = others.filter(({ session: s }) => s.owner === "worker").length;
182
201
  const waiting = others.filter(({ session: s }) => waitingOn(s)).length;
183
- const day = at + 1;
184
- const text = day === links.length ? `day ${day}` : `day ${day} of ${links.length}`;
202
+ const n = at + 1;
203
+ const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
185
204
  const earlier = at;
186
205
  const later = links.length - 1 - at;
187
206
  const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
188
- const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
207
+ const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
189
208
  const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
190
209
  const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
191
210
  return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
@@ -193,24 +212,26 @@ export function chainChipTemplate(f, { open = false } = {}) {
193
212
  }
194
213
 
195
214
  // The chain's list (the chip's popover): one row per other link, newest
196
- // first: status dot, "day N", its date, its recap's first sentence (else
215
+ // first: status dot, "#N", its date (with its HH:MM when another link of
216
+ // the chain, the head too, started the same day: chainStamp), its recap's first sentence (else
197
217
  // its preview), the waiting / stopped badge, archived. A row's click opens
198
218
  // that link (families_view.js's popover binds .family-row[data-id]).
199
219
  // @param activeId the open session's id (its row is .active)
200
220
  export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
201
221
  const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
202
- const rows = chainOthers(f).map(({ session: s, day }) => {
222
+ const links = f?.chain?.links || [];
223
+ const rows = chainOthers(f).map(({ session: s, n }) => {
203
224
  const cls = ["family-row", "chain-row"];
204
225
  if (activeId && s.id === activeId) cls.push("active");
205
226
  if (s.archived) cls.push("archived");
206
227
  const waiting = waitingBadge(s, { inFamily: true });
207
228
  const stopped = waiting ? null : stoppedBadge(s);
208
- const date = chainDate(s.created_at);
229
+ const date = chainStamp(s.created_at, links.filter((l) => l !== s).map((l) => l.created_at));
209
230
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
210
231
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
211
232
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
212
- title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">day ${day}</span>${
213
- date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
233
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">#${n}</span>${
234
+ date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
214
235
  rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
215
236
  });
216
237
  return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
@@ -1033,6 +1033,8 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1033
1033
  .chain-chip .cc-waiting{font-family:system-ui,sans-serif;font-weight:600}
1034
1034
  .chain-row .cr-day{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
1035
1035
  .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
1036
+ /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1037
+ .chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
1036
1038
  /* The session page's links to the previous and the next link of its chain. */
1037
1039
  #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
1038
1040
  #infoBar .meta a.chain-link:hover{background:var(--accent-bg)}
@@ -21,12 +21,12 @@ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
21
21
  export const PREV = "←";
22
22
  export const NEXT = "→";
23
23
 
24
- // A chain's ← / → link (chains.js chainNeighbours): its date (else its short
25
- // id), its preview and id in the tooltip. +link+: {id, date, preview,
26
- // archived}.
24
+ // A chain's ← / → link (chains.js chainNeighbours): its date or time (else
25
+ // its short id), its id, full start and preview in the tooltip. +link+:
26
+ // {id, date, stamp, preview, archived} (info_bar_model.js chainLinkView).
27
27
  export function chainLinkTemplate(link, arrow, what) {
28
28
  const short = String(link.id).slice(0, 8);
29
- const title = `${what}: ${short}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
29
+ const title = `${what}: ${short}${link.stamp ? ` · ${link.stamp}` : ""}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
30
30
  const text = arrow === PREV ? `${arrow} ${link.date || short}` : `${link.date || short} ${arrow}`;
31
31
  return html`<a class="model chain-link" href=${sessionHash(link.id)} title=${title}>${text}</a>`;
32
32
  }
@@ -4,7 +4,7 @@
4
4
  // its list and its in-flight actions. Pure: app.js's updateInfoBar applies
5
5
  // it, every second while a turn runs (the session clock).
6
6
  import { canStopSession, childrenSummary, delegatedBy, modelLabel, promptNotesChip } from "./format.js";
7
- import { chainDate, chainNeighbours } from "./chains.js";
7
+ import { chainClock, chainDate, chainNeighbours } from "./chains.js";
8
8
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
9
9
  import { formatDuration, sessionDurationNow } from "./timing.js";
10
10
  import { workerBadge } from "./update_notice.js";
@@ -21,12 +21,18 @@ export function workerBadgeFor(id, state, versions) {
21
21
  }
22
22
 
23
23
  // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
24
- // shows it (info_bar.js chainLinkTemplate), or null. +cardTitle+: the
25
- // lists' title for a session (a chain's opener reads its first link's).
26
- export function chainLinkView(link, cardTitle) {
24
+ // shows it (info_bar.js chainLinkTemplate), or null. Its date: its local
25
+ // HH:MM when it started on the open link's day (+selfIso+: the open
26
+ // session's created_at; the day is implied), else its YYYY-MM-DD; its
27
+ // stamp (the tooltip's) always both. +cardTitle+: the lists' title for a
28
+ // session (a chain's opener reads its first link's).
29
+ export function chainLinkView(link, cardTitle, selfIso = null) {
27
30
  if (!link) return null;
28
- return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? cardTitle(link.session) : "",
29
- archived: !!link.session?.archived };
31
+ const iso = link.session?.created_at;
32
+ const day = chainDate(iso);
33
+ const clock = chainClock(iso);
34
+ return { id: link.id, date: day && day === chainDate(selfIso) ? clock : day, stamp: day ? `${day} ${clock}` : "",
35
+ preview: link.session ? cardTitle(link.session) : "", archived: !!link.session?.archived };
30
36
  }
31
37
 
32
38
  // The bar's buttons: continue (not on a link continued already, which has
@@ -66,6 +72,9 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
66
72
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
67
73
  const sessionDuration = sessionDurationNow(state.timing, running);
68
74
  // A delegated session names its parent, as a link to it.
75
+ // The open link's start: the payload's, else its list entry's (a fresh
76
+ // link may not be listed yet: then neither, and its links show dates).
77
+ const selfIso = state.createdAt || sessions.find((x) => x.id === id)?.created_at || null;
69
78
  const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
70
79
  return {
71
80
  id,
@@ -74,7 +83,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
74
83
  model: modelLabel(state.model, ...(state.served || [])),
75
84
  // A link of a session chain: ← the previous link, → the next one (each
76
85
  // only while the list holds it).
77
- chain: { prev: chainLinkView(chain.prev, cardTitle), next: chainLinkView(chain.next, cardTitle) },
86
+ chain: { prev: chainLinkView(chain.prev, cardTitle, selfIso), next: chainLinkView(chain.next, cardTitle, selfIso) },
78
87
  parent: parentChip ? { id: state.parentId, title: parentChip.title } : null,
79
88
  // A parent names its delegates; the link opens all sessions.
80
89
  kids: childrenSummary({ id }, sessions),
@@ -129,7 +129,7 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
129
129
  // member does; with rows (all sessions) an open family lists its members
130
130
  // inline, the strip's opens in a popover; matchIds: a search's matching
131
131
  // members. A family in a session chain (chains.js foldChains) carries its
132
- // `day N` chip, which opens the other links in a popover. entering: a card
132
+ // `#N` chip, which opens the other links in a popover. entering: a card
133
133
  // new to its list (enteringCards: it fades in); anim: its inline animation
134
134
  // phases (cardAnimStyle).
135
135
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
@@ -342,7 +342,7 @@ export function createListsView(deps) {
342
342
  doc.addEventListener("scroll", () => familyPop.reposition(), true);
343
343
 
344
344
  // A chain's popover (chains.js): the other links of the chain a card's
345
- // `day N` chip stands for, under the chip, in the strip or all sessions
345
+ // `#N` chip stands for, under the chip, in the strip or all sessions
346
346
  // (the list it was opened from: chainFrom). A row opens that link.
347
347
  const chainFamilies = new Map(); // head id → family, as the lists last drew it
348
348
  let chainFrom = null; // the list element the open popover's chip is in
@@ -23,6 +23,9 @@ export function emptySessionState() {
23
23
  parentId: null,
24
24
  // The previous link of its chain (its `continues`), or null.
25
25
  continues: null,
26
+ // Its start (the summary's created_at; the info bar's chain links are
27
+ // times on its day), or null.
28
+ createdAt: null,
26
29
  ctxPct: null,
27
30
  // Window the kernel resolved for the running generation (:generation_started).
28
31
  ctxWindow: null,
@@ -107,6 +110,7 @@ export function sessionStateFrom(data) {
107
110
  state.toolkit = s.toolkit || null;
108
111
  state.parentId = s.parent_id || null;
109
112
  state.continues = s.continues || null;
113
+ state.createdAt = s.created_at || null;
110
114
  state.commands = Array.isArray(data.commands) ? data.commands : [];
111
115
  return state;
112
116
  }
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.74.0
4
+ version: 0.75.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -380,6 +380,7 @@ files:
380
380
  - lib/samagotchi/memory_bundle/installer.rb
381
381
  - lib/samagotchi/memory_bundle/listing.rb
382
382
  - lib/samagotchi/memory_bundle/manifest.rb
383
+ - lib/samagotchi/memory_bundle/memory_files.rb
383
384
  - lib/samagotchi/memory_bundle/merger.rb
384
385
  - lib/samagotchi/memory_bundle/placeholder.rb
385
386
  - lib/samagotchi/memory_bundle/plugin_ref.rb