samagotchi 0.43.0 → 0.45.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/configuration.md +18 -0
  5. data/docs/context.md +2 -1
  6. data/docs/internals/context-telemetry.md +4 -1
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/plugins.md +4 -2
  9. data/docs/sessions.md +8 -4
  10. data/lib/samagotchi/bridge/card_store.rb +20 -9
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  12. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/commands/registry.rb +54 -9
  19. data/lib/samagotchi/context_status.rb +18 -5
  20. data/lib/samagotchi/engine.rb +8 -3
  21. data/lib/samagotchi/host_registry.rb +28 -3
  22. data/lib/samagotchi/kernel_loop.rb +24 -9
  23. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  24. data/lib/samagotchi/llm_context_forget.rb +6 -2
  25. data/lib/samagotchi/llm_context_notice.rb +96 -0
  26. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  27. data/lib/samagotchi/model_notes.rb +8 -6
  28. data/lib/samagotchi/model_overlay.rb +3 -2
  29. data/lib/samagotchi/model_profile.rb +71 -2
  30. data/lib/samagotchi/plugin/context.rb +19 -7
  31. data/lib/samagotchi/self_report.rb +31 -2
  32. data/lib/samagotchi/session_commands.rb +24 -7
  33. data/lib/samagotchi/session_manager.rb +19 -1
  34. data/lib/samagotchi/session_metrics.rb +39 -6
  35. data/lib/samagotchi/sessions_command.rb +15 -5
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
  39. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  40. data/lib/samagotchi/terminal_ui.rb +84 -12
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -13
  43. data/lib/samagotchi/web/message_parts.rb +90 -11
  44. data/lib/samagotchi/web/public/app.js +38 -19
  45. data/lib/samagotchi/web/public/command_complete.js +3 -1
  46. data/lib/samagotchi/web/public/ctx.js +42 -15
  47. data/lib/samagotchi/web/public/index.html +7 -1
  48. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  49. data/lib/samagotchi/web/public/stage_model.js +18 -4
  50. data/lib/samagotchi/web/public/stage_view.js +3 -3
  51. data/lib/samagotchi/web/public/turn_events.js +50 -4
  52. data/lib/samagotchi/web/public/turn_html.js +33 -2
  53. data/lib/samagotchi/web/public/turn_view.js +4 -2
  54. data/lib/samagotchi/web/session_hub.rb +9 -1
  55. data/lib/samagotchi/web/session_summary.rb +19 -2
  56. data/lib/samagotchi/worker.rb +156 -20
  57. data/lib/samagotchi/worker_inbound.rb +6 -5
  58. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '019e204c3cd98489376bc61446efc6c576d5eb31f73ca97331c23b2d551b015f'
4
- data.tar.gz: 20b4127aae83e29bfb00d67762ea443b78b2961622502df32458d9d6f9d89068
3
+ metadata.gz: 1dc1da4c0179829c1e78da27358f269fba9fcd1ee2a2412b73493bab37a1d81d
4
+ data.tar.gz: b04878e89e0d634c817e748cc9fe435728e4c6c62863d719a53b4080b1ea5ff0
5
5
  SHA512:
