samagotchi 0.27.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/docs/cli.md +6 -3
- data/docs/configuration.md +17 -3
- data/docs/internals/prompt-caching.md +38 -7
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/desktop/macos/App.swift +6 -4
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
- data/lib/samagotchi/desktop/macos.rb +5 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +40 -4
- data/lib/samagotchi/llm/prompt_cache.rb +31 -5
- data/lib/samagotchi/session.rb +9 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +26 -13
- data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/turn_note.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_context.rb +17 -4
- data/lib/samagotchi/web/public/task_stop.js +24 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51c7c1a00eec47f70f15b04381eb6b60f5bd6c66c8dd11fbe05ef045c41d5219
|
|
4
|
+
data.tar.gz: 00cbfcba1b3b21ef54c9d54bd9de349a90a19314cb5613e9daa94ca51c8c04fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11f14c97b088efe1c1eafafb63604f44c84a7a7a79d738db5f6344191b6fa9948ceab37acd0f8daba2a85840b3f272bfcd767bd2147b0da7abbc23a04936bba4
|
|
7
|
+
data.tar.gz: e3136af1decc273b092f66756e2994ad0760a626cb9b007a4a4ad2f68ee85bdf6f37aab3f5a0bde85597f677bde3fde32826763c9b44001e941945abc39dfa5d
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,39 @@ 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
|
+
|
|
11
44
|
## [0.27.0] - 2026-10-04
|
|
12
45
|
|
|
13
46
|
### Added
|
|
@@ -1438,7 +1471,8 @@ and long-lived sessions.
|
|
|
1438
1471
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1439
1472
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1440
1473
|
|
|
1441
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
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
|
|
1442
1476
|
[0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
|
|
1443
1477
|
[0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
|
|
1444
1478
|
[0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
|
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
|
|
499
|
-
|
|
500
|
-
|
|
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:
|
data/docs/configuration.md
CHANGED
|
@@ -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`.
|
|
523
|
-
|
|
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
|
|
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 | |
|
|
@@ -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)
|
|
47
|
-
│ ◄── remote Claude: breakpoint #2 on the last │ the image
|
|
48
|
-
│ non-system message │
|
|
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
|
|
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.
|
|
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
|
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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+
|
|
122
|
-
///
|
|
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
|
|
78
|
-
///
|
|
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
|
-
|
|
538
|
-
.
|
|
539
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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: ("
|
|
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
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
|
|
3
6
|
module Samagotchi
|
|
4
7
|
module LLM
|
|
5
8
|
# Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
|
|
@@ -17,11 +20,33 @@ module Samagotchi
|
|
|
17
20
|
# the stable part with the mark, the per-session tail (model, location,
|
|
18
21
|
# session) without, so a new session reads the stable part from the
|
|
19
22
|
# cache. `cache_split:` never goes on the wire (#without_split).
|
|
23
|
+
#
|
|
24
|
+
# cache.ttl: 1h marks the breakpoints with `ttl: "1h"` (#control), so a
|
|
25
|
+
# cache outlives a long reading gap; 5m, the default, sends Anthropic's
|
|
26
|
+
# default (no ttl).
|
|
20
27
|
module PromptCache
|
|
21
28
|
CONTROL = { type: "ephemeral" }.freeze
|
|
29
|
+
CONTROL_1H = { type: "ephemeral", ttl: "1h" }.freeze
|
|
30
|
+
TTLS = %w[5m 1h].freeze
|
|
22
31
|
|
|
23
32
|
module_function
|
|
24
33
|
|
|
34
|
+
# The cache_control a breakpoint carries under cache.ttl (an unknown
|
|
35
|
+
# value warns once and is 5m).
|
|
36
|
+
def control
|
|
37
|
+
value = Config.get("cache.ttl").to_s.strip.downcase
|
|
38
|
+
return CONTROL_1H if value == "1h"
|
|
39
|
+
|
|
40
|
+
unless value.empty? || value == "5m" || @warned_ttl
|
|
41
|
+
@warned_ttl = true
|
|
42
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.ttl: #{value.inspect} (allowed: 5m, 1h) — using 5m",
|
|
43
|
+
key: "cache.ttl")
|
|
44
|
+
end
|
|
45
|
+
CONTROL
|
|
46
|
+
rescue StandardError
|
|
47
|
+
CONTROL
|
|
48
|
+
end
|
|
49
|
+
|
|
25
50
|
# Whether +model+ (the bare id a host is asked for) names a Claude model.
|
|
26
51
|
def claude?(model) = model.to_s.match?(/claude/i)
|
|
27
52
|
|
|
@@ -34,8 +59,9 @@ module Samagotchi
|
|
|
34
59
|
return without_split(messages) unless claude?(model)
|
|
35
60
|
|
|
36
61
|
indexes = [system_index(messages), last_index(messages)].compact.uniq
|
|
62
|
+
breakpoint = control
|
|
37
63
|
messages.each_with_index.map do |message, index|
|
|
38
|
-
next marked(message.except(:cache_split), split: message[:cache_split]) if indexes.include?(index)
|
|
64
|
+
next marked(message.except(:cache_split), breakpoint, split: message[:cache_split]) if indexes.include?(index)
|
|
39
65
|
|
|
40
66
|
message.key?(:cache_split) ? message.except(:cache_split) : message
|
|
41
67
|
end
|
|
@@ -80,15 +106,15 @@ module Samagotchi
|
|
|
80
106
|
# A String becomes one text part (two at +split+, the mark on the
|
|
81
107
|
# first); an Array gets the mark on its last text part (the last part
|
|
82
108
|
# when none is text).
|
|
83
|
-
def marked(message, split: nil)
|
|
109
|
+
def marked(message, breakpoint, split: nil)
|
|
84
110
|
content = message[:content]
|
|
85
111
|
if content.is_a?(String)
|
|
86
112
|
if split.is_a?(Integer) && split.positive? && split < content.length
|
|
87
|
-
return message.merge(content: [{ type: "text", text: content[0, split], cache_control:
|
|
113
|
+
return message.merge(content: [{ type: "text", text: content[0, split], cache_control: breakpoint },
|
|
88
114
|
{ type: "text", text: content[split..] }])
|
|
89
115
|
end
|
|
90
116
|
|
|
91
|
-
return message.merge(content: [{ type: "text", text: content, cache_control:
|
|
117
|
+
return message.merge(content: [{ type: "text", text: content, cache_control: breakpoint }])
|
|
92
118
|
end
|
|
93
119
|
|
|
94
120
|
target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
|
|
@@ -96,7 +122,7 @@ module Samagotchi
|
|
|
96
122
|
part = content[target]
|
|
97
123
|
key = part.key?("type") ? "cache_control" : :cache_control
|
|
98
124
|
parts = content.dup
|
|
99
|
-
parts[target] = part.merge(key =>
|
|
125
|
+
parts[target] = part.merge(key => breakpoint)
|
|
100
126
|
message.merge(content: parts)
|
|
101
127
|
end
|
|
102
128
|
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -62,6 +62,15 @@ module Samagotchi
|
|
|
62
62
|
# "ended_at" => iso8601, "seconds" => Float, "origin" =>
|
|
63
63
|
# "client"|"reminder"|"delegate"}; nil before the first turn.
|
|
64
64
|
attr_accessor :last_turn
|
|
65
|
+
|
|
66
|
+
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
67
|
+
# left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
|
|
68
|
+
# and symbols are read (the file's keys are strings).
|
|
69
|
+
def stopped_by
|
|
70
|
+
return nil unless @last_turn.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
(@last_turn["stopped_by"] || @last_turn[:stopped_by])&.to_s
|
|
73
|
+
end
|
|
65
74
|
# Archived (ArchiveStore): hidden from the lists. Set by .list (with
|
|
66
75
|
# include_archived); not saved in session.json.
|
|
67
76
|
attr_accessor :archived
|
|
@@ -259,12 +259,13 @@ module Samagotchi
|
|
|
259
259
|
# @param include_archived [Boolean] archived sessions too
|
|
260
260
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
261
261
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
262
|
-
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
|
|
262
|
+
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:, stopped_by:}; busy = live with
|
|
263
263
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
264
264
|
# Session#project_root, waiting = the kind of question it waits on
|
|
265
265
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
266
266
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
267
|
-
# whose card it waits in too (the approval relay), its short id, or nil
|
|
267
|
+
# whose card it waits in too (the approval relay), its short id, or nil,
|
|
268
|
+
# stopped_by = the hook that stopped the last turn (Session#stopped_by)
|
|
268
269
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
269
270
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
270
271
|
sd = state_dir || Session.default_state_dir
|
|
@@ -284,7 +285,7 @@ module Samagotchi
|
|
|
284
285
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
285
286
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
286
287
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
|
|
287
|
-
scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
288
|
+
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
288
289
|
end
|
|
289
290
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
290
291
|
end
|
|
@@ -112,6 +112,28 @@ module Samagotchi
|
|
|
112
112
|
options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# The flags a list row carries: a chi scratch session, a test run, an
|
|
116
|
+
# archived one, and the turn's stopper (see #stopped_flag).
|
|
117
|
+
def session_flag(scratch:, test_run:, archived:, stopped_by: nil)
|
|
118
|
+
flag = if scratch
|
|
119
|
+
" [scratch]"
|
|
120
|
+
else
|
|
121
|
+
(test_run ? " [test]" : "")
|
|
122
|
+
end
|
|
123
|
+
flag += " [archived]" if archived
|
|
124
|
+
flag + stopped_flag(stopped_by)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The marker for a turn a hook stopped: "[looped]" for loop-guard (the
|
|
128
|
+
# bundle's thinking watch, and the word `chi sessions stats` uses for its
|
|
129
|
+
# cuts), "[stopped by X]" for any other hook; "" otherwise. A cut with no
|
|
130
|
+
# retry left records the same stopped_by.
|
|
131
|
+
def stopped_flag(by)
|
|
132
|
+
return "" if by.to_s.empty?
|
|
133
|
+
|
|
134
|
+
by.to_s == "loop-guard" ? " [looped]" : " [stopped by #{by}]"
|
|
135
|
+
end
|
|
136
|
+
|
|
115
137
|
def list
|
|
116
138
|
format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
|
|
117
139
|
unless format.nil? || %w[text json tsv].include?(format)
|
|
@@ -156,7 +178,7 @@ module Samagotchi
|
|
|
156
178
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
157
179
|
# id) whose card it also waits in (the approval relay), or nil
|
|
158
180
|
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
|
|
159
|
-
waiting_id relayed_to]
|
|
181
|
+
waiting_id relayed_to stopped_by]
|
|
160
182
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
161
183
|
when "tsv"
|
|
162
184
|
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
@@ -176,12 +198,8 @@ module Samagotchi
|
|
|
176
198
|
end
|
|
177
199
|
# A delegated session points at its parent.
|
|
178
200
|
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
179
|
-
flag =
|
|
180
|
-
|
|
181
|
-
else
|
|
182
|
-
(summary[:test_run] ? " [test]" : "")
|
|
183
|
-
end
|
|
184
|
-
flag += " [archived]" if summary[:archived]
|
|
201
|
+
flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
|
|
202
|
+
stopped_by: summary[:stopped_by])
|
|
185
203
|
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
186
204
|
@stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
|
|
187
205
|
end
|
|
@@ -209,12 +227,7 @@ module Samagotchi
|
|
|
209
227
|
end
|
|
210
228
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
211
229
|
row = lambda do |s|
|
|
212
|
-
flag =
|
|
213
|
-
" [scratch]"
|
|
214
|
-
else
|
|
215
|
-
(s.test_run ? " [test]" : "")
|
|
216
|
-
end
|
|
217
|
-
flag += " [archived]" if s.archived
|
|
230
|
+
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
218
231
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
219
232
|
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
220
233
|
ctx = Samagotchi::SessionMetrics.context_label(
|
|
@@ -94,6 +94,19 @@ module Samagotchi
|
|
|
94
94
|
# @return [Boolean] whether it went in
|
|
95
95
|
def prefill(text) = open? && @reader.prefill(text)
|
|
96
96
|
|
|
97
|
+
# What the open prompt holds, nil when none is open: a line typed
|
|
98
|
+
# ahead there is the next turn's input.
|
|
99
|
+
def typed_text = @reader&.typed_text
|
|
100
|
+
|
|
101
|
+
# Whether the next turn's input is already here: text typed ahead at
|
|
102
|
+
# the open prompt, or a line a turn took as steering but never merged
|
|
103
|
+
# (it runs as the next turn).
|
|
104
|
+
def waiting_input?
|
|
105
|
+
@lock.synchronize { return true unless @front.empty? }
|
|
106
|
+
|
|
107
|
+
!typed_text.to_s.strip.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
97
110
|
# A question answered at the open prompt: the lines submitted from now
|
|
98
111
|
# on go to it, at the ? prompt. What was typed at the prompt is put aside
|
|
99
112
|
# and comes back once the question closes.
|
|
@@ -175,6 +175,11 @@ module Samagotchi
|
|
|
175
175
|
@engine.interface = @non_interactive ? :non_interactive : :repl
|
|
176
176
|
# A --non-interactive run exits after its turn: no turn-end warm-up.
|
|
177
177
|
@engine.next_turn_waiting = -> { true } if @non_interactive
|
|
178
|
+
# A warm-up never pays off while a line typed ahead at the open prompt
|
|
179
|
+
# (or a left-over steer) already forms the next turn: only prefill when
|
|
180
|
+
# the prompt is empty. The REPL-only check composes with the one above.
|
|
181
|
+
next_waiting = @engine.next_turn_waiting
|
|
182
|
+
@engine.next_turn_waiting = -> { @repl_input&.waiting_input? || next_waiting&.call }
|
|
178
183
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
179
184
|
# A recap written while a continue offer waits says the turn stopped
|
|
180
185
|
# unfinished. (Answering at the prompt is typing: activity already.)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
def cut_retry(by, reason)
|
|
144
144
|
why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
|
|
145
145
|
retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
|
|
146
|
-
"again;
|
|
146
|
+
"again; continue the task with your next tool call, or answer if it is done."))
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
# A retry nudge is marked, so the spent one is found by the mark
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -66,8 +66,10 @@ module Samagotchi
|
|
|
66
66
|
|
|
67
67
|
# Which images of a conversation one request sends. Every image that is
|
|
68
68
|
# not sent becomes a placeholder line: all of them when the model can't
|
|
69
|
-
# see images, the
|
|
70
|
-
#
|
|
69
|
+
# see images, the oldest ones past limits.max_per_request, and any whose
|
|
70
|
+
# file is gone. Past the limit the oldest images are dropped in batches
|
|
71
|
+
# (ImagePlan.dropped_count), so the cut stays put for a few new images
|
|
72
|
+
# and the history before it stays a cacheable prefix.
|
|
71
73
|
class ImagePlan
|
|
72
74
|
Item = Data.define(:ref, :data, :placeholder) do
|
|
73
75
|
def sent? = !data.nil?
|
|
@@ -92,7 +94,18 @@ module Samagotchi
|
|
|
92
94
|
Array(entry[:images]).each_index { |position| occurrences << [index, position] }
|
|
93
95
|
end
|
|
94
96
|
@limit = vision&.limits&.max_per_request || ImageStore::Limits.from_config.max_per_request
|
|
95
|
-
@sent = occurrences.
|
|
97
|
+
@sent = occurrences.drop(self.class.dropped_count(occurrences.size, @limit))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# How many of +total+ images (oldest first) a request leaves out with
|
|
101
|
+
# +limit+ per request: none up to the limit, then whole steps of
|
|
102
|
+
# limit/2, so it's a pure function of the count. With limit 20, 21..30
|
|
103
|
+
# images drop the oldest 10, 31..40 the oldest 20.
|
|
104
|
+
def self.dropped_count(total, limit)
|
|
105
|
+
return 0 if total <= limit
|
|
106
|
+
|
|
107
|
+
step = [limit / 2, 1].max
|
|
108
|
+
(((total - limit) + step - 1) / step) * step
|
|
96
109
|
end
|
|
97
110
|
|
|
98
111
|
# The Items for conversation[+index+] (none when it has no images).
|
|
@@ -120,7 +133,7 @@ module Samagotchi
|
|
|
120
133
|
def skip_reason(index, position)
|
|
121
134
|
return "no session images here" if @vision.nil?
|
|
122
135
|
return CANT_SEE unless @vision.sendable?
|
|
123
|
-
return "
|
|
136
|
+
return "an older image (chi sends up to the newest #{@limit})" unless @sent.include?([index, position])
|
|
124
137
|
|
|
125
138
|
nil
|
|
126
139
|
end
|
|
@@ -54,10 +54,24 @@ export function createTaskStopper({
|
|
|
54
54
|
alert = (text) => globalThis.alert(text),
|
|
55
55
|
}) {
|
|
56
56
|
const inFlight = new Set();
|
|
57
|
+
const watchers = new Map(); // key -> Set of fn(busy)
|
|
57
58
|
const keyOf = (taskId) => `${sessionId()}|${taskId}`;
|
|
59
|
+
const notify = (key, busy) => watchers.get(key)?.forEach((fn) => fn(busy));
|
|
58
60
|
return {
|
|
59
61
|
target: (row) => stopTarget(row, features()),
|
|
60
62
|
busy: (taskId) => inFlight.has(keyOf(taskId)),
|
|
63
|
+
// Call +fn+ whenever a stop for +taskId+ starts or ends (the key enters
|
|
64
|
+
// or leaves the in-flight set) — the session's own task, on this session.
|
|
65
|
+
// Returns unwatch. A task drawn in two rows (the stage's "now" slot and
|
|
66
|
+
// the step list) so both buttons stay in step; a watcher for a button
|
|
67
|
+
// that is gone unwatches itself.
|
|
68
|
+
watch(taskId, fn) {
|
|
69
|
+
const key = keyOf(taskId);
|
|
70
|
+
let set = watchers.get(key);
|
|
71
|
+
if (!set) watchers.set(key, (set = new Set()));
|
|
72
|
+
set.add(fn);
|
|
73
|
+
return () => set.delete(fn);
|
|
74
|
+
},
|
|
61
75
|
// true once the worker stopped it. A stop that went through stays in
|
|
62
76
|
// flight: the button goes with its row's completion a moment later.
|
|
63
77
|
async stop(taskId, command = null) {
|
|
@@ -65,11 +79,13 @@ export function createTaskStopper({
|
|
|
65
79
|
const key = keyOf(taskId);
|
|
66
80
|
if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
|
|
67
81
|
inFlight.add(key);
|
|
82
|
+
notify(key, true);
|
|
68
83
|
try {
|
|
69
84
|
await stopTask(id, taskId);
|
|
70
85
|
return true;
|
|
71
86
|
} catch (e) {
|
|
72
87
|
inFlight.delete(key);
|
|
88
|
+
notify(key, false);
|
|
73
89
|
if (!quietFailure(e)) alert(e.message);
|
|
74
90
|
return false;
|
|
75
91
|
}
|
|
@@ -79,7 +95,10 @@ export function createTaskStopper({
|
|
|
79
95
|
|
|
80
96
|
// The button itself (the one DOM part): a click asks, posts, and keeps it
|
|
81
97
|
// disabled while the stop is in flight. +command+ is read at the click;
|
|
82
|
-
// +onChange+ lets a view redraw (the stage's slot).
|
|
98
|
+
// +onChange+ lets a view redraw (the stage's slot). It also watches the
|
|
99
|
+
// task's in-flight state, so a stop started from a second button of the
|
|
100
|
+
// same row (the stage draws one in the "now" slot and one in the step
|
|
101
|
+
// list) disables this one too — a button that is gone unwatched.
|
|
83
102
|
export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
|
|
84
103
|
const btn = document.createElement("button");
|
|
85
104
|
btn.type = "button";
|
|
@@ -87,6 +106,10 @@ export function taskStopButton(stopper, taskId, { command = () => null, onChange
|
|
|
87
106
|
btn.textContent = "stop task";
|
|
88
107
|
btn.title = "Kill this background task; the model is told you stopped it";
|
|
89
108
|
btn.disabled = stopper.busy(taskId);
|
|
109
|
+
const unwatch = stopper.watch?.(taskId, (busy) => {
|
|
110
|
+
if (!btn.isConnected) { unwatch(); return; }
|
|
111
|
+
btn.disabled = busy;
|
|
112
|
+
});
|
|
90
113
|
btn.addEventListener("click", (e) => {
|
|
91
114
|
e.stopPropagation();
|
|
92
115
|
const pending = stopper.stop(taskId, command());
|