samagotchi 0.107.0 → 0.108.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 +37 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/internals/engine.md +17 -6
  5. data/docs/internals/participants.md +5 -2
  6. data/docs/internals/working-set.md +2 -1
  7. data/docs/memory.md +20 -0
  8. data/docs/plugins.md +9 -2
  9. data/docs/releasing.md +11 -6
  10. data/docs/sessions.md +3 -3
  11. data/docs/sub-agent.md +1 -1
  12. data/docs/testing.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  14. data/lib/samagotchi/bridge.rb +3 -2
  15. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  21. data/lib/samagotchi/child_reports.rb +2 -1
  22. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  23. data/lib/samagotchi/context_sources.rb +1 -6
  24. data/lib/samagotchi/continue_offer.rb +8 -6
  25. data/lib/samagotchi/engine.rb +36 -25
  26. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  27. data/lib/samagotchi/idle_scheduler.rb +17 -2
  28. data/lib/samagotchi/last_turn.rb +7 -0
  29. data/lib/samagotchi/log_subscriber.rb +2 -1
  30. data/lib/samagotchi/memory_orphans.rb +74 -0
  31. data/lib/samagotchi/memory_paths.rb +4 -4
  32. data/lib/samagotchi/parent_report.rb +11 -3
  33. data/lib/samagotchi/reply_wait.rb +7 -2
  34. data/lib/samagotchi/send_command/busy.rb +5 -2
  35. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  36. data/lib/samagotchi/session_inbox.rb +12 -8
  37. data/lib/samagotchi/session_metrics.rb +7 -6
  38. data/lib/samagotchi/session_participants.rb +3 -1
  39. data/lib/samagotchi/steer.rb +11 -1
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  41. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  42. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  43. data/lib/samagotchi/tools/memory.rb +16 -9
  44. data/lib/samagotchi/turn_origin.rb +68 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +2 -0
  47. data/lib/samagotchi/web/grants.rb +2 -1
  48. data/lib/samagotchi/web/participant_access.rb +6 -116
  49. data/lib/samagotchi/web/participant_view.rb +7 -13
  50. data/lib/samagotchi/web/participants_admin.rb +129 -0
  51. data/lib/samagotchi/web/public/participant.js +2 -2
  52. data/lib/samagotchi/web/public/participant_model.js +5 -0
  53. data/lib/samagotchi/web/public/participant_names.js +2 -2
  54. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  55. data/lib/samagotchi/worker.rb +30 -25
  56. data/lib/samagotchi/worker_inbound.rb +12 -9
  57. data/lib/samagotchi/working_set.rb +3 -2
  58. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58e0f763c1974428c1144b884f63a9529fe5b1a973b1443e44b0a4e1a3f3cf48
4
- data.tar.gz: 6c6a8a429e01a837329065239819bf69568e1455425bcf60e1badae8208553be
3
+ metadata.gz: 3f63eb88d80157a6b77f733031284c1a4e376528356f8d099ffe356b4cd7e6c1
4
+ data.tar.gz: eab92f98b12468b5c882e5ee80524fdf151a1b479fd232ea54f8318bf952c2e3
5
5
  SHA512:
6
- metadata.gz: b0fe1b18a4b7defe96fc787ab753e9cfbf6c62eebddac74d05d74b972439b87737a9b5e6192c1d026dc2add3702b1659be6846ebaa894bd87e65dc5ccdffd823
7
- data.tar.gz: 7a74e55c15487d3ccdac35ecaaec567b5feefab39312712b2ed07befdbfd9def1b5e4685170cc28550fd89f47eca839e5aa06a3c4128ae426e89962f69f0c1db
6
+ metadata.gz: c5df88fe266c32153072be45890e6c12f54bcab0492d367cabc0f0066e6dec41bfc8d54e800f08c610979c3ec66131f7082dbc8691ea3854498ce603f553ffec
7
+ data.tar.gz: e324e458bc5801eec9bc0f170ab5b839fbc4633ca33ce026566cac121deed90b53149a1052a9ec4607f67a7e3d284b4201c08309b5e8eeb1181bd1ff3ec65e31
data/CHANGELOG.md CHANGED
@@ -8,6 +8,41 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.108.0] - 2026-10-24
12
+
13
+ ### Added
14
+
15
+ - A project's memories copied from another machine or path (their folder keyed by the old path's hash) are no
16
+ longer missing without a word: when this project's folder is missing or empty and one of the same name with
17
+ another hash exists, chi warns at session start (`memory>`) and under a project `memory_read` miss, naming both
18
+ folders and the `mv` that fixes it. See docs/memory.md (Moving memories to another machine).
19
+
20
+ ### Changed
21
+
22
+ - `chi send --wait` and `chi answer` left with the turn still running (`--timeout`, Ctrl-C) offer the command that waits
23
+ again: `chi send --wait --turn T ID`, or `chi send --wait ID` when the turn's id isn't known (with the attach that
24
+ shows it); under `--remote NAME` both carry it.
25
+ - `chi send --wait` into a running turn is refused with what to do next: `--queue` runs the message after it,
26
+ `chi send --wait ID` waits for it, `chi --attach ID` shows it (under `--remote NAME` the attach, no longer "open it
27
+ in chi web").
28
+
29
+ ### Fixed
30
+
31
+ - `chi -p … --non-interactive` prints the load warnings (a guardrail or plugin that failed to load, the orphaned
32
+ project memory folder) on stderr as `<label>> …` lines; it used to drop them.
33
+ - cost-cap 0.2.3, work-cap 0.1.3: a session's state file that doesn't read (not JSON) no longer drops its `/cap` or
34
+ `/workcap` cap silently: it is logged and you get one notice naming the file and the cap used instead (the
35
+ config's), and the next `/cap`, `/workcap` or Continue that writes over it says so instead of replacing it silently.
36
+ - `chi send --wait -m` to an idle session (and `--new --wait`) waits for its message's own turn: a worker that dies
37
+ in that turn ends the wait `canceled` (`worker_stopped`) once the next worker settles it, instead of sitting to
38
+ the timeout; the `turn_id` it reports was already that turn's.
39
+ - A `delegate` follow-up's wait (or delegate report) to an idle child no longer takes the child's previous turn's
40
+ reply for its own when it looks just as the follow-up's own turn starts.
41
+ - A scheduled job that dies of an error outside Ruby's StandardError (a stack overflow, a job calling `exit`) no
42
+ longer cuts chi's shutdown short: the rest of it (plugin tasks, MCP servers) still stops.
43
+
44
+ Update with `chi update` (the system bundle 0.108.0, the cost-cap bundle 0.2.3, the work-cap bundle 0.1.3), then restart `chi web`.
45
+
11
46
  ## [0.107.0] - 2026-10-24
12
47
 
13
48
  ### Added
@@ -4130,7 +4165,8 @@ and long-lived sessions.
4130
4165
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4131
4166
  hotkey panel that send selected text or the clipboard to your sessions.
4132
4167
 
4133
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.107.0...HEAD
4168
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.108.0...HEAD
4169
+ [0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
4134
4170
  [0.107.0]: https://github.com/dm1try/samagotchi/compare/v0.106.0...v0.107.0
4135
4171
  [0.106.0]: https://github.com/dm1try/samagotchi/compare/v0.105.0...v0.106.0
4136
4172
  [0.105.0]: https://github.com/dm1try/samagotchi/compare/v0.104.0...v0.105.0
data/docs/architecture.md CHANGED
@@ -201,7 +201,8 @@ These are the Engine's raw events. The worker's SSE projection omits `call` from
201
201
  `:tool_call_started` and `result` from `:turn_completed`; the answer remains in
202
202
  `turn_summary.output`.
203
203
 
204
- A turn started with `origin:` carries it on each of these (and `:turn_failed`).
204
+ A turn started with `origin:` (a `TurnOrigin`, or its Hash) carries it on each of these (and `:turn_failed`), as
205
+ the Hash `{client_id:, enqueued_id:, author:}` with only the fields set (`TurnOrigin#to_h`).
205
206
 
206
207
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
207
208
  rescues sink errors). A new UI (web, API) supplies its own `on_event` and
@@ -695,7 +696,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
695
696
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
696
697
  | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
697
698
  | Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
698
- | Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
699
+ | Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`: the participant's gate and routes), `web/participants_admin.rb` (`Web::ParticipantsAdmin`: the owner's invite, list, revoke, cap and Apply routes), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
699
700
  | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads; `session_metrics/usage.rb` (`SessionMetrics::Usage`): the work `ctx.usage` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
700
701
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
701
702
  | Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
@@ -424,7 +424,7 @@ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
424
424
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
425
425
  :settings, :id, :limit, :resumed, :resume_from) do
426
426
  def resumed? = resumed ? true : false # a step-limit Continue
427
- def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
427
+ def tag(event) = origin ? event.merge(origin: origin.to_h) : event # boundary events carry the origin's wire Hash
428
428
  def elapsed = ... # seconds since started_at
429
429
  end
430
430
  ```
@@ -519,11 +519,22 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
519
519
  | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
520
520
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
521
521
 
522
- The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
523
- when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than the session's
524
- owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`). An owner's turn
525
- has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn` gets the
526
- origin, and `#turn_author` reads its author for the gate's checks.
522
+ A turn's origin is a `TurnOrigin` (`lib/samagotchi/turn_origin.rb`, a `Data.define` of `client_id`, `enqueued_id`
523
+ and `author`): `SessionInbox.read_input` makes it from an input file's own fields, the wakes, reports and reminders
524
+ make theirs, and `Engine#run_turn`, the Worker and `GuardrailWiring` pass it on. Its readers say what callers ask
525
+ (`#participant?`, `#participant_author`, `#participant_id`, `#source` = `ClientId.origin`, `#human?` =
526
+ `ClientId.human?`). At a boundary it is turned once: `#to_h` writes the wire Hash (only the fields set, as before),
527
+ `TurnOrigin.from` reads a Hash back (symbol or string keys; nil for none). In-process an origin is never nil:
528
+ `TurnOrigin.of` gives `TurnOrigin::NONE` (no sender: an owner's turn with no client) for nil or `{}`, and NONE is
529
+ `run_turn(origin:)`'s default (a Hash is still read as one). NONE has no wire form (`#empty?`; `#wire` is nil, or
530
+ `#to_h` for any other origin), so the events and the hooks' `context` carry no origin for it, as before.
531
+ `LastTurn#author` stays the session file's string; `LastTurn#participant_author` reads it as
532
+ `TurnOrigin#participant_author` does. The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry
533
+ `origin` when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than
534
+ the session's owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`).
535
+ An owner's turn has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn`
536
+ gets the origin, and `#turn_author` reads its participant author for the gate's checks; the guardrails `Context`
537
+ (the hooks' `context`) keeps the wire Hash.
527
538
 
528
539
  Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any author but none or `"owner"`) goes:
529
540
 
@@ -135,8 +135,8 @@ lock), and its ack and `:turn_enqueued` say `queue`; the worker's drain takes no
135
135
  turn (`Worker#pending_input_drain`, for a file the Bridge never saw), so each runs as the owner's own turn after it.