6
- metadata.gz: cdd1bbcef93ae1d57db0fde19927ac5b1c3dbb2e0fe36ff6bc93296044ce305fb2b75c1cc62b6a96c84720d9cb6e288135b69f2aaa624f949282355f360c2ab6
7
- data.tar.gz: 91d1f3c0d5e2145135045e5f8fd79335142bc99bdccea708a608a541f72401812d1b965a4ec2e03e05a07d109664e40c9826d2573d51ec4cc918cc00b7d7400a
6
+ metadata.gz: c0331854a38cff99772512941a1ce8227dc10448c00d4d4c758ba532cfdd19b311e7f4d9733bce7745645231794ec93212a658a542563a8f56008c97492f8c90
7
+ data.tar.gz: 984e5ce3436b750c872bf0a7dd10f1282ed7f4eaf9c084d9f1add023bdcee5088c8f48acbd9eb3e3d01e2efa8dad200c3a350a4a903725aa1de6597407c9ee36
data/CHANGELOG.md CHANGED
@@ -8,6 +8,92 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.45.0] - 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - Plugins: `ctx.fork?` says whether the session is a fork (`ctx.sessions.fork`: it has a parent and isn't a
16
+ delegate child). See [docs/plugins.md](docs/plugins.md).
17
+
18
+ ### Changed
19
+
20
+ - Commands sent while a turn runs no longer all answer `busy`. Those that only show something answer at once
21
+ (`/models`, and `/model`, `/llm-context` and `/guardrails` alone). Those that change something (`/model X`,
22
+ `/llm-context strategy …`, `/guardrails revoke N`) and `!cmd` run after the turn, in the order sent with the
23
+ prompts: the terminals say `(queued: runs after this turn)`, the web draws the command's bubble dashed until it
24
+ ran (after a reload too), and the web's `llm ctx` chip can Set mid-turn. A `!cmd` waiting after a canceled or
25
+ failed turn is dropped, so the turn's `!rollback` stays open, and one still waiting when the worker stops is
26
+ answered `dropped`. `!rollback` and `/continue` stay refused (`busy: Ctrl-C the turn first, then !rollback`).
27
+ `/archive` typed during a turn archives once it ends, in the REPL and an attached terminal. `chi --attach ID
28
+ --model X` on a running turn waits for its end. See "Typing during a turn" in [docs/cli.md](docs/cli.md).
29
+
30
+ ### Fixed
31
+
32
+ - `/stats`, `/model` and the web named no model notes for a fresh session (a REPL's before the first turn, a new
33
+ web or worker session), though its first turn's prompt carries them; they now name the notes that prompt will
34
+ load. A session whose prompt carried none keeps saying so.
35
+ - `chi self`'s `model notes` row resolved a note's model overlay from the process's `XDG_CONFIG_HOME` while it
36
+ read the note itself from the env it was given, so the row could count no overlay (or another config's); it now
37
+ reads both from the same env, and passes the fallback key the session's prompt does, so a model typed as an
38
+ alias whose note overlay is keyed by the alias's name shows it — the row agrees with the notes the session's prompt loads.
39
+ - In the plain REPL a session command typed at a continue offer (other than `/continue`) was read as an invalid
40
+ answer and lost; it now runs and the offer stays open, as in a worker.
41
+ - A session command sent as a message behind queued prompts (`chi send -m "/model x"` after `chi send -m "task"`)
42
+ could be answered `busy` by the next queued prompt's turn instead of running before it; it now runs once its
43
+ own message's turn comes.
44
+ - The web's ctx meter under an LLM context budget smaller than the window (`llm_context.budget_tokens`) read the
45
+ percentage of the window after a reload (`ctx 12%`) where live it read the budget's (`ctx 25%`); it now counts
46
+ against the budget both ways, as the `[CONTEXT: …]` line and the status line do, and a generation's final counts
47
+ no longer flip it back to the window mid-turn. The ctx tooltip's context line says so: `context: ~16.0k of the
48
+ 64.0k budget, 128.0k window (server)`.
49
+ - The web's strip of latest sessions, back from All sessions with the pointer still on it, could keep a waiting
50
+ family behind other cards until the pointer moved: an update that came while All sessions opened was drawn in
51
+ the order held for the pointer.
52
+ - github-pr 0.3.1: in `auto_attach: offer` mode a forked session (`/btw keep`, a plugin's fork) now gets its
53
+ `first_prompt` row in `offers.ndjson` (its first prompt after the conversation it started from), as a new
54
+ session does; it got none.
55
+ - A resumed or attached session (`chi --resume`, `chi --attach`) drew a turn that failed with its steps kept as its
56
+ prompt and tool rows followed by an earlier step's text, as if that were the answer; the join now draws the line
57
+ the live view left (`✕ turn failed: HTTP 500: boom · 2.0s`, then ` partial progress kept; !rollback restores the
58
+ pre-turn state` when the steps stayed) after the rows, with no answer text, as the web does.
59
+ - The web's stage trail: when the ✂ row of an applied LLM context edit landed in its step inside the
60
+ closed cloud it was out of sight and, unlike a hook's notice, flashed nothing. It now flashes in the
61
+ trail for a few seconds, as its row reads (`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k
62
+ tokens (paid off)`), as a hook notice does.
63
+ - The web's stage trail: when a message cut a generation that was still thinking, its row (`↪ cut in
64
+ for your message`, `… a message sent with chi send`, `… the parent agent's message`) landed in its
65
+ step inside the closed cloud and, out of sight, flashed nothing. It now flashes in the trail for a
66
+ few seconds, as its row reads, as the ✂ row and a hook notice do.
67
+ - `chi send --new --model` and `delegate` warned `host 'h' doesn't list model 'x'` for an id the host serves, when
68
+ the on-disk model list still named the model a one-model server served before it was reloaded: a saved list
69
+ older than 10 minutes that misses the id now re-lists that one host once (bounded at 5 s) and judges again
70
+ against what it lists now, saving the fresh ids. A list from the last 10 minutes is taken at its word, so a host
71
+ that serves ids it never lists (a gateway's round-robin aliases) is not asked on every launch; a re-list that
72
+ fails, times out or lists nothing warns from the saved list.
73
+ - The session lists' `ctx %` (the web's session cards, `chi sessions list` and its `ctx 12%` column, the
74
+ `list_sessions` tool's `ctx_pct`) and `/stats`' `context used:` still divided by the window when the session's
75
+ LLM context budget (`llm_context.budget_tokens`) was smaller, where the live meter counts the smaller of the
76
+ two; they now count the same way, and `/stats` says so: `context used: 16000 tokens (25.0% of the 64000
77
+ budget)`.
78
+
79
+ ## [0.44.0] - 2026-10-08
80
+
81
+ ### Added
82
+
83
+ - LLM context edits are visible: each batch of stubs that reaches the prompt gets a ✂ row in its step, in the web
84
+ and the terminal (`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`), whose web hover
85
+ lists the outputs, a stale stub's reason, a forget's note and kept lines, and the cost. After a reload a stubbed
86
+ output's tool row carries a ✂ mark (`✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`,
87
+ `✂ forget staged`). The stream has a new event, `llm_context_edited`. See
88
+ [docs/configuration.md](docs/configuration.md) ("LLM context: what you see (✂)").
89
+
90
+ ### Fixed
91
+
92
+ - Under an `llm_context.budget_tokens` budget without the forget layer (strategy `none` or `stale`), the model's
93
+ `[CONTEXT: …]` line said "of the context window" for a percentage of the budget; it now reads "about 62% of the
94
+ context budget (64k tokens) is in use", and its top bucket no longer asks the model to "summarize aggressively"
95
+ ("context budget critical — avoid large outputs and re-reads, delegate broad work to subagents").
96
+
11
97
  ## [0.43.0] - 2026-10-08
12
98
 
13
99
  ### Added
@@ -2008,7 +2094,9 @@ and long-lived sessions.
2008
2094
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2009
2095
  hotkey panel that send selected text or the clipboard to your sessions.
2010
2096
 
2011
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.43.0...HEAD
2097
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.45.0...HEAD
2098
+ [0.45.0]: https://github.com/dm1try/samagotchi/compare/v0.44.0...v0.45.0
2099
+ [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
2012
2100
  [0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
2013
2101
  [0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
2014
2102
  [0.41.0]: https://github.com/dm1try/samagotchi/compare/v0.40.0...v0.41.0
data/docs/cli.md CHANGED
@@ -429,7 +429,7 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
429
429
  reports them),
430
430
  the decode speed, generation latency, cancellations, retries (the network's),
431
431
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
432
- (generations that ended at the provider's cap), context used and window, the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the prompt profile and the model the server says it ran, and the
432
+ (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the prompt profile and the model the server says it ran, and the
433
433
  cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
434
434
  guessed). The speed line is the last generation's and the session's average
435
435
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
@@ -451,7 +451,8 @@ saved with the session and applies from the next turn's start; the reply says
451
451
  what it costs: `forget` on or off re-reads the whole prompt once, `stale` turned
452
452
  on stages the reads already superseded as one batch under the apply rule, and a
453
453
  layer turned off sends its stubs whole again (turning it back on brings them
454
- back). It runs between turns (a worker answers `busy` mid-turn). `/stats` shows
454
+ back). `/llm-context` alone answers mid-turn too; a change sent while a turn runs
455
+ waits for the turn's end (see *Typing during a turn*). `/stats` shows
455
456
  the same values on its `llm context:` line. The web's info bar shows them as the
456
457
  `llm ctx` chip (`llm ctx stale,forget · session`), whose popover sets them
457
458
  through `/llm-context`. See "LLM context: a session's own strategy" in
@@ -468,11 +469,28 @@ REPL alike:
468
469
  A line that comes after the turn's last step runs as the next turn, and so
469
470
  does one sent after Ctrl-C: it doesn't merge into the turn being cancelled.
470
471
  Reminder turns take merged lines too.
471
- - `/stats` and `/recap` answer at once, and in an attached terminal so do `/help`
472
- and a plugin's anytime command (`/btw`). Other commands (`!cmd`, `/model`, `/models`,
473
- `!rollback`, `/continue`, `/guardrails`) say `busy: wait for the turn to end`
474
- and go back into the prompt, so Enter runs them once the turn ends. A
475
- one-word `/word` no command answers (`/modle`) is not steering text either:
472
+ - Commands answer, wait or are refused, by what they do:
473
+ - At once, beside the turn: `/stats`, `/recap`, `/help`, `/context`, a
474
+ plugin's anytime command (`/btw`), and the commands that only show
475
+ something: `/models` (with a filter too), and `/model`, `/llm-context` and
476
+ `/guardrails` alone.
477
+ - After the turn: the ones that change something (`/model X`, `/model clear`,
478
+ `/llm-context strategy …`, `/guardrails revoke N`) and `!cmd`, whose output
479
+ joins the conversation. `(queued: runs after this turn)` says so at once (the
480
+ web draws the command's bubble dashed, with the same words, until it ran);
481
+ when the turn ends they run in the order sent, a prompt sent before one
482
+ running first and one sent after it running after. A `/model X` sent
483
+ mid-turn doesn't change the running turn, nor a line merged into it later.
484
+ After a canceled or failed turn a waiting `!cmd` is dropped
485
+ (`turn canceled: !ls not run; send it again`): running it would close the
486
+ `!rollback` the turn left; the others still run. A command still waiting
487
+ when the session's worker stops is answered `dropped` too.
488
+ - Refused: `!rollback` (`busy: Ctrl-C the turn first, then !rollback`),
489
+ `/continue` and a plugin's command that isn't anytime
490
+ (`busy: wait for the turn to end`). The line goes back into the prompt, so
491
+ Enter runs it once the turn ends.
492
+
493
+ A one-word `/word` no command answers (`/modle`) is not steering text either:
476
494
  the hint prints and the line is dropped.
477
495
  - A question (`ask_user_question`, a guardrails approval) turns the prompt into
478
496
  a yellow `? ` and lists its choices under it, fitted to the terminal; only a
@@ -486,6 +504,10 @@ REPL alike:
486
504
  (`/exit --delete` deletes the session then too). In an
487
505
  attached terminal it detaches at once and the turn goes on in the worker
488
506
  (`/exit` then says the worker stays up: a turn is running).
507
+ - `/archive` mid-turn archives once the turn ends:
508
+ `(archives after this turn; Ctrl-C cancels it)` in the plain REPL,
509
+ `(archives after this turn)` in an attached terminal, which also waits for
510
+ the commands queued behind the turn.
489
511
 
490
512
  With stdin that isn't a terminal (a pipe), the REPL reads a line only between
491
513
  turns.
@@ -900,6 +900,24 @@ models:
900
900
  - `/stats` counts the re-prefilled tokens each applied batch costs; `script/llm_context_bench.rb --strategy
901
901
  forget_outputs` replays the offer on stored sessions (docs/internals/llm-context-bench.md).
902
902
 
903
+ ## LLM context: what you see (✂)
904
+
905
+ Every edit is shown; the session keeps the originals.
906
+
907
+ - **A ✂ row** for each batch of stubs that reaches the prompt (`stale`'s, a `forget_outputs` call's, or the ones
908
+ staged until a turn's end), in the step it came in, in the web and the terminal alike:
909
+ `✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`. It says why it went now (`paid off`,
910
+ `the context is nearly full`, `at turn end`; nothing under `next_request`) and how many stubs stay staged. In the
911
+ web, its hover lists each output (its id, tool and path or command), a stale stub's reason, a forget's note once
912
+ with the lines it keeps, the tokens it frees and the tail the server reads again. A batch held back, the warm-up's
913
+ own copy and a restore show no row (the `forget_outputs` row says what a restore did). A reload puts the rows back
914
+ where they were (`cards.json`, its own cap of 20, apart from hook notices).
915
+ - **A ✂ mark** on a tool row after a reload, above its output, when the model is sent a stub for it:
916
+ `✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`, `✂ forgotten with t41` (the
917
+ other outputs of the call whose row carries the note), `✂ forget staged`, and `· lines 12-40 kept`.
918
+ The running turn's rows get theirs on the next reload.
919
+ - Tokens are estimates (chars/4), hence the `~`.
920
+
903
921
  ## LLM context: a session's own strategy
904
922
 
905
923
  A session can set its own `llm_context` strategy, apply rule and budget, which come before the model's, its host's
data/docs/context.md CHANGED
@@ -225,7 +225,8 @@ later when an offer is wanted: one JSON line per event, `offered` (session,
225
225
  project root, working directory, branch, PR URL, main checkout or worktree,
226
226
  model), `attached` and `declined` (with the seconds since the offer), and
227
227
  `first_prompt` (the first 160 characters of every session's first message,
228
- offered or not; not scratch sessions or delegate children). Over 1 MB it is
228
+ offered or not; a fork's first after the conversation it started from, once;
229
+ not scratch sessions or delegate children). Over 1 MB it is
229
230
  renamed to `offers.ndjson.1` (one old file is kept). Only you can read it
230
231
  (0600), and nothing leaves your machine.
231
232
 
@@ -36,7 +36,10 @@ both loops):
36
36
 
37
37
  - `llm_context.budget_tokens` (or a model's or host's `llm_context_budget_tokens`): the buckets count against the
38
38
  budget instead of the window, the smaller of the two. The event's `window_tokens` and the status line's percentage
39
- are of it too, and so is `payoff`'s top bucket.
39
+ are of it too, and so is `payoff`'s top bucket. Without the forget layer the model's line names it, `[CONTEXT:
40
+ about 62% of the context budget (64k tokens) is in use (…)]` (the window when that is the smaller), and its top
41
+ bucket reads "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents": no
42
+ "summarize", as nothing in the model's hands shrinks the context.
40
43
  - The forget layer replaces the guidance with tiered offers of `forget_outputs`, each line a readout,
41
44
  `[CONTEXT: ~52k/64k tokens in use (bucket=60plus). …]`: the readout alone in the guided buckets below the top two
42
45
  (CLM: how-to at low pressure makes a model wipe everything); "Finish the unit of work in flight, then tidy once
@@ -298,6 +298,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
298
298
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
299
299
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
300
300
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
301
+ | `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
301
302
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
302
303
  | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
303
304
  | `:tool_call_started` | before a call runs | see ToolRunner |
data/docs/plugins.md CHANGED
@@ -94,8 +94,9 @@ commands it knows.
94
94
 
95
95
  #### `anytime: true`
96
96
 
97
- A normal command waits for its turn: typed while a turn runs, it is refused
98
- as busy (the REPL puts it back into the prompt). An `anytime: true` command
97
+ A plugin's normal command waits for its turn: typed while a turn runs, it is
98
+ refused as busy (the REPL puts it back into the prompt; a plugin command can't
99
+ ask to run after the turn, as the built-in `/model X` does). An `anytime: true` command
99
100
  runs **at once, on its own thread, beside the running turn**, and the turn
100
101
  goes on:
101
102
 
@@ -504,6 +505,7 @@ full label, `plugin.rb (bundle my-bundle)`.
504
505
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
505
506
  | `ctx.scratch?` | whether the session is a `chi scratch` one (deleted when it ends) |
506
507
  | `ctx.delegate?` | whether the session is a delegate child: a task another session handed over |
508
+ | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
507
509
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
508
510
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
509
511
  | `ctx.settings` | the bundle's settings, frozen |
data/docs/sessions.md CHANGED
@@ -21,11 +21,11 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
21
21
 
22
22
  **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
23
23
 
24
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
24
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
25
25
 
26
26
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
27
27
 
28
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
29
29
 
30
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
31
31
 
@@ -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 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). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. 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). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. 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 fill is counted against the session's LLM context budget when one is set and smaller than the window (its own `--llm-context-budget`, else the model's/host's/config's; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), as the live meter counts it; the list, the web card and the session page otherwise agree. 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
 
@@ -131,6 +131,10 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
131
131
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
132
132
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
133
133
  and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
134
+ Under an LLM context budget smaller than the window (`llm_context.budget_tokens`, a session's `/llm-context budget`;
135
+ see [configuration.md](configuration.md#all-settings)), the percentage is of the budget and the line says so
136
+ (`about 62% of the context budget (64k tokens) is in use`); the status line's ctx and the web's ctx meter (live and
137
+ after a reload) count against it too.
134
138
 
135
139
  Failed retries replace the note, they don't pile up. The note's text is escaped like user text (an error may
136
140
  quote what a server sent). A cancelled continue turn and `!rollback` go back to before the turn, note included.
@@ -171,7 +175,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
171
175
  chi send --wait 3fa2 # sends nothing: waits for its next reply
172
176
  ```
173
177
 
174
- - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
178
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A saved list older than 10 minutes that misses the id is re-listed once (that one host, capped at 5 s) before the warning: the list can be stale by content, e.g. a one-model server reloaded with another model; a list re-listed that way names the typed id, nothing is said. A list younger than 10 minutes is taken at its word (a host that serves ids it never lists would otherwise pay the re-list on every launch). A re-list that fails, times out or lists nothing falls back to the saved list. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
175
179
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
176
180
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
177
181
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -16,7 +16,8 @@ module Samagotchi
16
16
  # iteration started before it (a before_tool_call hook's notice comes
17
17
  # before its call's row, so the web puts it above row calls + 1). The
18
18
  # loop's "asking again" row (:empty_answer_retry, after an empty or cut
19
- # answer) and a steer's cut row (:steer_cut) are kept the same way, and so is a question the turn asked
19
+ # answer), a steer's cut row (:steer_cut) and a ✂ row (:llm_context_edited,
20
+ # LLMContextNotice) are kept the same way, and so is a question the turn asked
20
21
  # (:question_requested), with how it was answered or cancelled, so a
21
22
  # reload draws the resolved card where it was.
22
23
  #
@@ -28,9 +29,10 @@ module Samagotchi
28
29
  # as /btw) goes after that turn: its prompt is already in the history.
29
30
  # A failed turn leaves no prompt, so its cards stay before the next one.
30
31
  #
31
- # Notices (hook notices, load warnings, "asking again" rows) and cards
32
- # (with questions) are capped apart, +capacity+ each, so a burst of
33
- # notices can't push out a card. An open question (an approval too) is
32
+ # Notices (hook notices, load warnings, "asking again" rows), ✂ rows
33
+ # and cards (with questions) are capped apart, +capacity+ each, so a
34
+ # burst of notices can't push out a card, nor a long turn's ✂ rows a
35
+ # hook's notice. An open question (an approval too) is
34
36
  # never pushed out: the oldest other card or answered question goes.
35
37
  #
36
38
  # With a +path+ (the Bridge's: FILE in the session's folder) the store
@@ -47,11 +49,14 @@ module Samagotchi
47
49
  class CardStore
48
50
  CAPACITY = 20
49
51
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
52
+ # Capped apart from the notices.
53
+ LLM_CONTEXT_TYPES = %i[llm_context_edited].freeze
54
+ LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
50
55
  FILE = "cards.json"
51
56
  # The events after which the file is written again: the ones that
52
57
  # change an entry or the turn count.
53
- SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut question_requested question_answered question_cancelled
54
- question_relay turn_failed] + Events::TURN_KEPT).freeze
58
+ SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
59
+ question_cancelled question_relay turn_failed] + Events::TURN_KEPT).freeze
55
60
 
