samagotchi 0.42.0 → 0.43.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: d41b91ef2514e877a756d7f66ba02f58b65c7d85f7446728b02ec06f652299b3
4
- data.tar.gz: 37e0869508863e5db69395b847ac147101f60a79208a5ba9e789e3427eb9716e
3
+ metadata.gz: '019e204c3cd98489376bc61446efc6c576d5eb31f73ca97331c23b2d551b015f'
4
+ data.tar.gz: 20b4127aae83e29bfb00d67762ea443b78b2961622502df32458d9d6f9d89068
5
5
  SHA512:
6
- metadata.gz: abb02fdd05aec927500fbb42a750b9da3dc9e1c8e24cbc55bfdc933e797e206ee8e3a3502d0a4074804b0342f856726d2b04d2eb15c2aa61682e23439bca38ea
7
- data.tar.gz: 681406d11e96949fd168b252a6717fe2f6dcfaa765400bf66a43a7131dc4440ff7f077312f3ccd219ddce8c05e88f15e4bc808c52652d265beed59eed89c8c1c
6
+ metadata.gz: cdd1bbcef93ae1d57db0fde19927ac5b1c3dbb2e0fe36ff6bc93296044ce305fb2b75c1cc62b6a96c84720d9cb6e288135b69f2aaa624f949282355f360c2ab6
7
+ data.tar.gz: 91d1f3c0d5e2145135045e5f8fd79335142bc99bdccea708a608a541f72401812d1b965a4ec2e03e05a07d109664e40c9826d2573d51ec4cc918cc00b7d7400a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,32 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.43.0] - 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - A new web chat can start under its own LLM context: the start page has an `llm ctx` chip beside the model
16
+ picker that shows the picked model's strategy, and its form (as the info bar's, titled "new chat") sets a
17
+ strategy, apply rule and budget for that chat's first turn on (`llm ctx stale · new chat`). The choice is not
18
+ remembered: it resets after a create, on a reload and when you open a session, and an untouched chip sends
19
+ nothing. `POST /api/sessions` takes
20
+ `llm_context` (`{strategy, apply, budget}` as `/llm-context` words them; anything else answers
21
+ `400 invalid_llm_context`), and `GET /api/models` rows carry each model's `llm_context`. See
22
+ [docs/configuration.md](docs/configuration.md) ("LLM context: a session's own strategy").
23
+ - The web's `ctx` chip shows the context window and where it came from: its tooltip gains `context: ~41.0k of
24
+ 128.0k tokens (server)`, and the chip reads `ctx ~12%` when the window is chi's 256k default, a guess no server,
25
+ model list or setting gave (the tooltip says to set `window_tokens` for the model).
26
+ - github-pr 0.3.0: `bundles: github-pr: auto_attach:` says what happens to the branch's open PR when a worker
27
+ starts: `attach` (the default, as before), `offer` (a card, "PR #42 for branch feat/x", with **Attach** and **Not
28
+ here**, shown once per session; `/pr-attach 42` and `/pr-decline 42`) or `off` (nothing). In `offer` mode the
29
+ offers, what you clicked and each session's first prompt go to a local log, `plugins/github-pr/offers.ndjson` in
30
+ the state dir. See [docs/context.md](docs/context.md) ("Attach, offer or off").
31
+ - For plugins: `ctx.context.decline(url:|name:)`, `declined?(name, url:)`, `attach(…, force: true)` (the user's
32
+ explicit choice, past a decline) and `mark_offered(name, hint)` / `offered(name)`, a per-session record that a
33
+ plugin offered a source. See [docs/plugins.md](docs/plugins.md) ("Attached context").
34
+
35
+ Update with `chi update`: it updates github-pr (0.3.0: `auto_attach`; needs chi 0.43.0).
36
+
11
37
  ## [0.42.0] - 2026-10-08
12
38
 
13
39
  ### Added
@@ -1982,7 +2008,8 @@ and long-lived sessions.
1982
2008
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1983
2009
  hotkey panel that send selected text or the clipboard to your sessions.
1984
2010
 
1985
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.42.0...HEAD
2011
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.43.0...HEAD
2012
+ [0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
1986
2013
  [0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
1987
2014
  [0.41.0]: https://github.com/dm1try/samagotchi/compare/v0.40.0...v0.41.0
1988
2015
  [0.40.0]: https://github.com/dm1try/samagotchi/compare/v0.39.0...v0.40.0
@@ -909,6 +909,14 @@ its own). The first target is a long session such as a day-long coordinator, whe
909
909
  - **Set it** at start with `chi --llm-context stale,forget --llm-context-apply turn_end --llm-context-budget 64k`
910
910
  (the same flags on `chi send --new`), in the session with `/llm-context strategy … apply … budget …`
911
911
  (docs/cli.md, "Session commands"), or from the web's `llm ctx` chip in the info bar.
912
+ - **A new web chat:** the start page's `llm ctx` chip, beside the model picker, shows the picked model's values
913
+ and follows a model pick. Its form (titled "new chat") sets a choice for that one chat: the chip reads
914
+ `llm ctx stale · new chat` in the accent colour, and the create sends it as `POST /api/sessions`'s
915
+ `llm_context` (`{"strategy": "stale,forget", "apply": "turn_end", "budget": "64k"}`, each a word as
916
+ `/llm-context` takes it, `default` for unset; anything else answers `400 invalid_llm_context` and starts
917
+ nothing), so the session file holds it before the first turn runs. It is not remembered: it resets after a
918
+ create, on a reload and when you open a session (a failed create keeps it), and an untouched chip sends nothing
919
+ (the model's values apply, as without the chip).
912
920
  - **Values:** `none` and a budget of `off` are values the session sets on purpose (they win over the model's);
913
921
  `default` (or `/llm-context reset` for all three) unsets one, back to following the model. A budget is 4k to
914
922
  10M tokens (`4000`–`10000000`, or `4k`–`10000k`). The layers are kept in chi's order (`stale`, then `forget`).
data/docs/context.md CHANGED
@@ -175,6 +175,7 @@ It needs [`gh`](https://cli.github.com), logged in (`gh auth login`).
175
175
  the next start doesn't bring it back; `chi context add <PR URL>` or "+ URL"
176
176
  does.
177
177
  - `chi context add <PR URL>` and the web's "+ URL" attach any PR.
178
+ - `auto_attach` (below) can make it ask first, or attach nothing.
178
179
  - The text: title, state, branch, description, reviews, comments (oldest
179
180
  first) and checks, every 5 minutes.
180
181
  - The summary says what changed in **counts, authors and states only**
@@ -184,6 +185,50 @@ It needs [`gh`](https://cli.github.com), logged in (`gh auth login`).
184
185
  - It wakes for a review requesting changes, checks turning red, and the PR
185
186
  merged or closed. Comments alone never wake.
186
187
 
188
+ ### Attach, offer or off
189
+
190
+ ```yaml
191
+ bundles:
192
+ github-pr:
193
+ auto_attach: offer # attach (the default) | offer | off
194
+ ```
195
+
196
+ - `attach`: the branch's open PR is attached when the worker starts, as
197
+ above.
198
+ - `offer`: nothing is attached; a card asks instead, once per session per
199
+ PR: **PR #42 for branch feat/x**, the PR's title and "Not attached: the
200
+ agent gets nothing until you attach it.", with **Attach** and **Not
201
+ here**. The terminal shows them as `→ /pr-attach 42` and
202
+ `→ /pr-decline 42`, to type. Attach attaches it as auto-attach would
203
+ (even after Not here: a click is your choice); Not here marks it removed
204
+ from the session, so `attach` mode won't attach it there either. Either
205
+ turns the card into a one-line notice. Not here on a PR that is attached
206
+ already changes nothing and says so: `chi context rm pr-42` removes it. A worker restart (idle exit,
207
+ resume) doesn't ask again; once the card has scrolled out of the last 20,
208
+ `/pr-attach 42` or "+ URL" still attaches it. `/pr-attach` and
209
+ `/pr-decline` act only on a PR offered in that session.
210
+ - `off`: nothing is attached or offered. "+ URL", `chi context add <PR URL>`
211
+ and line links work as before.
212
+
213
+ `true` and `false` (YAML reads a bare `on`/`off` as those) mean `attach` and
214
+ `off`; an unknown value is `attach`, with a warning in the log. The setting
215
+ is read when a worker starts.
216
+
217
+ A web chat is created with its first message, so in `offer` mode the card
218
+ arrives while that first turn runs: the first answer comes without the PR,
219
+ and Attach delivers it before the next turn. An offered PR that isn't
220
+ attached gets no line links (unless your message names its URL).
221
+
222
+ In `offer` mode the bundle keeps a local log,
223
+ `$XDG_STATE_HOME/samagotchi/plugins/github-pr/offers.ndjson`, to find out
224
+ later when an offer is wanted: one JSON line per event, `offered` (session,
225
+ project root, working directory, branch, PR URL, main checkout or worktree,
226
+ model), `attached` and `declined` (with the seconds since the offer), and
227
+ `first_prompt` (the first 160 characters of every session's first message,
228
+ offered or not; not scratch sessions or delegate children). Over 1 MB it is
229
+ renamed to `offers.ndjson.1` (one old file is kept). Only you can read it
230
+ (0600), and nothing leaves your machine.
231
+
187
232
  ### Line links
188
233
 
189
234
  In the web, a file reference in an answer links to that line of the pull
@@ -256,6 +301,7 @@ sessions/<id>/<name>.json, …snapshot.json a session's own
256
301
  sessions/<id>/subscriptions.json what the session has seen and read (its worker writes it)
257
302
  sessions/<id>/muted/<name> a project source this session mutes
258
303
  sessions/<id>/declined/<name> a source you removed (its hint inside): auto-attach skips it
304
+ sessions/<id>/offered/<name> a source a plugin offered here ({hint, why, at}): it isn't offered again
259
305
  ```
