samagotchi 0.26.0 → 0.28.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +17 -3
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/prompt-caching.md +38 -7
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +8 -0
  15. data/lib/samagotchi/desktop/macos/App.swift +6 -4
  16. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
  17. data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
  18. data/lib/samagotchi/desktop/macos.rb +5 -1
  19. data/lib/samagotchi/engine.rb +1 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +40 -4
  21. data/lib/samagotchi/llm/prompt_cache.rb +31 -5
  22. data/lib/samagotchi/log.rb +19 -3
  23. data/lib/samagotchi/session.rb +9 -0
  24. data/lib/samagotchi/session_manager.rb +4 -3
  25. data/lib/samagotchi/sessions_command.rb +26 -13
  26. data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
  27. data/lib/samagotchi/terminal_ui.rb +5 -0
  28. data/lib/samagotchi/thinking_tails.rb +1 -1
  29. data/lib/samagotchi/tool_activity.rb +3 -2
  30. data/lib/samagotchi/tool_view.rb +14 -3
  31. data/lib/samagotchi/tools/task_get.rb +3 -2
  32. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  33. data/lib/samagotchi/tools/task_stop.rb +1 -1
  34. data/lib/samagotchi/tools/task_wait.rb +22 -10
  35. data/lib/samagotchi/turn_note.rb +4 -2
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/vision_context.rb +17 -4
  38. data/lib/samagotchi/web/app.rb +25 -0
  39. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  40. data/lib/samagotchi/web/public/app.js +4 -0
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +19 -0
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +124 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9914119c74e2e2233fcd5931386afca4fbc95b84aa7603e2f4f14289651a12c
4
- data.tar.gz: 5ba92c2703f202dffe2d4125eac11fd9d93c970a032cc56af1ce41c0827ab5ba
3
+ metadata.gz: 51c7c1a00eec47f70f15b04381eb6b60f5bd6c66c8dd11fbe05ef045c41d5219
4
+ data.tar.gz: 00cbfcba1b3b21ef54c9d54bd9de349a90a19314cb5613e9daa94ca51c8c04fe
5
5
  SHA512:
6
- metadata.gz: 881df5c769cb4d5ca994253bf7cccfbbd29d033f51e9f73533cf74926d8d6fe155c8e0e92a7a6226b770dceb4e4a816b1d78dd0ff0b1502938a6e8761a26c8d3
7
- data.tar.gz: 0a6c687f219568001c688d385221b239a2f285c66f17895c2202bf77098c40236a42cd3ca6b824b493260acb698bf6345e715ecaaa8e53ce6368e938c0bb155f
6
+ metadata.gz: 11f14c97b088efe1c1eafafb63604f44c84a7a7a79d738db5f6344191b6fa9948ceab37acd0f8daba2a85840b3f272bfcd767bd2147b0da7abbc23a04936bba4
7
+ data.tar.gz: e3136af1decc273b092f66756e2994ad0760a626cb9b007a4a4ad2f68ee85bdf6f37aab3f5a0bde85597f677bde3fde32826763c9b44001e941945abc39dfa5d
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.28.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Desktop: the panel's footer says where its text and images came from, "from clipboard" (the hotkey) or "from
16
+ selection" (Send to chi); run `chi desktop upgrade`.
17
+ - `cache.ttl: 1h` keeps a Claude model's prompt-cache breakpoints for an hour instead of 5 minutes (a write costs 2×
18
+ the input price instead of 1.25×; off by default), and `cache.key: session` sends the session id as
19
+ `prompt_cache_key` to OpenAI's API and OpenRouter (off by default).
20
+ - `chi sessions list` marks a session whose last turn loop-guard stopped with `[looped]` (another hook:
21
+ `[stopped by <name>]`); `--format json` carries `stopped_by`.
22
+
23
+ ### Changed
24
+
25
+ - Past `image.max_per_request` images, the oldest are left out in batches of half the limit instead of one per new
26
+ image, so the earlier conversation stays a cacheable prefix; the placeholder reads "an older image (chi sends up
27
+ to the newest N)".
28
+ - After loop-guard cuts a runaway reply, chi's retry note tells the model to continue the task with its next tool
29
+ call (or answer if it's done) instead of "answer briefly", which made models wrap up in the middle of a task.
30
+
31
+ ### Fixed
32
+
33
+ - REPL: a line typed ahead during a turn no longer starts a turn-end warm-up that the next turn throws away.
34
+ - Web: when one of a task's stop buttons is clicked, the other one (the stage's step list) disables too.
35
+ - Desktop: `chi desktop install` starts the helper with a bare env, so `SAMAGOTCHI_*` settings (and anything else)
36
+ from the install shell no longer stick to the helper and the chi it runs until a relaunch; run `chi desktop upgrade`.
37
+ - Desktop: ⌘⏎ on the panel's "New session" row says "A note needs a session" instead of only beeping, and the Note
38
+ button is off on that row; run `chi desktop upgrade`.
39
+ - Desktop: the panel lists live sessions in the order they started, so ⌘1…⌘9 no longer shift when a session runs a
40
+ turn (a new session comes last); run `chi desktop upgrade`.
41
+
42
+ Update with `chi update`, then `chi desktop upgrade` if you use the desktop helper.
43
+
44
+ ## [0.27.0] - 2026-10-04
45
+
46
+ ### Added
47
+
48
+ - Web: a "stop task" button on a running `task_wait` stops the background task; the turn goes on and the model is
49
+ told the user stopped it (Cancel still cancels the turn and leaves the task running).
50
+
51
+ ### Changed
52
+
53
+ - `/mcp` lists each server's tools alphabetically, not in the order the server sent them (mcp bundle 0.4.3).
54
+
55
+ ### Fixed
56
+
57
+ - A stopped background task ends as `stopped`, not `failed`, and records who stopped it (the user or the model).
58
+ - Code blocks in the web follow the light theme too: their highlighting comes from the page's palette instead of one
59
+ fixed dark theme.
60
+
61
+ Update with `chi update` (mcp 0.4.3).
62
+
11
63
  ## [0.26.0] - 2026-10-04