56
61
  # What a session's saved file lists, as a store started from it lists
57
62
  # (no turn running): the web's cards for a session no worker runs.
@@ -167,6 +172,7 @@ module Samagotchi
167
172
  .compact.merge(in_turn: false, turns: @turns_done))
168
173
  when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
169
174
  when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
175
+ when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running
170
176
  when :question_requested
171
177
  add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
172
178
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
@@ -236,15 +242,20 @@ module Samagotchi
236
242
 
237
243
  def push(entry)
238
244
  @entries << entry
239
- notice = notice?(entry)
240
- kind = @entries.select { |e| notice?(e) == notice }
245
+ cap = cap_class(entry)
246
+ kind = @entries.select { |e| cap_class(e) == cap }
241
247
  return if kind.size <= @capacity
242
248
 
243
249
  evict = kind.find { |e| !open_question?(e) }
244
250
  @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
245
251
  end
246
252
 
247
- def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
253
+ # Which cap +entry+ counts against: :notice, :llm_context or :card.
254
+ def cap_class(entry)
255
+ return :llm_context if LLM_CONTEXT_TYPES.include?(entry[:type])
256
+
257
+ NOTICE_TYPES.include?(entry[:type]) ? :notice : :card
258
+ end
248
259
 
249
260
  def open_question?(entry)