260
306
 
261
307
  Deleting a session deletes its folder. A source and its snapshot go with
data/docs/plugins.md CHANGED
@@ -679,6 +679,9 @@ ctx.sessions.stop(id) # one of this session's own children
679
679
  ctx.context.attach(url: "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
680
680
  ctx.context.attach(name: "ci", cmd: "bin/ci-status", why: "this branch's CI", every_seconds: 120)
681
681
  ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:}]
682
+ ctx.context.decline(url: "https://github.com/acme/app/pull/42") # => "pr-42"
683
+ ctx.context.mark_offered("pr-42", "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
684
+ ctx.context.offered("pr-42") # => #<data Offer name="pr-42", hint="https://…", why="branch…", at="2026-10-08T…Z">, or nil
682
685
  ```
683
686
 
684
687
  [Attached context](context.md) for the session the plugin runs in: chi runs
@@ -691,13 +694,30 @@ gets a note when its text changes.
691
694
  command of the plugin's own. A source of that name already attached stays
692
695
  as it is. It returns the name, or nil when the user removed that name (or
693
696
  that URL) from the session: a plugin doesn't attach it again until the
694
- user adds it (`ctx.context.declined?(name)` says so).
697
+ user adds it (`ctx.context.declined?(name, url: nil)` says so, by the
698
+ name or by the URL). `force: true` is for the user's own explicit choice
699
+ (a click on the plugin's card): it attaches anyway and clears the
700
+ declined marker, by the name and by the URL.
701
+ - `decline(url:)` or `decline(name:)` marks the source declined in this
702
+ session, as the user's removal does: `attach` skips it from then on. With
703
+ `url:`, the name and hint come from the bundle's provider (`name:`
704
+ replaces the name). It returns the name. A source attached already stays
705
+ attached (`chi context rm` removes it).
706
+ - `mark_offered(name, hint, why: nil)` records that the plugin offered a
707
+ source here (the hint is usually its URL; `why:` is kept for the attach
708
+ that may follow); `offered(name)` returns that record
709
+ (`ContextSources::Offer`: `name`, `hint`, `why`, `at`) or nil. A plugin that asks
710
+ before it attaches uses it so a worker restart doesn't ask again. Neither
711
+ an attach nor a decline clears it; deleting the session does.
695
712
  - The source is the session's (not the project's), marked
696
713
  `added_by: plugin:<bundle>`. It is plugin code, so it isn't asked about as
697
714
  the agent's `chi context add --cmd` is.
698
715
  - `list` gives the session's sources, its own then its project's.
699
- - `attach` raises `Samagotchi::Plugin::AttachedContext::Error` with the
700
- reason (no session yet, no provider for the URL, a bad name).
716
+ - Two attaches of one source at once (a double click) both return its
717
+ name.
718
+ - `attach`, `decline` and `mark_offered` raise
719
+ `Samagotchi::Plugin::AttachedContext::Error` with the reason (no session
720
+ yet, no provider for the URL, a bad name).
701
721
 
702
722
  The [github-pr bundle](#the-github-pr-bundle) attaches the branch's PR from
703
723
  a quiet `chi.init` task.
@@ -1219,8 +1239,10 @@ nothing without `gh`, a repository or an open PR), and resolves PR URLs for
1219
1239
  and a summary of what changed in counts, authors and states only; it wakes
1220
1240
  the session for a review requesting changes, checks turning red, or the PR
1221
1241
  merged or closed. In the web, a `path:line` in an answer links to that line
1222
- of the session's PR ([Line links](context.md#line-links)). See [Attached
1223
- context](context.md#github-prs-the-github-pr-bundle).
1242
+ of the session's PR ([Line links](context.md#line-links)). `auto_attach:
1243
+ offer` asks with a card first (`/pr-attach N`, `/pr-decline N`), `off`
1244
+ attaches nothing ([Attach, offer or off](context.md#attach-offer-or-off)).
1245
+ See [Attached context](context.md#github-prs-the-github-pr-bundle).
1224
1246
 
1225
1247
  ## The coordinator bundle
1226
1248
 
data/docs/sessions.md CHANGED
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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). 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. 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, 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; 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; 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, sampling?}], warning?}`; `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). 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 choice; 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).
68
+ - 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). 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. 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, 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; 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; 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, sampling?, llm_context?}], warning?}`; `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).
69
69
  - 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).
70
70
  - 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), `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), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - 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).
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
- description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR
5
+ description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.32.0"
7
+ requires_chi: ">= 0.43.0"
8
8
  needs:
9
9
  - command: gh
10
10
  why: reads the pull request
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:f8b9794f84681f3a16a4d49da3b857a59fec3ccdc2d0a2b16d1192fd2a26bdf3
14
+ sha256: sha256:8c3b1b2de9112320d95d42d19b924a2a6bb0418715d3c78a24ac695c7b825487
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require "open3"
6
+ require "time"
6
7
  require "tmpdir"
7
8
 
8
9
  # github-pr: a session on a branch with an open GitHub PR gets the PR
@@ -24,15 +25,31 @@ require "tmpdir"
24
25
  # done); before_turn, before_generation and the init task fill the cache
25
26
  # off the turn's thread. `bundles: github-pr: line_links: false` turns it
26
27
  # off.
28
+ #
29
+ # `bundles: github-pr: auto_attach:` says what the init task does with the
30
+ # branch's open PR: `attach` (the default), `offer` (a card with Attach and
31
+ # Not here, which run /pr-attach N and /pr-decline N; nothing attached until
32
+ # a click; offered once per session) or `off` (nothing). In offer mode the
33
+ # offers, their outcomes and each session's first prompt go to
34
+ # <data_dir>/offers.ndjson (OffersLog), to find heuristics later.
27
35
  class Plugin
28
36
  # gh and git each get this long (a network hang must not keep a thread).