12
64
 
13
65
  ### Added
@@ -1419,7 +1471,9 @@ and long-lived sessions.
1419
1471
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1420
1472
  hotkey panel that send selected text or the clipboard to your sessions.
1421
1473
 
1422
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.26.0...HEAD
1474
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.28.0...HEAD
1475
+ [0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
1476
+ [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
1423
1477
  [0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
1424
1478
  [0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
1425
1479
  [0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
data/docs/architecture.md CHANGED
@@ -246,6 +246,12 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
246
246
  `guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
247
247
  - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
248
248
  `200`, or `409` when it is no longer pending.
249
+ - `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
250
+ (`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
251
+ web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
252
+ `task_create` started (the saved messages, or the running turn's accumulator); answers once the
253
+ task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
254
+ The worker names it in its sidecar features (`task_stop`).
249
255
  - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
250
256
  (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
251
257
  pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
data/docs/cli.md CHANGED
@@ -495,9 +495,12 @@ A model that can see images gets them these ways:
495
495
  Images are downscaled to a 1568 px long side (with `sips` on macOS or
496
496
  ImageMagick; without either, a larger image is refused with a hint) and stored
497
497
  next to the session in `<session>/images/`, and the session file keeps small
498
- references to them. Each request sends the newest 20 images of the conversation;
499
- older ones become a line like `[image shot.png 1280×800 not sent: only the
500
- newest 20 images are sent]`.
498
+ references to them. Each request sends up to the newest 20 images of the
499
+ conversation (`image.max_per_request`); past that the oldest are left out ten at
500
+ a time (half the limit), so the earlier conversation stays the same between
501
+ requests and the server's prompt cache keeps it. A left-out image becomes a line
502
+ like `[image shot.png 1280×800 not sent: an older image (chi sends up to the
503
+ newest 20)]`.
501
504
 
502
505
  A model that can't see images (a text-only model, llama.cpp without
503
506
  `--mmproj`, an mlx host) refuses a turn with images before sending it:
@@ -519,8 +519,20 @@ OpenAI-compatible host get two prompt-cache breakpoints (`cache_control` on the
519
519
  message), so each step reads the earlier prompt from Anthropic's cache instead of paying for it again. The system
520
520
  prompt goes as two text parts, the breakpoint after the first: everything sessions share, then the model, working
521
521
  directory and session lines, so a new session reads the shared part from the cache as well; the debug
522
- log's line shows `cache=on`. Nothing to configure. A provider behind a gateway may still not cache: check the
523
- cached tokens in `/stats`. Each request's own counts are in the debug log's `generation_completed` line:
522
+ log's line shows `cache=on`. A provider behind a gateway may still not cache: check the cached tokens in `/stats`.
523
+
524
+ Anthropic drops a cache entry 5 minutes after its last read, so a session you come back to after a longer pause
525
+ writes its prompt again. `cache.ttl: 1h` keeps the breakpoints an hour (the log shows `cache=1h`); a write then
526
+ costs 2× the input price instead of 1.25×, so it pays off when you often pause 5–60 minutes between turns.
527
+ `cache.key: session` sends the session id as `prompt_cache_key` (OpenAI's API and OpenRouter only; the log shows
528
+ `cache_by=session`), which OpenAI uses to route a conversation to the machine that holds its cache:
529
+
530
+ ```yaml
531
+ cache:
532
+ ttl: 1h # 5m (default) or 1h
533
+ key: session # off (default) or session
534
+ ```
535
+ Each request's own counts are in the debug log's `generation_completed` line:
524
536
  `prompt=` (its prompt tokens), `cached=` (read from the server's cache) and `cache_write=` (written to it, when the
525
537
  server reports writes); recap and side requests log theirs as `recap request_usage`.
526
538
 
@@ -784,7 +796,7 @@ downscaled first; three settings bound them (env `SAMAGOTCHI_IMAGE_*` or
784
796
  image:
785
797
  max_side: 1568 # long side in px (Claude's standard; ~1.3k tokens for 1280×800)
786
798
  max_bytes: 3750000 # larger after downscaling → re-encoded as JPEG
787
- max_per_request: 20 # older images in the conversation become placeholder lines
799
+ max_per_request: 20 # older images become placeholder lines, dropped in batches of half this
788
800
  ```
789
801
 
790
802
  Whether a model can see images is found out before a turn with images is sent:
@@ -899,6 +911,8 @@ described in their own sections.
899
911
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
900
912
  | `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
901
913
  | `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
914
+ | `cache.ttl` | `5m` | | How long Anthropic keeps the prompt-cache breakpoints of a Claude model on a remote host: `5m` (Anthropic's default) or `1h`. A 1h cache write costs 2× the input price (5m: 1.25×), a read the same 0.1×. See "Remote or local". |
915
+ | `cache.key` | `off` | | `session`: send the session id as `prompt_cache_key` to OpenAI's API and OpenRouter, which route and cache by it; no other host gets it. `off`: none. |
902
916
  | `retry.max` | `5` | yes | See "Llama Network Retry Behavior". |
903
917
  | `retry.base_delay` | `0.5` | yes | |
904
918
  | `retry.max_delay` | `8.0` | yes | |
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
59
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
60
60
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
61
 
62
62
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -43,9 +43,9 @@ right-hand column happens.
43
43
  shared by every turn of this session ─────────────────────────────────────
44
44
  ┌──────────────────────────────────────────────────────────────────────────┐
45
45
  │ history user / assistant / tool messages, APPEND-ONLY │ !rollback, a failed or
46
- │ (images: the newest N, ImagePlan) │ cancelled turn's tail,
47
- │ ◄── remote Claude: breakpoint #2 on the last │ the image window
48
- │ non-system message │ sliding, history
46
+ │ (images: up to the newest N, cut in batches; ImagePlan) │ cancelled turn's tail,
47
+ │ ◄── remote Claude: breakpoint #2 on the last │ the image cut moving
48
+ │ non-system message │ a batch, history
49
49
  ├──────────────────────────────────────────────────────────────────────────┤ sanitizing (thinking
50
50
  │ tail notes reminders, turn notes, steers (system/user messages) │ stripped per spec)
51
51
  │ new prompt this turn's user message │
@@ -83,7 +83,39 @@ user message starts (`KernelLoop#warmup_prompt`) and sends it in a thread (`Clie
83
83
  server has since cleared skips its prompt cache and prefills everything (measured 0.5 s → 12 s). For the same
84
84
  reason `id_slot` is a reserved sampling key.
85
85
  - What breaks it (the warm-up simply misses): a system-prompt rebuild (`/thinking`, `/model`, a tool change),
86
- the image window sliding, `!rollback`.
86
+ the image cut moving a batch, `!rollback`.
87
+
88
+ ## The image window
89
+
90
+ A request sends at most `image.max_per_request` images (20 by default); older ones become placeholder lines
91
+ (`[image a.png 1280×800 not sent: an older image (chi sends up to the newest 20)]`). If the window slid by one
92
+ image per new image, every new image would turn the oldest sent one into a placeholder and rewrite history from
93
+ there. So the cut is a pure function of the image count (`ImagePlan.dropped_count`): nothing is dropped up to
94
+ the limit, then the oldest in whole steps of half the limit (21–30 images drop 10, 31–40 drop 20). Between two
95
+ step boundaries the earlier messages stay byte-identical; at a boundary the history from the newly cut image on
96
+ is prefilled once. The native and the chat loop use the same plan.
97
+
98
+ ## Remote caches: TTL and routing
99
+
100
+ - **TTL.** Anthropic keeps a breakpoint 5 minutes after its last read; a session the user comes back to later
101
+ writes its prompt again. `cache.ttl: 1h` sends `cache_control: {type: "ephemeral", ttl: "1h"}` on both
102
+ breakpoints (the log line shows `cache=1h`). Off by default: a 1h write costs 2× the input price, a 5m write
103
+ 1.25×, a read 0.1× either way, so 1h pays off only when pauses of 5–60 minutes are common.
104
+ - **Routing.** Every chat request carries a `Session-Id` header, which OpenRouter uses to keep a conversation on
105
+ one provider. `cache.key: session` also sends the session id as `prompt_cache_key` in the body, the field
106
+ OpenAI's API routes and caches by (OpenRouter honours it too); the log line shows `cache_by=session`. Only
107
+ `api.openai.com` and OpenRouter get it, and only the session's own chat requests (not recap or `/btw`): a local
108
+ llama.cpp, Splash or another gateway may reject an unknown field. Off by default.
109
+
110
+ ## Reading the numbers
111
+
112
+ - The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
113
+ server's cache) and `cache_write=` (written to it, where the provider reports writes). The `http` line of a
114
+ remote Claude request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
115
+ - The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
116
+ - `/stats`: "cached N (P%)" and "cache writes" for the session.
117
+ - A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
118
+ same project reads everything up to the volatile tail.
87
119
 
88
120
  ## Rules for changes
89
121
 
@@ -114,7 +146,6 @@ user message starts (`KernelLoop#warmup_prompt`) and sends it in a thread (`Clie
114
146
  a slot (`id_slot`) is the hazard. With `--cache-ram 0` or an old single-slot build, a recap does evict the
115
147
  session. `--ctx-checkpoints` controls how many restore points a hybrid model keeps. chi sends
116
148
  `cache_prompt: true` on every native request.
117
- - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes. Provider routing (which upstream serves a
118
- request) also decides whether a cache is there to hit.
149
+ - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes (`cache.ttl: 1h` for an hour, see above).
150
+ Provider routing (which upstream serves a request) also decides whether a cache is there to hit.
119
151
 
120
- Still to come: image-window batches and longer remote TTLs.
data/docs/sessions.md CHANGED
@@ -56,7 +56,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  `--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
58
58
 
59
- `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
59
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
60
60
 
61
61
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
62
62
 
@@ -27,6 +27,8 @@ require_relative "version"
27
27
  require_relative "worker_sidecar"
28
28
  require_relative "relay_verifier"
29
29
  require_relative "relay_watcher"
30
+ require_relative "tools/task_create"
31
+ require_relative "tools/task_runtime"
30
32
 
31
33
  module Samagotchi
32
34
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -461,7 +463,8 @@ module Samagotchi
461
463
  # sidecar (WorkerSidecar#features): a client checks a name here rather
462
464
  # than a version.
463
465
  # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
464
- FEATURES = ["restart"].freeze
466
+ # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
467
+ FEATURES = %w[restart task_stop].freeze
465
468
 
466
469
  # The routes, by method and what follows /session/:id/: a handler takes
467
470
  # the session id and the request body and returns [headers, status, body].
@@ -477,6 +480,7 @@ module Samagotchi
477
480
  %w[POST recap] => :handle_recap,
478
481
  %w[POST relay] => :handle_relay,
479
482
  %w[POST relay/status] => :handle_relay_status,
483
+ %w[POST tasks/stop] => :handle_task_stop,
480
484
  %w[GET state] => :handle_state,
481
485
  %w[GET stats] => :handle_stats,
482
486
  %w[GET snapshot] => :handle_snapshot,
@@ -539,6 +543,38 @@ module Samagotchi
539
543
  end
540
544
  end
541
545
 
546
+ # POST /session/:id/tasks/stop {task_id}: stop a background task as the
547
+ # user (TaskRuntime.stop_task by: "user"); a task_wait on it returns, and
548
+ # the turn goes on. Only a task this conversation created: records in
549
+ # tmp/tasks are shared by every session in the cwd. Those are in the
550
+ # saved conversation, or, created in the running turn, only in the
551
+ # accumulator's task_create parts. 200 {status, stop_reason, task_id},
552
+ # 404 task_not_found, 409 not_running (it already ended), 400 no id.
553
+ # Answers once the task is gone: up to STOP_GRACE_SEC.
554
+ def handle_task_stop(_session_id, body)
555
+ parsed = parse_json(body)
556
+ task_id = (fetched(parsed, "task_id") if parsed.is_a?(Hash)).to_s.strip
557
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "task_id required" }] if task_id.empty?
558
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] unless own_task_ids.include?(task_id)
559
+
560
+ record, error = Tools::TaskRuntime.get_record(task_id)
561
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
562
+ return [{}, 409, { error: "not_running", status: record["status"], task_id: task_id }] unless record["status"] == "running"
563
+
564
+ Log.info(:bridge, "task_stop", task_id: task_id)
565
+ record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
566
+ return [{}, 500, { error: "bridge_error", detail: error }] if error
567
+
568
+ [{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
569
+ end
570
+
571
+ def own_task_ids
572
+ running = Array(@accumulator.current_turn&.dig(:parts)).filter_map do |part|
573
+ part[:output].to_s[/\A(?:\[task_create\]\n)?task_id: (\S+)/, 1] if part[:kind] == "tool" && part[:tool] == Tools::TaskCreate::NAME
574
+ end
575
+ Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint) | running
576
+ end
577
+
542
578
  # Answer the pending question. A past +deadline+ (see #handle_post_turn):
543
579
  # 408 deadline_passed, and the question stays open. An answer takes no
544
580
  # event hold, so it is checked right before it is recorded.
@@ -187,6 +187,15 @@ module Samagotchi
187
187
  post("cancel", { reason: reason }, read_body: true)
188
188
  end
189
189
 
190
+ # POST /session/:id/tasks/stop: stop a background task this session's
191
+ # conversation created, as the user (Bridge#handle_task_stop). 200 with
192
+ # its final status, 404 task_not_found, 409 not_running. Synchronous:
193
+ # the worker answers once the task is gone (up to its 3 s grace).
194
+ # @return [Response]
195
+ def stop_task(task_id)
196
+ post("tasks/stop", { task_id: task_id }, read_body: true)
197
+ end
198
+
190
199
  # How long the approval relay's calls wait for a reply: a parent's Stop
191
200
  # isn't held for READ_TIMEOUT by a child that doesn't answer.
192
201
  RELAY_TIMEOUT = 5
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.2
3
+ version: 0.4.3
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:d0491b038e7f111f9f8c629729a2b0525d573fd0b17d48ea8ec59c4a2b8b821b
10
+ sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
11
11
  requires_chi: ">= 0.8.0"
@@ -752,7 +752,8 @@ class Plugin
752
752
 
753
753
  @servers.map do |server|
754
754
  head = "**#{server.name}**: #{state_text(server)}"
755
- tools = server.tools.map { |tool| tool["chi_name"] }.compact
755
+ # Sorted: a server lists its tools in its own (often grouped) order.
756
+ tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
756
757
  tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
757
758
  end.join("\n\n")
758
759
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.26.0
3
+ version: 0.28.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:
@@ -142,6 +142,14 @@ module Samagotchi
142
142
  # prompt on a local llama.cpp host while the user reads; off never
143
143
  # does. A :string, since YAML reads an unquoted off as false.
144
144
  Entry.new(key: "cache.warmup", yaml_path: %w[cache warmup], type: :string, default: "auto", expose: %i[env config]),
145
+ # How long Anthropic keeps a cache breakpoint (PromptCache): 5m, or
146
+ # 1h for long reading gaps (a 1h write costs 2x the input price, a 5m
147
+ # one 1.25x, so it's opt-in).
148
+ Entry.new(key: "cache.ttl", yaml_path: %w[cache ttl], type: :string, default: "5m", expose: %i[env config]),
149
+ # session sends the session id as `prompt_cache_key` to OpenAI's API
150
+ # and OpenRouter (OpenAIChat), which route and cache by it; off sends
151
+ # none. A :string, since YAML reads an unquoted off as false.
152
+ Entry.new(key: "cache.key", yaml_path: %w[cache key], type: :string, default: "off", expose: %i[env config]),
145
153
 
146
154
  Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
147
155
  Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
@@ -18,7 +18,7 @@ final class ServiceProvider: NSObject {
18
18
  error.pointee = "No text or image in the selection" as NSString
19
19
  return
20
20
  }
21
- app?.open(text: text, images: images)
21
+ app?.open(text: text, images: images, origin: "selection")
22
22
  }
23
23
  }
24
24
 
@@ -60,12 +60,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
60
60
  /// The hotkey doesn't activate us, so the frontmost app is the source.
61
61
  func openFromClipboard() {
62
62
  let (text, images) = ImageIntake.read(NSPasteboard.general)
63
- open(text: text, images: images)
63
+ open(text: text, images: images, origin: "clipboard")
64
64
  }
65
65
 
66
- func open(text: String, images: [PanelImage] = []) {
66
+ /// @param origin "clipboard" (the hotkey) or "selection" (the Service)
67
+ func open(text: String, images: [PanelImage] = [], origin: String) {
67
68
  let from = sourceApp
68
- panel.show(text: text, images: images, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
69
+ panel.show(text: text, images: images, origin: origin, source: from?.localizedName?.lowercased() ?? "desktop",
70
+ returnTo: from)
69
71
  }
70
72
  }
71
73
 
@@ -118,11 +118,13 @@ final class ChiRunner {
118
118
  stdin: stdin, timeout: timeout ?? self.timeout, completion: completion)
119
119
  }
120
120
 
121
- /// The live sessions, then up to +recentCount+ stopped ones, newest
122
- /// first. A session a chi REPL holds is in neither: it takes no notes or
121
+ /// The live sessions, oldest started first, then up to +recentCount+
122
+ /// stopped ones, newest first. The live order is by creation so ⌘1…⌘9
123
+ /// stay on their sessions as turns update them; a new one comes last.
124
+ /// A session a chi REPL holds is in neither: it takes no notes or
123
125
  /// messages. A failed second call just leaves "recent" empty.
124
126
  func sessions(recentCount: Int = 3, completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
125
- list(["--live"]) { result in
127
+ list(["--live", "--sort", "created_at", "--order", "asc"]) { result in
126
128
  guard case .success(let live) = result else { completion(result); return }
127
129
  self.list(["--limit", "20"]) { recent in
128
130
  let liveIds = Set(live.map(\.id))
@@ -20,6 +20,9 @@ final class PanelModel: ObservableObject {
20
20
  /// The selection or clipboard: the quoted context of a message, or a note.
21
21
  @Published var text = ""
22
22
  @Published var source = ""
23
+ /// The path the opening text and images took: "clipboard" (the hotkey)
24
+ /// or "selection" (the Service); "" when the panel opened empty.
25
+ @Published var origin = ""
23
26
  /// Sent as `--image` with a message; notes are text only.
24
27
  @Published var images: [PanelImage] = []
25
28
  /// The thumbnail under the pointer (shows its ✕). Kept here, not in a
@@ -74,8 +77,9 @@ final class PanelModel: ObservableObject {
74
77
  }
75
78
  var live: [LiveSession] { sessions.filter { !$0.recent } }
76
79
 
77
- /// One list for rows, ⌘ numbers and selection: live sessions, then kitty
78
- /// windows, then recent sessions.
80
+ /// One list for rows, ⌘ numbers and selection: live sessions (oldest
81
+ /// started first: their numbers stay put), then kitty windows, then
82
+ /// recent sessions.
79
83
  var targets: [Target] { live.map(Target.chi) + kittyWindows.map(Target.kitty) + recent.map(Target.chi) }
80
84
 
81
85
  /// Up to maxImages; the rest is dropped (and its temp files deleted).
@@ -534,9 +538,17 @@ struct PanelView: View {
534
538
  .padding(.horizontal, 8).padding(.vertical, 4)
535
539
  .background(Capsule().fill(Color.primary.opacity(0.07)))
536
540
 
537
- Text(byteLabel)
538
- .font(.system(size: 11).monospacedDigit())
539
- .foregroundColor(model.bytes > noteCap ? .red : .secondary)
541
+ HStack(spacing: 0) {
542
+ if !model.origin.isEmpty {
543
+ Text("from \(model.origin) · ").foregroundColor(.secondary)
544
+ .help(model.origin == "clipboard" ? "opened with the hotkey: the text and images are the clipboard's"
545
+ : "opened with Send to chi: the text and images are the selection")
546
+ }
547
+ Text(byteLabel).foregroundColor(model.bytes > noteCap ? .red : .secondary)
548
+ }
549
+ .font(.system(size: 11).monospacedDigit())
550
+ .lineLimit(1)
551
+ .fixedSize()
540
552
 
541
553
  Text(model.message)
542
554
  .font(.system(size: 11))
@@ -554,8 +566,9 @@ struct PanelView: View {
554
566
  .help("paste it into the agent's input without Enter: add more, then press Enter there")
555
567
  } else {
556
568
  Button("Note ⌘⏎") { send(.note) }
557
- .disabled(!model.sendEnabled || !model.selectedKitty.isEmpty)
558
- .help("add it as background the model sees on its next turn; starts no turn")
569
+ .disabled(!model.sendEnabled || !model.selectedKitty.isEmpty || model.newSelected)
570
+ .help(model.newSelected ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
571
+ : "add it as background the model sees on its next turn; starts no turn")
559
572
  }
560
573
  Button(action: { send(.message) }) {
561
574
  if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
@@ -753,8 +766,9 @@ final class PanelController: NSObject, NSWindowDelegate {
753
766
  return true
754
767
  }
755
768
 
769
+ /// @param origin "clipboard" or "selection": where text and images came from
756
770
  /// @param returnTo the app to give focus back to on close
757
- func show(text: String, images: [PanelImage] = [], source: String, returnTo app: NSRunningApplication?) {
771
+ func show(text: String, images: [PanelImage] = [], origin: String, source: String, returnTo app: NSRunningApplication?) {
758
772
  returnTo = app
759
773
  // A show on an open panel resets it without a close.
760
774
  dropImages()
@@ -762,6 +776,7 @@ final class PanelController: NSObject, NSWindowDelegate {
762
776
  model.images = []
763
777
  model.add(images)
764
778
  model.text = text
779
+ model.origin = text.isEmpty && images.isEmpty ? "" : origin
765
780
  model.source = source
766
781
  model.message = ""
767
782
  model.newSelected = false
@@ -873,13 +888,18 @@ final class PanelController: NSObject, NSWindowDelegate {
873
888
  model.message = "Paste only is for kitty windows"
874
889
  return
875
890
  }
891
+ // A note into a session that doesn't exist yet makes no sense.
892
+ if kind == .note, new {
893
+ NSSound.beep()
894
+ model.message = "A note needs a session"
895
+ return
896
+ }
876
897
  if kind == .note, !model.images.isEmpty {
877
898
  NSSound.beep()
878
899
  model.message = "Notes are text only: ⏎ sends the image as a message"
879
900
  return
880
901
  }
881
- // A note into a session that doesn't exist yet makes no sense.
882
- guard model.sendEnabled, new ? kind == .message : !(ids.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
902
+ guard model.sendEnabled, new || !(ids.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
883
903
  model.phase = .sending
884
904
  model.message = ""
885
905
  if !new {
@@ -310,7 +310,11 @@ module Samagotchi
310
310
  # type may otherwise show only after a re-login).
311
311
  @runner.run([LSREGISTER, "-f", app_path])
312
312
  @runner.run([PBS, "-update"])
313
- @runner.run(["open", "-g", app_path])
313
+ # open hands the app its own env, so the install shell's
314
+ # (SAMAGOTCHI_* knobs, tokens) would stay in the helper and in every
315
+ # chi it runs until a relaunch. Through env -i the app gets launchd's
316
+ # bare env, as at login; launch.json carries what chi needs.
317
+ @runner.run(["/usr/bin/env", "-i", "HOME=#{@env["HOME"] || Dir.home}", "/usr/bin/open", "-g", app_path])
314
318
  end
315
319
 
316
320
  def quit
@@ -1780,7 +1780,7 @@ module Samagotchi
1780
1780
  # A caller's check that the next turn starts at once (input queued for
1781
1781
  # it: a worker's inbox) or that none follows (a one-shot run): no
1782
1782
  # warm-up then. nil: neither.
1783
- attr_writer :next_turn_waiting
1783
+ attr_accessor :next_turn_waiting
1784
1784
 
1785
1785
  # After a completed turn, prefill the next turn's prompt up to its user
1786
1786
  # message while the user reads (PromptWarmup, cache.warmup). Only on a
@@ -80,6 +80,17 @@ module Samagotchi
80
80
  false
81
81
  end
82
82
 
83
+ # true when +url+ is OpenAI's own API (api.openai.com).
84
+ def self.openai?(url)
85
+ URI(url.to_s).host.to_s.downcase == "api.openai.com"
86
+ rescue URI::Error
87
+ false
88
+ end
89
+
90
+ # Whether cache.key asks for the session id as `prompt_cache_key`
91
+ # (session; off, the default, or anything else sends none).
92
+ def self.cache_key? = Samagotchi::Config.get("cache.key").to_s.strip.downcase == "session"
93
+
83
94
  # Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
84
95
  def self.parse_arguments(raw)
85
96
  return raw if raw.is_a?(Hash)
@@ -141,10 +152,10 @@ module Samagotchi
141
152
  # @return [ChatResponse]
142
153
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
143
154
  session_id: nil)
144
- body = request_body(messages, tools, model, options)
155
+ body = request_body(messages, tools, model, options, session_id)
145
156
  request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
146
157
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
147
- cache: ("on" if PromptCache.marked?(body[:messages])) }
158
+ cache: cache_summary(body[:messages]), cache_by: ("session" if body.key?(:prompt_cache_key)) }
148
159
  return chat_once(request, cancel_controller, log_fields) unless @stream
149
160
 
150
161
  assembly = Assembly.new
@@ -236,7 +247,7 @@ module Samagotchi
236
247
  [[open_timeout, timeout].min, timeout]
237
248
  end
238
249
 
239
- def request_body(messages, tools, model, options)
250
+ def request_body(messages, tools, model, options, session_id)
240
251
  body = {
241
252
  model: model,
242
253
  messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
@@ -244,6 +255,7 @@ module Samagotchi
244
255
  }
245
256
  body[:stream_options] = { include_usage: true } if @stream
246
257
  body[:max_tokens] = @default_max_tokens if @default_max_tokens
258
+ body[:prompt_cache_key] = session_id.to_s if prompt_cache_key?(session_id)
247
259
  unless Array(tools).empty?
248
260
  body[:tools] = tools
249
261
  body[:tool_choice] = "auto"
@@ -264,10 +276,34 @@ module Samagotchi
264
276
  PromptCache.mark(messages, model: model)
265
277
  end
266
278
 
279
+ # The session id as `prompt_cache_key` (cache.key: session) goes only
280
+ # to the APIs that document it, OpenAI's and OpenRouter's (which also
281
+ # routes by it), for the session's own chat requests: a local server or
282
+ # another gateway may reject an unknown field.
283
+ def prompt_cache_key?(session_id)
284
+ return false if session_id.to_s.empty? || !@remote || @purpose != "chat"
285
+ return false unless self.class.openai?(@base_url) || self.class.openrouter?(@base_url)
286
+
287
+ self.class.cache_key?
288
+ rescue StandardError
289
+ false
290
+ end
291
+
292
+ # The log line's cache= : on for breakpoints with Anthropic's default
293
+ # TTL, 1h for cache.ttl 1h, nil for none.
294
+ def cache_summary(messages)
295
+ return nil unless PromptCache.marked?(messages)
296
+
297
+ hour = Array(messages).any? do |message|
298
+ Array(message[:content]).any? { |part| part.is_a?(Hash) && part.dig(:cache_control, :ttl) == "1h" }
299
+ end
300
+ hour ? "1h" : "on"
301
+ end
302
+
267
303
  # The body's fields beyond the conversation and the stream, for the
268
304
  # log line: "temperature=0.6 presence_penalty=1.5", nil for none.
269
305
  def sampling_summary(body)
270
- SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
306
+ SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice, :prompt_cache_key))
271
307
  end
272
308
 
273
309
  # A String stays a String, an Array of parts an Array; both scrubbed