250
261
  entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
@@ -26,6 +26,7 @@ module Samagotchi
26
26
  # When each running tool call started, by [iteration, call_index].
27
27
  @tool_started_at = {}
28
28
  @queued = []
29
+ @queued_commands = []
29
30
  @merged_origins = []
30
31
  @recap = nil
31
32
  @continue_offer = nil
@@ -199,11 +200,24 @@ module Samagotchi
199
200
  @mutex.synchronize { @queued.map(&:dup) }
200
201
  end
201
202
 
203
+ # @return [Array<Hash>] commands queued for after the running turn
204
+ # (command_queued with waits, not a card's action) that haven't run
205
+ # or been dropped yet:
206
+ # {command_id:, client_id:, line:}, in arrival order
207
+ def queued_commands
208
+ @mutex.synchronize { @queued_commands.map(&:dup) }
209
+ end
210
+
202
211
  private
203
212
 
204
213
  def fold(event)
205
214
  type = event[:type]
206
215
  case type
216
+ when :command_queued
217
+ # A card's action shows as its card, never as a line (the UIs skip it live).
218
+ @queued_commands << event.slice(:command_id, :client_id, :line) if event[:waits] && !event[:card]
219
+ when :command_ran
220
+ @queued_commands.reject! { |entry| entry[:command_id] == event[:command_id] } if event[:queued]
207
221
  when :turn_enqueued