29
37
  COMMAND_SECONDS = 20
30
38
  # before_generation looks for a newly attached PR at most this often.
31
39
  GENERATION_CHECK_SECONDS = 30
40
+ # auto_attach's values (YAML's bare true/false: attach/off).
41
+ AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
42
+ # /pr-attach and /pr-decline take a PR number (`42` or `#42`).
43
+ PR_NUMBER = /\A#?(\d+)\z/
44
+ # The offer card's line under the PR's title.
45
+ OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
46
+ # first_prompt keeps this much of the prompt.
47
+ FIRST_PROMPT_CHARS = 160
32
48
 
33
49
  def initialize
34
50
  @cache = PrCache.new
35
51
  @generation_checked_at = nil
52
+ @warned_mode = false
36
53
  end
37
54
 
38
55
  def register(chi)
@@ -48,6 +65,13 @@ class Plugin
48
65
  # The prompt too: messages is the history before this turn, and a
49
66
  # delegate child's task is its first prompt.
50
67
  kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
68
+ log_first_prompt(event, ctx)
69
+ end
70
+ chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
71
+ offer_command("/pr-attach", args, ctx) { |number, offer| attach_offered(ctx, number, offer) }
72
+ end
73
+ chi.command("/pr-decline", "Don't attach the PR this session was offered: /pr-decline 42", anytime: true) do |args, ctx|
74
+ offer_command("/pr-decline", args, ctx) { |number, offer| decline_offered(ctx, number, offer) }
51
75
  end