136
136
  The web's delivery line says so (`participantTurn`: Enter queues, no cut), the TUI prints its queued note, `chi send`
137
137
  says `(queued: runs after the current turn)` and its `--wait` waits for that turn (`WaitTarget.message`). The owner's
138
- own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin keeps
139
- their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; once their link ended or their cap is reached, the owner's Continue
138
+ own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin (a `TurnOrigin`,
139
+ `#participant?`; see engine.md) keeps their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; once their link ended or their cap is reached, the owner's Continue
140
140
  ends the offer as a Stop instead (`ContinueOffer#refuse_continue`: decision `refused`, a notice, `not_continued`), so
141
141
  nothing waits on it. The step-limit Continue offer of the owner's turn is the
142
142
  owner's to answer: while one stands, the worker **holds** a participant's input file (`Worker#run_input_files`,
@@ -306,6 +306,9 @@ the owner sees the text before it runs (docs/guardrails.md, the review boundary)
306
306
 
307
307
  ## The owner's routes
308
308
 
309
+ `Web::ParticipantsAdmin` (`web/participants_admin.rb`, mixed into `App` beside `ParticipantAccess`, the participant's own
310
+ gate and routes) answers these.
311
+
309
312
  | Route | Body / params | Answer | Refusals |
310
313
  |---|---|---|---|
311
314
  | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it | |
@@ -52,7 +52,8 @@ is left out. So is a failed run (`LLMContextStale`'s rule: an `Error` after the
52
52
  add op `edit`.
53
53
  - `memory_read` gives one memory per name (`Tools::MemoryRead.parse_names`; an empty name, the index listing, gives
54
54
  none). A partial read ("a,b" where b is missing: the found bodies, then `Error: memory not found: b`) keeps the
55
- found names only, and its tokens are split evenly among them.
55
+ found names only, and its tokens are split evenly among them. The names come off that line alone
56
+ (`WorkingSet::MISSING`): an orphaned-folder hint may follow it on the next line.
56
57
  - `memory_write` gives the memory `call[:path]`, op `write`. A `remove: true` write stays in the set.
57
58
  - `web_fetch` gives the URL with its whitespace collapsed.
58
59
 
data/docs/memory.md CHANGED
@@ -21,6 +21,26 @@ Tool behavior:
21
21
  - `chi memory ls` and `chi memory rm NAME` are the user's side of the same (see [Listing and removing from the shell](#listing-and-removing-from-the-shell)).
22
22
  - `write`/`edit` that change a `*.md` right in a scope dir refresh its managed `index.md` line (date, bytes; the description is kept), as `memory_write` does. A memory created with `write` gets a line with no description; any other `.md` written there gets a line too. Model overlays (`<name>.<key>.md` next to `<name>.md`), files in another project's folder and files changed by `execute` are left out.
23
23
 
24
+ ## Moving memories to another machine
25
+
26
+ A project's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
27
+ memories copied to a machine (or a path) where the repository lives elsewhere sit in a folder chi no
28
+ longer reads. Rename the folder to the new key, or move memories with a bundle zip (see
29
+ [What a bundle owns](#what-a-bundle-owns)).
30
+
31
+ Inside a git repository, when this project's folder is missing or holds no `*.md` while the projects
32
+ dir has a folder of the same basename with another hash, chi says so, naming both folders and the
33
+ `mv` that fixes it (a name match can't prove it's the same project, so nothing is renamed for you):
34
+
35
+ - at session start, a `memory>` load warning (a `:guardrail_warning` with label `memory`; the attached
36
+ TUI, the REPL, `--non-interactive` and the web show it; a UI that attaches later gets it from the
37
+ worker's cards), once per worker or REPL process, so a respawn or resume says it again;
38
+ - under a `memory_read` miss that looked in project scope (`scope: "project"` or none), and under the
39
+ project index read, on a `Hint:` line after `Error: memory not found: …`. A system-scope read never
40
+ gets it.
41
+
42
+ Neither enters the system prompt.
43
+
24
44
  ## Listing and removing from the shell
25
45
 
26
46
  `chi memory ls [--scope project|system] [--format text|json]` lists the
data/docs/plugins.md CHANGED
@@ -1708,7 +1708,10 @@ parent may Continue: nothing restricts it, as with the step limit. A background
1708
1708
  its own card and doesn't wake its parent ([sessions.md](sessions.md#delegating)).
1709
1709
 
1710
1710
  The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"cap": 3.0, "step": 3.0}`), removed
1711
- with the session.
1711
+ with the session. A file that is there but doesn't read (not JSON, or not a mapping) is logged
1712
+ (`costcap_state_unreadable`) and gets one notice, `cost-cap: <path> doesn't read, its /cap cap is ignored; using
1713
+ default: ($5.00)`: the settings' cap is used, never a silent drop; the next `/cap` or Continue rewrites it, logged
1714
+ (`costcap_state_replaced`) and said (`cost-cap: <path> didn't read; replaced with the new cap`).
1712
1715
 
1713
1716
  Its sibling for work instead of money, tool calls and active minutes across the session's turns, is
1714
1717
  [the work-cap bundle](#the-work-cap-bundle): the cap a local model (no cost) can have.
@@ -1766,7 +1769,11 @@ bundles:
1766
1769
  - `/workcap off` (or `/workcap 0`): no work cap for this session, over the config.
1767
1770
 
1768
1771
  The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"tool_calls": {"cap": 800, "step":
1769
- 400}}`, a meter's `"cap": null` for off).
1772
+ 400}}`, a meter's `"cap": null` for off). A file that is there but doesn't read (not JSON, or not a mapping) is
1773
+ logged (`workcap_state_unreadable`) and gets one notice, `work-cap: <path> doesn't read, its /workcap caps are
1774
+ ignored; using default.tool_calls: (400)`: the settings' caps are used, never a silent drop; the next `/workcap` or
1775
+ Continue rewrites it, logged (`workcap_state_replaced`) and said (`work-cap: <path> didn't read; replaced with the
1776
+ new cap`).
1770
1777
 
1771
1778
  ## The check-in bundle
1772
1779
 
data/docs/releasing.md CHANGED
@@ -25,7 +25,8 @@ can run the whole release; the user approves the notes before the tag and the
25
25
  marked bundle's `requires_chi` to `">= X.Y.Z"` and drops the marker;
26
26
  `release:check` fails while a marker is left. chi itself ignores the key,
27
27
  and `bundles:check` notes it (and fails a marker that isn't `true` or a
28
- one-line string). `release:bump` touches no other `requires_chi`;
28
+ one-line string). `release:bump` touches no other `requires_chi` but a
29
+ profile's (next point);
29
30
  - a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
30
31
  released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as a comment;
31
32
  - a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
@@ -33,9 +34,11 @@ can run the whole release; the user approves the notes before the tag and the
33
34
  goes up: `chi update` then installs only the new bundle for those who
34
35
  have the profile;
35
36
  - a profile's `requires_chi` is its members' highest: a member's raised
36
- floor (by hand, or by `release:bump` for a `requires_chi_next` marker)
37
- raises its profile's too, with the profile's version (`bundles:check`
38
- notes a profile below a member);
37
+ floor raises its profile's too, with the profile's version. `release:bump`
38
+ does both for a profile its `requires_chi_next` raises leave below a
39
+ member (a patch bump of the profile's version, once per release); a floor raised by hand
40
+ needs the profile raised by hand (`bundles:check` notes a profile below a
41
+ member);
39
42
  - the release notes say "run `chi update`" and name the bundles whose
40
43
  version moved (what changed in each), not per-bundle upgrade steps.
41
44
  - Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
@@ -67,7 +70,7 @@ as the GitHub release body.
67
70
  | `rake lint` / `rake lint:fix` | RuboCop over the repo (must report no offenses; CI runs it) / its safe autocorrect (`rubocop -a`). |
68
71
  | `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
69
72
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
70
- | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped). No commit. |
73
+ | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line. No commit. |
71
74
  | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
72
75
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
73
76
 
@@ -85,7 +88,9 @@ The agent does each step and stops where the user has to say yes.
85
88
  3. **The user approves the notes and the version.** Show them the section.
86
89
  4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`: it
87
90
  names the bundles whose `requires_chi` it raised (their `requires_chi_next`
88
- markers are gone).
91
+ markers are gone) and the profiles it raised with them (a patch bump of
92
+ each one's version, none for a profile already bumped since the last tag),
93
+ which it names in the CHANGELOG's "Update with `chi update`" line.
89
94
  5. **Check.** Commit first (the check wants a clean tree), then run it:
90
95
  `git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
91
96
  Fix anything it finds in new commits and run it again.
data/docs/sessions.md CHANGED
@@ -29,7 +29,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
29
29
 
30
30
  **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
31
31
 
32
- **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
32
+ **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
33
33
 
34
34
  **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`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, 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 (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. 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.
35
35
 
@@ -223,8 +223,8 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
223
223
  (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
224
224
  worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
225
225
  - `--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.
226
- - `--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 merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
227
- - 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.
226
+ - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`; one merged into a turn that started at that moment gets that turn's reply). `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
227
+ - 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 send --wait --turn T ID` for a turn whose id is known, else `chi send --wait ID waits again; chi --attach ID shows it`, as after a `--timeout`). There is no default timeout.
228
228
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
229
229
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
230
230
  - `--set KEY=VALUE` (with `--new`, repeatable) gives the new session a setting of its own, as `chi --set` does (see *Session settings* below): a core key a session may set (`--set recap.enabled=false`) or a bundle's `BUNDLE.KEY` (`chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap). A key no session sets (`guardrails.mode`), a bundle or key the installed bundles don't offer, or a value of another type than the key takes (`recap.enabled=maybe`, `cost-cap.cap=five`) is a usage error (exit 2), as is `--set` without `--new`.
data/docs/sub-agent.md CHANGED
@@ -72,7 +72,7 @@ Exit codes, both commands:
72
72
  | 3 | `waiting_for_owner` | (a participant's wait, `chi --remote NAME send --wait` with their link's token) their review turn waits on the session's owner: a question or an approval it raised, or the owner's step-limit Continue; wait again |
73
73
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
74
74
  | 1 | `refused` | (a participant's wait) their queued review turn was refused before it ran: `cancel_reason` `revoked` (their link was revoked or expired) or `cap_reached` (they spent their cap); no turn ran for it |
75
- | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the `--queue` turn waited for died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
75
+ | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the turn waited for (a `--queue` message's, or a message's to an idle session) died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
76
76
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
77
77
  | 130 | `running` | Ctrl-C; the turn goes on |
78
78
 
data/docs/testing.md CHANGED
@@ -223,7 +223,11 @@ docs or source as text (its `RULES`: add one when a full run catches what the na
223
223
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
224
224
  words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
225
225
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
226
- `spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
226
+ `spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
227
+ changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
228
+ `call`… and `Object`'s or `Enumerable`'s methods skip), then the specs that name a changed file's class. The method tier
229
+ needs Prism 1.0 or later: Ruby 3.4+ ships it, a 3.3 install has it only beside rubocop or minitest 6; below that it is
230
+ skipped, which `--why` says. A method named in too many specs to list (a hub) gets a line under `--why` only. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
227
231
  The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" (with `npm run lint` too when a frontend module changed) and a
228
232
  hint for a hub class (named in too many specs to list) go to stderr, and so do the `timing:` hints, from the lines a
229
233
  changed spec adds (`git diff -U0` against the base; an untracked spec, all its lines): a `Thread.new`, `.start`,
@@ -5,6 +5,7 @@ require_relative "../kernel_loop"
5
5
  require_relative "../steer"
6
6
  require_relative "../tool_output_cut"
7
7
  require_relative "../tool_row_fields"
8
+ require_relative "../turn_origin"
8
9
  require_relative "turn_notice"
9
10
 
10
11
  module Samagotchi
@@ -70,7 +71,7 @@ module Samagotchi
70
71
  # Whether the turn in progress is a participant's review turn (its
71
72
  # author): the owner's input queues behind it then (Bridge's POST /turn).
72
73
  def participant_turn?
73
- @mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || @turn.dig(:origin, :author)) }
74
+ @mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || TurnOrigin.from(@turn[:origin])&.author) }
74
75
  end
75
76
 
76
77
  # The turn in progress as conversation messages, for a plugin's
@@ -382,7 +383,7 @@ module Samagotchi
382
383
  end
383
384
 
384
385
  def dequeue(origins)
385
- ids = origins.filter_map { |origin| origin.is_a?(Hash) ? origin[:enqueued_id] : nil }
386
+ ids = origins.filter_map { |origin| TurnOrigin.from(origin)&.enqueued_id }
386
387
  @queued.reject! { |entry| ids.include?(entry[:enqueued_id]) } unless ids.empty?
387
388
  end
388
389
  end
@@ -22,6 +22,7 @@ require_relative "engine"
22
22
  require_relative "session_commands"
23
23
  require_relative "queued_command"
24
24
  require_relative "steer"
25
+ require_relative "turn_origin"
25
26
  require_relative "participants"
26
27
  require_relative "image_store"
27
28
  require_relative "log"
@@ -1032,14 +1033,14 @@ module Samagotchi
1032
1033
  # +author+'s turns running or waiting: the running turn when it is
1033
1034
  # theirs, and their queued ones. Read with the event log held.
1034
1035
  def pending_turns(author)
1035
- running = Steer.sender_author(@accumulator.current_turn&.dig(:origin, :author)) == author ? 1 : 0
1036
+ running = TurnOrigin.of(@accumulator.current_turn&.dig(:origin)).participant_author == author ? 1 : 0
1036
1037
  running + @accumulator.queued.count { |row| row[:author] == author }
1037
1038
  end
1038
1039
 
1039
1040
  # 403 not_permitted for a participant's +what+ (a command, a shell
1040
1041
  # line), with its audit line.
1041
1042
  def participant_refused(sid, author, what)
1042
- Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: author.delete_prefix("p:"), what: what,
1043
+ Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: Steer.participant_id(author), what: what,
1043
1044
  reason: "not_permitted")
1044
1045
  [{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
1045
1046
  end
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: cost-cap
3
- version: 0.2.1
3
+ version: 0.2.3
4
4
  scope: system
5
5
  description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:28d632cd19117ef359acc6aa1c621081c14c018772b7fe1efd6284e346fea135
9
+ sha256: sha256:57362669f6b499fb70103a11eb6582e261b69103f51ee1ac36496a04717f5d9b
10
10
  # ctx.cost is new in chi 0.83.0; ask_user's effects: in the next chi
11
11
  requires_chi: ">= 0.92.0"
12
12
  # session_settings: as a mapping of keys to types is new
@@ -44,6 +44,7 @@ class Plugin
44
44
  @warned = nil # [session id, cap] the share warning was given for
45
45
  @unknown_warned = nil # the session id the "can't be enforced" notice was given for
46
46
  @unread_warned = nil # the session id the "isn't a number" notice was given for
47
+ @corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
47
48
  end
48
49
 
49
50
  def register(chi)
@@ -104,6 +105,7 @@ class Plugin
104
105
  def check(ctx)
105
106
  resolved = resolve(ctx)
106
107
  unreadable(ctx, resolved)
108
+ corrupt(ctx, resolved)
107
109
  cap = resolved[:cap]
108
110
  return unless cap
109
111
 
@@ -140,10 +142,23 @@ class Plugin
140
142
  return if bad.nil? || bad.empty? || !first_time?(:@unread_warned, resolved[:session])
141
143
 
142
144
  named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
143
- used = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
144
- ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{used}", level: :warn)
145
+ ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
146
+ level: :warn)
145
147
  end
146
148
 
149
+ # Once per session: a state file that is there but doesn't read (not
150
+ # JSON, or not a mapping) holds no /cap cap, so the settings' is used;
151
+ # said rather than a /cap cap silently gone.
152
+ def corrupt(ctx, resolved)
153
+ path = resolved[:corrupt]
154
+ return unless path && first_time?(:@corrupt_warned, resolved[:session])
155
+
156
+ ctx.notify("cost-cap: #{path} doesn't read, its /cap cap is ignored; #{using(resolved)}", level: :warn)
157
+ end
158
+
159
+ # "using default: ($5.00)", or "no cap".
160
+ def using(resolved) = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
161
+
147
162
  def at_cap(ctx, resolved, cost)
148
163
  cap = resolved[:cap]
149
164
  raised = raised_cap(cap, resolved[:step] || cap, cost.total)
@@ -222,20 +237,29 @@ class Plugin
222
237
  cached = @mutex.synchronize { @resolved }
223
238
  return cached if cached && cached[:session] == session
224
239
 
225
- resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
240
+ saved, corrupt = state(ctx, session)
241
+ resolved = { session: session, **(saved || configured(ctx)), corrupt: corrupt }
226
242
  @mutex.synchronize { @resolved = resolved }
227
243
  end
228
244
 
245
+ # The state file's cap and step ({cap:, step:}), nil without one (or
246
+ # one without a "cap"), and the file's path when it is there but
247
+ # doesn't read (logged; the user is told by #corrupt), else nil.
229
248
  def state(ctx, session)
230
- return nil unless session
249
+ return [nil, nil] unless session
231
250
 
232
- saved = JSON.parse(File.read(state_path(ctx, session)))
233
- return nil unless saved.is_a?(Hash) && saved.key?("cap")
251
+ path = state_path(ctx, session)
252
+ saved = JSON.parse(File.read(path))
253
+ raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
254
+ return [nil, nil] unless saved.key?("cap")
234
255
 
235
256
  cap = self.class.amount(saved["cap"])
236
- { cap: cap, step: self.class.amount(saved["step"]) || cap }
237
- rescue Errno::ENOENT, JSON::ParserError
238
- nil
257
+ [{ cap: cap, step: self.class.amount(saved["step"]) || cap }, nil]
258
+ rescue Errno::ENOENT
259
+ [nil, nil]
260
+ rescue JSON::ParserError => e
261
+ ctx.log.warn(:costcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
262
+ [nil, path]
239
263
  end
240
264
 
241
265
  # The first of cap:, child: (a delegate's) and default: that is set and
@@ -259,14 +283,32 @@ class Plugin
259
283
  share.is_a?(Numeric) && share.positive? ? share : nil
260
284
  end
261
285
 
286
+ # A state file there that doesn't read (#state) is written over: logged
287
+ # and said, never a silent replace.
262
288
  def save(ctx, session, data)
263
289
  path = state_path(ctx, session)
264
290
  FileUtils.mkdir_p(File.dirname(path))
291
+ replacing = unreadable_file?(path)
265
292
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
293
+ replaced(ctx, path) if replacing
266
294
  cap = self.class.amount(data["cap"])
267
295
  @mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
268
296
  end
269
297
 
298
+ # Whether +path+ is there but doesn't read (not JSON, or not a mapping).
299
+ def unreadable_file?(path)
300
+ !JSON.parse(File.read(path)).is_a?(Hash)
301
+ rescue Errno::ENOENT
302
+ false
303
+ rescue JSON::ParserError
304
+ true
305
+ end
306
+
307
+ def replaced(ctx, path)
308
+ ctx.log.warn(:costcap_state_replaced, path: path)
309
+ ctx.notify("cost-cap: #{path} didn't read; replaced with the new cap", level: :warn)
310
+ end
311
+
270
312
  def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
271
313
 
272
314
  def money(value) = self.class.money(value)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.107.0
3
+ version: 0.108.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
8
  self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
9
9
  config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
- memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
10
+ memory_guide.md: sha256:dfb3982cf281bf7920d018ea65b68b5755f86d8bd1e03bab9db42393a94ec7e0
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -26,7 +26,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
26
26
  - `name: "entry", scope: "system"|"project"` — scoped read.
27
27
  - `name: "a, b, c"` — comma-separated, concatenated with `\n\n---\n\n`.
28
28
  - `name: ""` (optionally with `scope`) — reads the index. Empty name with no scope returns both indexes concatenated (`Project memories:` / `System memories:`). Use this at start to discover what exists.
29
- - Returns `Error: memory not found: …` on miss — treat as missing, not fatal.
29
+ - Returns `Error: memory not found: …` on miss — treat as missing, not fatal. A `Hint:` line after it means this project's memory folder looks orphaned (moved from another path); tell the user the folders it names, don't rename them yourself.
30
30
 
31
31
  ### `memory_write` — write/update entries
32
32
 
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: work-cap
3
- version: 0.1.1
3
+ version: 0.1.3
4
4
  scope: system
5
5
  description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:f4ce7ea7f5801490a29e232c41c7bca2007c140d21f1a78bbfb60e6b6fd4f64b
9
+ sha256: sha256:3c93baec9ec20e147803311e1eb16d699dc169b52680374c1e6ff865a82204ee
10
10
  # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
11
  requires_chi: ">= 0.100.0"
12
12
  # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off