208
222
  queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
209
223
  queued[:images] = event[:images] if event[:images]
@@ -4,7 +4,8 @@ module Samagotchi
4
4
  class Bridge
5
5
  # The events a running turn shows as rows of its own (not text, thinking
6
6
  # or a tool call): a hook's notice, the loop asking again after an empty
7
- # or cut answer, a question and how it was answered. A UI that joins the
7
+ # or cut answer, a batch of LLM context edits (✂), a question and how it
8
+ # was answered. A UI that joins the
8
9
  # turn mid-way (TurnAccumulator's "notice" part) and one that reloads a
9
10
  # finished turn (CardStore) get the event back with the fields listed
10
11
  # here, and draw it with the handler that drew it live, where it was.
@@ -13,6 +14,7 @@ module Samagotchi
13
14
  hook_notice: %i[hook text level fallback_for],
14
15
  empty_answer_retry: %i[iteration attempt of stopped_by malformed],
15
16
  steer_cut: %i[iteration source],
17
+ llm_context_edited: %i[moment why freed_tokens tail_tokens staged text groups],
16
18
  question_requested: %i[pending_question],
17
19
  question_answered: %i[id answer],
18
20
  question_cancelled: %i[id reason]
@@ -75,7 +75,8 @@ module Samagotchi
75
75
  # SessionInbox::INPUT_FORMAT); nil advertises none