52
76
  chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
53
77
  # After source-links (90): its links are in the display this one gets.
@@ -60,15 +84,19 @@ class Plugin
60
84
  return "skipped: a delegate child" if ctx.delegate?
61
85
  return "no session yet" unless ctx.session_id
62
86
 
87
+ mode = auto_attach_mode(ctx)
88
+ return "auto_attach: off" if mode == :off
89
+
63
90
  branch = run(ctx, "git", "branch", "--show-current").to_s.strip
64
91
  return "not on a branch" if branch.empty?
65
92
 
66
- json = run(ctx, "gh", "pr", "view", "--json", "number,url,state") or return "no pull request for #{branch}"
93
+ json = run(ctx, "gh", "pr", "view", "--json", "number,url,state,title") or return "no pull request for #{branch}"
67
94
  pr = JSON.parse(json)
68
95
  return "pull request ##{pr["number"]} isn't open" unless pr["state"] == "OPEN"
69
96
 
70
97
  name = "pr-#{pr["number"]}"
71
98
  return "#{name} is attached already" if ctx.context.list.any? { |source| source[:name] == name || source[:hint] == pr["url"] }
99
+ return offer_branch_pr(ctx, branch, pr, name) if mode == :offer
72
100
 
73
101
  attached = ctx.context.attach(url: pr["url"], name: name, why: "branch #{branch} has open PR ##{pr["number"]}")
74
102
  attached ? "attached #{name}" : "#{name} was removed from this session; not attached again"
@@ -77,6 +105,21 @@ class Plugin
77
105
  "no pull request attached"
78
106
  end
79
107
 
108
+ # `bundles: github-pr: auto_attach:`, read at each worker start.
109
+ # @return [Symbol] :attach, :offer or :off; an unknown value is :attach
110
+ # (logged once)
111
+ def auto_attach_mode(ctx)
112
+ value = ctx.settings["auto_attach"]
113
+ return :attach if value.nil?
114
+
115
+ key = value.is_a?(String) ? value.strip.downcase : value
116
+ AUTO_ATTACH_MODES.fetch(key) do
117
+ ctx.log.warn(:pr_auto_attach_unknown, value: value.to_s[0, 40]) unless @warned_mode
118
+ @warned_mode = true
119
+ :attach
120
+ end
121
+ end
122
+
80
123
  # The after_turn handler: links the answer's `path:line` refs from the
81
124
  # PRs' cached data. Never runs gh: nothing cached yet links nothing.
82
125
  def link_answer(event, ctx)
@@ -109,6 +152,106 @@ class Plugin
109
152
 
110
153
  def line_links?(ctx) = ctx.settings["line_links"] != false
111
154
 