76
76
  # @param on_input [#call, nil] called once a turn for this session is
77
77
  # queued, to wake the worker loop (Worker::Waker#wake)
78
- # @param on_command [#call, nil] takes a session command
78
+ # @param on_command [#call, nil] takes a session command, returns its
79
+ # mid-turn policy (:anytime when it runs now)
79
80
  # ({command_id:, client_id:, line:}) for the worker loop to run; without
80
81
  # one, POST /command answers 501
81
82
  # @param on_exit_request [#call, nil] a client asks the worker to exit
@@ -222,7 +223,9 @@ module Samagotchi
222
223
  # doesn't keep it.
223
224
  # chi_version: the chi this worker runs (an attaching TUI compares it
224
225
  # with its own).
225
- # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
226
+ # The commands queued for after the running turn (queued_commands,
227
+ # TurnAccumulator#queued_commands) too.
228
+ # @return [Hash] {messages:, current_turn:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
226
229
  # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
227
230
  def snapshot
228
231
  @engine.synchronize_events do
@@ -231,6 +234,7 @@ module Samagotchi
231
234
  messages: @engine.messages_checkpoint,
232
235
  current_turn: @accumulator.current_turn,
233
236
  queued: @accumulator.queued,
237
+ queued_commands: @accumulator.queued_commands,
234
238
  recap: @accumulator.recap,