155
+ def offer_card_id(number) = "github-pr-offer-#{number}"
156
+
157
+ # Offer mode: a card instead of the attach, once per session per PR (a
158
+ # worker restart finds the offered marker). The marker is written after
159
+ # the card, so a card that failed leaves none.
160
+ def offer_branch_pr(ctx, branch, pr, name)
161
+ return "#{name} was removed from this session; not offered" if ctx.context.declined?(name, url: pr["url"])
162
+ return "#{name} was offered already" if ctx.context.offered(name)
163
+
164
+ number = pr["number"]
165
+ body = [pr["title"].to_s.gsub(/\s+/, " ").strip, OFFER_LINE].reject(&:empty?).join("\n\n")
166
+ ctx.card(id: offer_card_id(number), title: "PR ##{number} for branch #{branch}", body: body, level: :info,
167
+ actions: [{ label: "Attach", command: "/pr-attach #{number}" },
168
+ { label: "Not here", command: "/pr-decline #{number}" }])
169
+ ctx.context.mark_offered(name, pr["url"], why: "branch #{branch} has open PR ##{number}")
170
+ offers_log(ctx).write("offered", session: ctx.session_id, project_root: project_root(ctx), cwd: ctx.cwd,
171
+ branch: branch, pr: pr["url"], checkout: checkout_kind(ctx), model: ctx.model)
172
+ "offered #{name}"
173
+ end
174
+
175
+ # /pr-attach and /pr-decline: the PR number and its offer, else the
176
+ # answer (a usage line, no offer).
177
+ def offer_command(command, args, ctx)
178
+ number = args.to_s.strip[PR_NUMBER, 1] or return "usage: #{command} <PR number>, as the offer card names it"
179
+ number = number.to_i
180
+ offer = ctx.context.offered("pr-#{number}")
181
+ return "no offered PR ##{number} here; use `chi context add <PR URL>`" unless offer
182
+
183
+ yield number, offer
184
+ rescue Samagotchi::Plugin::AttachedContext::Error => e
185
+ "pr-#{number} not changed: #{e.message}"
186
+ end
187
+
188
+ # The user's click: attached as auto-attach would, past a decline, with
189
+ # the offer's why (no git or gh here).
190
+ def attach_offered(ctx, number, offer)
191
+ why = "#{offer.why || "open PR ##{number}"} (attached from the offer)"
192
+ attached = ctx.context.attach(url: offer.hint, name: offer.name, why: why, force: true)
193
+ return "#{offer.name} wasn't attached" unless attached
194
+
195
+ ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Attached #{attached}")
196
+ offers_log(ctx).write("attached", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
197
+ nil
198
+ end
199
+
200
+ # Not for a PR attached here already (a stale card): that is
201
+ # `chi context rm`'s, and a declined marker would leave it attached.
202
+ def decline_offered(ctx, number, offer)
203
+ attached = ctx.context.list.find { |source| source[:name] == offer.name || source[:hint] == offer.hint }
204
+ return "#{attached[:name]} is attached here; `chi context rm #{attached[:name]}` removes it" if attached
205
+
206
+ ctx.context.decline(url: offer.hint, name: offer.name)
207
+ ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Not attached here; + URL attaches it")
208
+ offers_log(ctx).write("declined", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
209
+ nil
210
+ end
211
+
212
+ def seconds_since(offer)
213
+ (Time.now - Time.iso8601(offer.at.to_s)).round
214
+ rescue ArgumentError
215
+ nil
216
+ end
217
+
218
+ # Offer mode: every session's first prompt (offered or not; a web chat's
219
+ # first turn starts before the init task's gh returns), joined to the
220
+ # offers by session at analysis time. First = no user message yet (a
221
+ # context note may be in messages already).
222
+ def log_first_prompt(event, ctx)
223
+ return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
224
+ return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
225
+ return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
226
+
227
+ offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
+ rescue StandardError => e
229
+ ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
230
+ end
231
+
232
+ def message_role(message)
233
+ return nil unless message.is_a?(Hash)
234
+
235
+ (message.key?(:role) ? message[:role] : message["role"]).to_s
236
+ end
237
+
238
+ def offers_log(ctx) = OffersLog.new(File.join(ctx.data_dir, OffersLog::FILE), ctx.log)
239
+
240
+ def project_root(ctx)
241
+ ctx.repo_root
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ # "main" for the main checkout, "worktree" for a linked one (its git dir
247
+ # isn't the common one), nil outside a repo.
248
+ def checkout_kind(ctx)
249
+ dirs = run(ctx, "git", "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").to_s.split("\n")
250
+ return nil unless dirs.size == 2
251
+
252
+ dirs[0] == dirs[1] ? "main" : "worktree"
253
+ end
254
+
112
255
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
256
 
114
257
  # #refresh on its own thread (a raw one, docs/plugins.md), when there is
@@ -191,6 +334,39 @@ class Plugin
191
334
  end
192
335
  end
193
336
 
337
+ # <data_dir>/offers.ndjson: one JSON object a line, {event:, ts:, …},
338
+ # private to the user (0600: it holds prompts). Every worker shares it, so
339
+ # each write holds offers.ndjson.lock (flock) around the size check, the
340
+ # rotation and the append; over MAX_BYTES the file becomes
341
+ # offers.ndjson.1 (one old file kept). A failure is logged at debug: the
342
+ # log never breaks an attach.
343
+ class OffersLog
344
+ FILE = "offers.ndjson"
345
+ MAX_BYTES = 1024 * 1024
346
+ LINE_MAX_BYTES = 4000
347
+ MODE = 0o600
348
+
349
+ def initialize(path, log)
350
+ @path = path
351
+ @log = log
352
+ end
353
+
354
+ def write(event, **fields)
355
+ line = "#{JSON.generate({ event: event, ts: Time.now.utc.iso8601 }.merge(fields))}\n"
356
+ return @log.debug(:pr_offers_line_too_long, event: event) if line.bytesize > LINE_MAX_BYTES
357
+
358
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, MODE) do |lock|
359
+ lock.flock(File::LOCK_EX)
360
+ File.rename(@path, "#{@path}.1") if File.size?(@path).to_i > MAX_BYTES
361
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, MODE) { |file| file.write(line) }
362
+ end
363
+ nil
364
+ rescue StandardError => e
365
+ @log.debug(:pr_offers_log_failed, error: e.class.name)
366
+ nil
367
+ end
368
+ end
369
+
194
370
  # A GitHub pull request, by its URL.
195
371
  PrRef = Data.define(:owner, :repo, :number)
196
372
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.42.0
3
+ version: 0.43.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:
@@ -22,6 +22,7 @@ module Samagotchi
22
22
  # sessions/<id>/subscriptions.json {name => Subscription}; the worker writes it
23
23
  # sessions/<id>/muted/<name> a marker: this session ignores <name>
24
24
  # sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
25
+ # sessions/<id>/offered/<name> a marker ({hint, why, at}): a plugin offered <name> (ctx.context.mark_offered)
25
26
  # The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
26
27
  # command runs later, outside the gate.
27
28
  module ContextSources
@@ -39,6 +40,7 @@ module Samagotchi
39
40
  SUBSCRIPTIONS_FILE = "subscriptions.json"
40
41
  MUTED_DIR = "muted"
41
42
  DECLINED_DIR = "declined"
43
+ OFFERED_DIR = "offered"
42
44
 
43
45
  # A name, a command, a value the store can't take.
44
46
  class Invalid < ArgumentError; end
@@ -131,6 +133,11 @@ module Samagotchi
131
133
  # What a command printed or a push sent (the contract, ContextSources.parse_output).
132
134
  Fetched = Data.define(:text, :summary, :wake, :hint)
133
135
 
136
+ # A plugin's offer of a source to a session (ctx.context.mark_offered):
137
+ # +hint+ (often the URL), +why+ (the plugin's reason, for the attach
138
+ # that may follow; nil for none) and +at+, when it was offered (ISO 8601).
139
+ Offer = Data.define(:name, :hint, :why, :at)
140
+
134
141
  # A source as one session sees it: the definition, where it lives, and
135
142
  # +shadowed+ for a project source hidden by a session one of its name.
136
143
  Attached = Data.define(:source, :location, :shadowed) do
@@ -273,6 +280,39 @@ module Samagotchi
273
280
  AtomicFile.write(declined_path(name), "#{hint}\n")
274
281
  end
275
282
 
283
+ # Clears the declined marker of +name+ and any whose hint is +hint+
284
+ # (the user's explicit attach: a URL declined under another name).
285
+ def undecline(name, hint: nil)
286
+ FileUtils.rm_f(declined_path(name))
287
+ folder = File.join(dir, DECLINED_DIR)
288
+ return if hint.to_s.empty? || !Dir.exist?(folder)
289
+
290
+ Dir.children(folder).each do |file|
291
+ path = File.join(folder, file)
292
+ FileUtils.rm_f(path) if File.read(path).strip == hint
293
+ rescue SystemCallError
294
+ nil
295
+ end
296
+ end
297
+
298
+ def offered_path(name) = File.join(dir, OFFERED_DIR, ContextSources.check_name!(name))
299
+
300
+ # Session only: a plugin offered +name+ here (so it doesn't offer it
301
+ # again). Neither an add nor a decline clears it.
302
+ # @return [Offer]
303
+ def mark_offered(name, hint, why: nil, at: Time.now)
304
+ offer = Offer.new(name: ContextSources.check_name!(name), hint: hint, why: why, at: at.utc.iso8601)
305
+ FileUtils.mkdir_p(File.join(dir, OFFERED_DIR))
306
+ AtomicFile.write(offered_path(name), "#{JSON.generate({ "hint" => offer.hint, "why" => offer.why, "at" => offer.at }.compact)}\n")
307
+ offer
308
+ end
309
+
310
+ # @return [Offer, nil]
311
+ def offered(name)
312
+ data = ContextSources.read_json(offered_path(name))
313
+ data.is_a?(Hash) ? Offer.new(name: name, hint: data["hint"], why: data["why"], at: data["at"]) : nil
314
+ end
315
+
276
316
  # Session only: the markers and the worker's subscription file.
277
317
  def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
278
318