235
239
  saved_recap: @engine.saved_recap,
236
240
  continue_offer: @accumulator.continue_offer,
@@ -277,13 +281,15 @@ module Samagotchi
277
281
  # The worker's own way in for a session command that came as a message
278
282
  # (its first prompt, an input file): queued and announced as a POST
279
283
  # /command's is (#handle_command).
284
+ # @param after_file [String, nil] the input file it came in (its
285
+ # basename): it runs once that file is taken (Worker#on_command)
280
286
  # @return [String] its command_id
281
- def queue_command(line, client_id: nil, card: false)
287
+ def queue_command(line, client_id: nil, card: false, after_file: nil)
282
288
  command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
283
289
  # A card's action (the step-limit question's answer): the UIs leave
284
290
  # its line out.
285
291
  command[:card] = true if card
286
- @engine.synchronize_events { queue_command_locked(command) }
292
+ @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
287
293
  command[:command_id]
288
294
  end
289
295
 
@@ -727,13 +733,19 @@ module Samagotchi
727
733
  [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
728
734
  end
729
735
 
730
- # With the event log held.
731
- def queue_command_locked(command)
732
- @on_command.call(command)
736
+ # With the event log held. +on_command+ answers what it did with the
737
+ # line (Commands::Registry::MID_TURN).
738
+ def queue_command_locked(command, after_file: nil)
739
+ policy = after_file ? @on_command.call(command, after_file: after_file) : @on_command.call(command)
733
740
  # An anytime command runs now, beside a turn (D8): the UIs show its
734
- # line here, so the cards it shows come after it.
735
- anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
736
- @engine.announce(type: :command_queued, **command, **anytime)
741
+ # line here, so the cards it shows come after it. A queued one runs
742
+ # when the turn ends: the UIs show it waiting until its command_ran.
743
+ marks = case policy
744
+ when :anytime then { anytime: true }
745
+ when :queue then { waits: "turn_end" }
746
+ else {}
747
+ end
748
+ @engine.announce(type: :command_queued, **command, **marks)
737
749
  end
738
750
 
739
751
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.43.0"
7
+ requires_chi: ">= 0.45.0"
8
8
  needs:
9
9
  - command: gh
10
10
  why: reads the pull request
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:8c3b1b2de9112320d95d42d19b924a2a6bb0418715d3c78a24ac695c7b825487
14
+ sha256: sha256:eb6207a0aec4267eb15d28e4db2edb10d458c3ff574c2be56f225b24b7afb733
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -218,13 +218,20 @@ class Plugin
218
218
  # Offer mode: every session's first prompt (offered or not; a web chat's
219
219
  # first turn starts before the init task's gh returns), joined to the
220
220
  # offers by session at analysis time. First = no user message yet (a
221
- # context note may be in messages already).
221
+ # context note may be in messages already); a fork starts from its
222
+ # parent's conversation, user messages and all, so its first is the one
223
+ # the log has no first_prompt row for yet.
222
224
  def log_first_prompt(event, ctx)
223
225
  return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
224
- return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
225
- return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
226
+ return if ctx.scratch? || ctx.session_id.nil?
226
227
 
227
- offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
+ log = offers_log(ctx)
229
+ if Array(event[:messages]).any? { |message| message_role(message) == "user" }
230
+ return unless ctx.fork? && !log.first_prompt?(ctx.session_id)
231
+ elsif ctx.delegate?
232
+ return
233
+ end
234
+ log.write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
235
  rescue StandardError => e
229
236
  ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
230
237
  end
@@ -365,6 +372,17 @@ class OffersLog
365
372
  @log.debug(:pr_offers_log_failed, error: e.class.name)
366
373
  nil
367
374
  end
375
+
376
+ # Whether the log (or its old file) has session +id+'s first_prompt row.
377
+ def first_prompt?(id)
378
+ [@path, "#{@path}.1"].any? do |path|
379
+ File.exist?(path) && File.foreach(path).any? do |line|
380
+ line.include?(%("session":#{JSON.generate(id)})) && JSON.parse(line)["event"] == "first_prompt"
381
+ rescue JSON::ParserError
382
+ false
383
+ end
384
+ end
385
+ end
368
386
  end
369
387
 
370
388
  # A GitHub pull request, by its URL.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.43.0
3
+ version: 0.45.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:9d1b2ff7ff5f237c1b1beed2eb8ecc3069570705a2c1763e533dd15829ff309d
8
+ self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
9
  config_modification_protocol.md: sha256:2d4135ce4fafcd391d1782dc3a54f7cbf10cd35ebe9f004b2d6cb256517a8451
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614