samagotchi 0.154.0 → 0.155.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -0,0 +1,136 @@
1
+ # Inline widgets: chi-ui blocks the page draws
2
+
3
+ A model can write a ```` ```chi-ui ```` fence in an answer: YAML, `type:` first, a **picks** (rows of options to choose
4
+ from, which fill the reply) or a **walk** (a scenario stepped through). Core draws it, not a bundle: the server parses
5
+ the YAML once and the page draws from the parsed data. Where it doesn't draw (the terminal, `web.markdown` off, a
6
+ refused block) the YAML reads as text. The server half (the parse) and the page half (the drawers) are below.
7
+
8
+ What tells the model the blocks exist is the shipped inline-widgets bundle
9
+ ([bundles.md](../bundles.md#the-inline-widgets-bundle)): two prompt sections, `picks` and `walk`, each with an example
10
+ its spec holds to a strict parse. Without it nothing here changes, and blocks already in a history still draw.
11
+
12
+ ## The path
13
+
14
+ 1. `Web::MarkdownRenderer#fences` walks the `code_block` nodes, as for block renderers' `data-lang`
15
+ ([block-renderers.md](block-renderers.md), The path), keeping each one's language and its literal
16
+ (`string_content`, the source between the fences, also inside a list item or a blockquote) as a `Fence`.
17
+ 2. After sanitize's walk, `mark_languages` pairs them with the `<pre>`s by position and, for a `chi-ui` one, calls
18
+ `InlineWidgets.parse(literal)`. The block gets `data-widget` (the JSON of the data with its `type`) or
19
+ `data-widget-error` (the one-line reason), and `data-widget-lenient` when the lenient pass was needed. A count
20
+ that differs sets nothing, as for `data-lang`.
21
+ 3. Every HTML path goes through `render`, so answers, cards, delegate reports and `/api/render` all carry the
22
+ attributes; which of them draw is the page's choice (answer texts only).
23
+ 4. `chi-ui` is `BlockRenderers::RESERVED`: a bundle's `block_renderers:` can't claim it, so the page never has two
24
+ drawers for one block.
25
+
26
+ **Safety.** The JSON is an attribute value: Nokogiri escapes its quotes, and `<`, `>` and `&` in it are `\u` escapes
27
+ (`JSON_MARKUP`), so the HTML holds no model markup even as attribute text. The page draws its strings as text.
28
+
29
+ ## The parse (`Samagotchi::InlineWidgets`)
30
+
31
+ Pure Ruby, no web, no Engine. `parse(source)` returns a `Result`: `ok?`, `type`, `data` (string keys, JSON-ready),
32
+ `lenient?`, `error`, and `widget_json`.
33
+
34
+ - **Strings stay strings.** The YAML is never loaded: `Tree` reads Psych's node tree and takes each scalar's own text.
35
+ YAML 1.1 would make `- no` false (a real eval block has one), `id: 1.10` the float 1.1, and a date a `Date` that
36
+ `safe_load` refuses. A plain empty value, `~` or `null` is absent.
37
+ - **Plain data only.** An anchor, an alias or a tag (`!ruby/object`, `!!str`) is refused, as is a second document, a
38
+ key that isn't a scalar, a root that isn't a mapping, text that isn't valid UTF-8, and nesting past 8 levels
39
+ (`MAX_DEPTH`; the deepest legal block nests 6). A stack overflow is rescued too, as the backstop: it is no
40
+ `StandardError`, so it would escape `render` and break every render of the history.
41
+ - **Strict first, then the lenient pass.** Only a block Psych can't parse gets `Lenient.quote` and a second parse.
42
+ The pass is eval 0013's (`~/.claude/plans/evals/0013-inline-widgets/scripts/validate.rb`): the free-text values
43
+ (`title text why does sees behind when recommended`) and the list items that aren't mappings go in double quotes
44
+ unless they are one clean scalar already. It mends the traps models fell into: a colon in a value
45
+ (`does: Asked: ...`), a colon in an option (`- B: keep 1 h`), a value starting with a quoted word
46
+ (`text: "x" is called`). It is line by line, so a YAML error's line still points at the source. It can't mend a
47
+ comma in a flow list (`options: [A allow, B deny project, allow system]` is three options, so the recommended one
48
+ isn't one of them: eval round 1's failures), and a block that parses strictly into the wrong shape isn't retried.
49
+ - **Folds.** A block that parses strictly can still have an option or a free-text value that YAML read as a mapping
50
+ of one pair of scalars (`- fix now: drop the command`). Where text is wanted (an option, `title`, `text`, `why`,
51
+ `does`, `sees`, `behind`, `when`, `recommended`), that mapping is taken as the text written: the source slice from
52
+ the key to the value's end when both sit on one line, else `key: value`. The Result is then `lenient?`. A mapping
53
+ of several pairs, or one holding a list or a mapping, stays an error, and so does one where an id, an actor, a
54
+ `next` or a `goto` goes. This is a mend, not a second lenient pass (`Tree#folds`, `Schema#text`).
55
+ - **Normalised.** Ids are strings, unknown keys are dropped, optional values are absent rather than null, and text is
56
+ stripped. The page never sees model junk.
57
+
58
+ ### The schemas
59
+
60
+ | | Required | Optional | Rules |
61
+ |---|---|---|---|
62
+ | picks | `rows`: rows of `id`, `text`, `options` (2-4 texts) | `title`; on a row `recommended`, `why` | ids unique; a row's options unique; `recommended` is one of the options |
63
+ | walk | `title`, `actors` (2-5 texts), `steps`: steps of `id`, `actor`, `does` or `sees` | on a step `behind`, `next`, `cases` (each `when` + `goto`) | ids unique, none of them `end`; actors unique; `actor` is one of the actors; `next` names a step or is `end`, and not the step itself; every `goto` names a step |
64
+
65
+ Eval round 1's `branches:` (the same shape as `cases:`) is read as `cases`.
66
+
67
+ **`next: end`** ends the walk at that step: a branch that ends early, such as a success step written before the
68
+ error step it must not run into. The normalised JSON keeps `"next": "end"`, and the page shows the step as the last
69
+ one (its "end" tag, then "Start over"). A `goto` can't be `end`. In YAML:
70
+
71
+ ```yaml
72
+ - id: s4
73
+ actor: user
74
+ sees: the saved form
75
+ next: end
76
+ ```
77
+
78
+ ### The caps
79
+
80
+ The source is at most 16 KB, checked before the parse. After it: a picks has at most 12 rows, a walk at most 30 steps
81
+ and 2-5 unique actors, and every string is at most 300 characters. An error repeats at most 40 characters of a bad
82
+ `type`, and never a tag.
83
+
84
+ ## The page (`inline_widgets.js`)
85
+
86
+ `createWidgetDrawer` is a core drawer of `block_watch.js` (its header has the contract), wired in `app.js` ahead of the
87
+ bundles' frame drawer through `createBlockHost`'s `drawers`. So a `chi-ui` block gets the watcher's paths: a reload, a
88
+ session switch, a turn's end, Revise's clones, the stage's hand-off to `#history`.
89
+
90
+ - **What it draws.** A `<pre>` with `data-widget` gets a light-DOM `<figure class="block-drawn widget">` right before it,
91
+ holding a `.widget-host` and the shared `.block-toggle` (block_source.js `setSourceShown`, the same icon; the
92
+ figure's `data-toggle-picture` makes its other label "Show the widget"). The `<pre>` stays, hidden: the DOM is the state, copy.js gives it its `.code-wrap`
93
+ copy button (it copies the YAML, which is the answer's text), and the toggle shows it. The toggle is in the light DOM
94
+ because a click in a shadow root reaches the page's delegated listeners retargeted at the host.
95
+ - **Shadow DOM.** The host has an open shadow root holding the widget, with `widgets.css` adopted
96
+ (`adoptedStyleSheets`): the page fetches it once into a constructable `CSSStyleSheet` (`loadWidgetSheet`), and `draw`
97
+ returns a Promise that places the widget once it is there, so no widget flashes unstyled and the view held
98
+ (`holdingView`) measures the styled one. A failed fetch draws unstyled and the next draw tries again. Its words are then out of the bubble's `textContent`, which the shown-texts dedupe (`seedSeenFromDom`), copy's
99
+ fallback and an annotation's quote read, as with a renderer's `<img>`-only figure. chi's `:root` tokens reach it as
100
+ inherited custom properties, so light and dark follow the page. Playwright's locators pierce an open shadow root;
101
+ `page.evaluate`'s DOM calls don't. Whatever asks `document.activeElement` whether a field has the focus looks
102
+ through shadow roots (`composer_fold.js editable`, `stage_watch.js`), or typing in a note opens a folded composer.
103
+ - **A clone redraws.** `cloneNode` doesn't copy a shadow root: a Revise clone (`revise_switch.js viewOf`) arrives with
104
+ the figure and an empty host, which `needsDraw` sees, and `draw` fills it again with a fresh widget and its own
105
+ listeners. A widget's position and picks live in the widget, so a clone or a rewrite starts over (accepted for v1).
106
+ - **A refused block** (`data-widget-error`) stays source under `failBlock`'s muted `.block-note` ("Couldn't draw this
107
+ chi-ui block: …", an attribute CSS shows, a click copies it). Bad JSON or an unknown `type` fails the same way, from
108
+ the watcher. A `chi-ui` `<pre>` with neither attribute (an older server) is left alone.
109
+ - **walk** (`widget_walk.js`): the swimlane (one column per actor, one row per step; the current, seen and a hovered
110
+ case's target marked; a click jumps), the step card (actor, `does`, "You see", the dashed "Behind it"), a fork's
111
+ "Which case?" buttons, Back / Next / Start over, the path taken, and ← → while the widget has the focus
112
+ (a move that removes or hides the focused button leaves the focus on the widget; a click on the current step is no
113
+ move). `walkRoutes`
114
+ resolves where a step goes: its `next` (`end`: nowhere), else the step below; a fork's `cases` as written, a case whose `when` is no
115
+ condition (round 1's `always`) reading as the usual path, alone it is the step's next. At a phone's width the
116
+ swimlane scrolls sideways inside the widget; the page doesn't.
117
+ - **picks** (`widget_picks.js`): one row per item, its options as chips (`aria-pressed`), the recommended one marked
118
+ and picked to start with, a "+ note" per row, and "Fill reply".
119
+
120
+ **The only way out is composer text.** Fill reply builds `id: option (note) · …` from the picked rows (a row with no
121
+ pick is left out) and hands it to app.js's `fillComposer`, after a typed draft on a new line (`withDraft`; the draft
122
+ is kept). It never sends: the owner reads the text and sends it or not. A walk has no way out at all. The model's
123
+ strings reach the page through `textContent` and attributes only, never `innerHTML`, so a step reading
124
+ `<img src=x onerror=…>` shows as those characters.
125
+
126
+ **What doesn't draw.** The participant page (`participant.js` wires no block host), `web.markdown` off (no
127
+ `data-lang`), a live answer while it streams (its YAML shows until the turn's end re-renders it as markdown), and
128
+ anything outside `TEXTS`: cards, question replies, the asking slot, delegate reports. The terminal shows the YAML.
129
+
130
+ ## The fixtures
131
+
132
+ `spec/fixtures/inline_widgets/` holds eval 0013's 78 real blocks (47 from round 2, 31 from round 1), one file each,
133
+ and `manifest.yml`, the outcome (ok, lenient or error) the eval's `check()` gave each when they were copied, but for
134
+ r2-deepseek-picks-review.2: its options are one-pair mappings, an error in the eval, and a fold (lenient) here.
135
+ `spec/inline_widgets_spec.rb` checks every one gets it: all 47 of round 2's draw, and round 1's errors are the
136
+ comma splits.
@@ -116,9 +116,9 @@ Bridge's `TurnAccumulator#current_messages`) leaves tool calls out. A move shows
116
116
 
117
117
  `action: propose` (`change` add, reword, done or drop; `id`; `text`; `note`, why) checks the change against the list
118
118
  (`Objectives::Proposal#refusal`: its line through `Objectives::Edit`, the edit the card's Apply runs, so the tool refuses
119
- what Apply would) and hands it to `Engine#propose_objective`, which keeps it until the turn is over,
120
- as `#context_choice`'s cards are kept. `run_turn`'s ensure shows them only for a turn that completed: a canceled or
121
- failed one drops them (`Engine#discard_objective_proposals`), so its Apply card never outlives a turn whose call a
119
+ what Apply would) and hands it to `Engine#propose_objective` (`TurnStages::Objectives#propose`), which keeps it until the turn is over,
120
+ as `TurnStages::ContextJudge`'s cards are kept. `run_turn`'s ensure (`TurnStages::Objectives#finally`) shows them only for a turn that completed: a canceled or
121
+ failed one drops them, so its Apply card never outlives a turn whose call a
122
122
  rollback has taken out of the history, and nothing carries over to the next turn. A card is between turns (no
123
123
  `in_turn`), so it writes no `pending_card.json`, the stage doesn't say it waits, and it keeps its buttons over a
124
124
  restart (`CardStore` takes them only from a card shown in a turn). The same change twice in a turn is one card. A P3
@@ -139,7 +139,7 @@ any call. An add's text can't split on ` | `. The body names the line, for a ter
139
139
 
140
140
  `Objectives.view` (`List#to_view`) is what the page draws: the items, each with its last move, and `pending`, whether
141
141
  the newest list is a file still waiting. The session read (`objectives`, no key with no list) and the live
142
- `:objectives_updated` carry it. `Engine#announce_objectives` emits when the view differs from the last: after an edit,
142
+ `:objectives_updated` carry it. `Engine#announce_objectives` (`TurnStages::Objectives#announce`) emits when the view differs from the last: after an edit,
143
143
  once a list note is in (`add_context_note`), at a turn's end (its moves) and after a rollback between turns. A session
144
144
  with no list emits nothing. Between two tabs that is all it takes. A participant gets neither: the event is in
145
145
  `ParticipantView::DROPPED`.
@@ -259,7 +259,9 @@ turn isn't on the page until the turn's end (`announce_objectives`), so its tap
259
259
 
260
260
  ## System 1 in audit
261
261
 
262
- `ObjectivesAudit`, off unless `objectives.system_one: audit`. See [system-one.md](../system-one.md#objectives-in-audit).
262
+ `ObjectivesAudit`, off unless `objectives.system_one: audit`. `TurnStages::Objectives` asks it: `incoming` at an owner's
263
+ turn's start (a person's prompt only), `outgoing` after the end event of a completed answer, `note` for a note added
264
+ between turns (docs/internals/engine.md, "Turn stages"). See [system-one.md](../system-one.md#objectives-in-audit).
263
265
 
264
266
  ## Not in this version
265
267
 
@@ -269,4 +271,4 @@ Taking it out: old notes stay readable framed bubbles and keep being sent, and t
269
271
  stay as history; with a kit no longer declaring the tool, they are ordinary saved calls. The card's bundle uninstalls. The
270
272
  page's pieces are the `objectives_*.js` modules and `objectives.css`, wired in a few lines of `app.js`; without them a list note
271
273
  falls back to the default bubble once `noteBubbleTemplate`'s first line goes. Check this is `objectives_check.rb`, its line in the command and
272
- the `check` key; the audit is `objectives_audit.rb` and three calls in `Engine`. Old `objective_checks.jsonl` files and saved runs are then read by nothing.
274
+ the `check` key; the audit is `objectives_audit.rb` and three calls in `TurnStages::Objectives`. Old `objective_checks.jsonl` files and saved runs are then read by nothing.
@@ -214,10 +214,10 @@ the list's `mode` (the panel's words) and the grant's stamp: `Grant#mode` / the
214
214
  turns) stays review until the owner invites them again.
215
215
 
216
216
  A turn's mode is `ParticipantMode.for_turn(session, entry)`: `:pair` only for a stamped entry in a pair session.
217
- The worker decides it once at the turn's start (`GuardrailWiring#begin_turn` → `#turn_mode`, from the entry
217
+ The worker decides it once at the turn's start (`GuardrailWiring#begin_turn` → `TurnStart#mode`, into the turn's `TurnContext#participant_mode`, from the entry
218
218
  `SessionParticipants#entry_for` reads), and everything in the turn reads that: the stamp on the opening message
219
219
  (`participant_mode`), the boundary in `GuardrailWiring#checks`, `ToolRunner#unreached_group_refusal` (through
220
- `KernelLoop#participant_mode`: a review turn's boundary words the refusal; a pair turn gets the owner's), the
220
+ the call's `TurnContext`: a review turn's boundary words the refusal; a pair turn gets the owner's), the
221
221
  mid-turn stop and the cap notice's words. The system prompt's line is the pair one once an active participant is
222
222
  stamped pair (`SessionParticipants#prompt_mode`); the participant page's `viewer.mode` is their own grant's
223
223
  (`ParticipantMode.live_for`).
@@ -323,7 +323,7 @@ and `ctx.turn_author` names it ([hooks](../hooks.md#participants-turns)).
323
323
  their $2.00 review cap: their comments are refused until you raise it (chi share cap ID p_… AMOUNT)." (a working
324
324
  session's: "Alex reached their $2.00 cap: their turns stop and are refused until you raise it (…)"). A review
325
325
  turn's spend counts from its end, so a review turn can finish past the cap. A pair turn stops mid-turn: before each
326
- generation (`KernelLoop#generation_guard`, both loops) `Engine#guard_participant_spend` asks the worker's check
326
+ generation (`KernelLoop::Services#generation_guard`, both loops) `Engine#guard_participant_spend` asks the worker's check
327
327
  (`Worker#participant_turn_stop`: `metrics.spend(author)` counts the running turn's finished generations) and, past
328
328
  the cap, stops the turn on the plugins' stop path (`HookRoutes#stop_turn`: "stopped the turn: Alex reached their
329
329
  $2.00 cap"), with the owner's notice once per cap. A generation's cost is known only when it ends, so a pair turn
@@ -469,7 +469,7 @@ fields: a field added to an event later reaches nobody until it is named there.
469
469
  its final assistant reply (`AnswerTail`: the turn's last assistant message with text) contains the quote. Matching is case-sensitive, collapses whitespace, and strips common
470
470
  Markdown presentation (links, headings, list/blockquote prefixes, emphasis and code delimiters) from both sides;
471
471
  this matches the rendered text the page selects without treating different casing as the same claim. The quote may
472
- also be in the answer as the pages show it (`Engine#verify_quote_in?`, `Redact.text`: the session's
472
+ also be in the answer as the pages show it (`TurnStages::VerifyAnchors#quote_in?`, `Redact.text`: the session's
473
473
  folder relative, home `~`, secret shapes `[redacted]`): a participant's page has only that text, and the owner's
474
474
  page quotes it the same way (`owner_claim.js` `opts()`, `normalizeClaim`; `spec/fixtures/redact_cases.json` keeps the two
475
475
  sides alike), so a claim naming a path is taken on both. A fresh child
@@ -510,7 +510,7 @@ fields: a field added to an event later reaches nobody until it is named there.
510
510
  `pair` only for a grant stamped to act in a working session whose worker runs pair turns, else `review`). Every
511
511
  turn of a participant's child is theirs, whoever starts it (the owner typing into it, a continue, a reminder):
512
512
  `VerifyFreshOrigin#origin_for` sets the author in `Engine#begin_turn` and in the worker, so their boundary
513
- (`GuardrailWiring#turn_mode` reads the saved mode, never a lookup), their System 1 skip, their once-only asks to
513
+ (`GuardrailWiring#begin_turn` reads the saved mode, never a lookup), their System 1 skip, their once-only asks to
514
514
  the owner and the stamps on their messages follow. The child has no `participants.json`: its participant lookups
515
515
  (`Session#participants_source_id`) read the parent's, so a revoke, an expiry or the parent's archive refuses the
516
516
  child's next turn and stops a pair turn mid-turn, as in the parent; their audit lines go to the parent's
@@ -67,15 +67,21 @@ right-hand column happens.
67
67
  the loop, the thinking level, the turn's LLM context layers; a `/thinking` change builds under another key). All
68
68
  of them are dropped only on `@prompt_builder.reset!`: a model switch (`Engine#switch_model!`), a plugin's
69
69
  `tools_changed!` or `prompt_changed!` (`Engine#prompt_changed!`), a profile change, or a toolkit change
70
- (`Engine#apply_toolkit!`). A per-turn rebuild would break everything below it.
70
+ (`Engine#apply_toolkit!`). A per-turn rebuild would break everything below it. `reset!` swaps in a new cache
71
+ (no lock; it runs on plugin and Bridge threads, and inside a build through the profile lookup): a build it lands
72
+ in returns the same bytes but is not kept, so the next build reads the sources again.
71
73
  - The tools layer is shared **per toolkit** (configuration.md, "Toolkits"): sessions on the same toolkit share it,
72
74
  and a chain's links keep theirs. A session's toolkit is saved when it is created and never resolved again from
73
75
  config. A change (`/toolkit`, `ctx.use_toolkit`) is applied only at a turn's start (`Engine#apply_toolkit!`,
74
- beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
75
- would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
76
- they were before toolkits.
76
+ beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request from the turn's
77
+ `Tools::ToolSurface` (built once in `Engine#generate` from the Registry, under the layers the turn's prompt was
78
+ built with), so its requests declare what the turn's prompt does, and a mid-turn change shapes only the next
79
+ turn's surface. The native declarations, the not-loaded line and the kit prompt come from a surface too: each
80
+ `SystemPrompt` build reads the Registry once as `ToolSurface.for(registry, layers:)` (its `declared`, `left_out`
81
+ and `index_only`, its toolkit's prompt), so the three agree and match the turn's (engine.md, "ToolSurface"). Under `full` nothing is filtered and no line is added: the bytes are what they were before
82
+ toolkits.
77
83
  - A toolkit suggestion (`toolkit.suggest.mode: on`) is set before `apply_toolkit!` of the turn it was asked on
78
- (`Engine#settle_toolkit_suggestion` → `ToolkitBinding#suggest`), and no request goes out before that, so on a
84
+ (`TurnStages::ToolkitSuggestion#settle` → `ToolkitBinding#suggest`), and no request goes out before that, so on a
79
85
  session's first turn it costs nothing. Its **deferred switch** (a later prompt after `undecided`, within
80
86
  `toolkit.suggest.defer_turns` / `defer_tokens`) is engine-side, so `/toolkit`'s CacheConfirm doesn't apply: it
81
87
  changes the declared tools at the front of the request, and the server re-reads **system prompt + tools +
@@ -169,7 +175,7 @@ right-hand column happens.
169
175
  The next turn's prompt differs from the turn's last request at its tail: the previous answer's thinking is
170
176
  stripped (the models' specs require it). A hybrid model on llama.cpp can only resume from a restore point, so the
171
177
  next turn prefills from well before the end, more with every turn. `PromptWarmup` moves that work into the time
172
- the user reads: after a completed turn, `Engine#warm_up_next_turn` formats the next prompt up to where the new
178
+ the user reads: after a completed turn, `TurnStages::Warmup#after` formats the next prompt up to where the new
173
179
  user message starts (`KernelLoop#warmup_prompt`) and sends it in a thread (`Client#warm_up`: native
174
180
  `/completion`, `n_predict: 1`, not streamed, no retries, log purpose `warmup`, then a `model warmup` line with
175
181
  `cached=`, `prefilled=` and `prefill_ms=`).
@@ -24,7 +24,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
24
24
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
25
25
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
26
26
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
27
- `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js` and the `objectives_*.js` modules are new modules for that reason. The last three `block_*` draw a bundle's fenced blocks: [block-renderers.md](block-renderers.md).
27
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js`, `block_watch.js`, `card_stamps.js`, `inline_widgets.js`, `widget_walk.js`, `widget_picks.js` and the `objectives_*.js` modules are new modules for that reason. The last four `block_*` draw fenced blocks (`block_watch.js` finds and watches them, `block_host.js` is its frame drawer for a bundle's renderers): [block-renderers.md](block-renderers.md). `inline_widgets.js` is its core drawer for `chi-ui` blocks, drawing `widget_walk.js`'s and `widget_picks.js`'s widgets in a shadow root styled by `widgets.css`: [inline-widgets.md](inline-widgets.md).
28
28
 
29
29
  ## Two pages, two models
30
30
 
@@ -51,7 +51,7 @@ change.
51
51
  | **`store.current`** (`session_state.js`: `emptySessionState` / `sessionStateFrom`; per field: `session_fields.js FIELDS`) | the open session's fields, about 40 (info bar, chips, verdicts, timing, worker, stream cursor) | `store.update` / `setStatus` from app.js, `stream_handlers.js`, `send_turn.js` (`firstPreview`, `owner`), `session_actions.js` (`status`), `owner_verify.js` (`verdicts`, `verifyFresh`); `forgetSession`'s `replaceCurrent`. Two writers in place, on purpose (D8 in `FIELDS`): live_timing.js's `activeTurn`, verdict_fresh.js's running list | the store runs the field's redraws (`FIELDS[key].redraws`) |
52
52
  | **`store.sessions`** (the hub's projection) | every card | `store.update({sessions})` from app.js: `openSessionEvents` (`applySessionEvent`), `refresh` (the list fallback), `addCreatedSession`, `applyView` (re-sorted), `replaceSelectedCard` (`setLiveStatus` through `setStatus`, `readTurnEnd`, `setUsedMemories`), the stream's close (`withoutWorker`); `session_actions.js` (`setSessions`) | the store: `lists` and `infoBar`. **Always a new array**; an entry is replaced, never changed in place. `followSelectedSession` copies the open one's owner and worker (`fieldsFromCard`) and, when not streaming, its status (`setStatus(…, "hub")`) into `store.current` |
53
53
  | **Factory closures**: the `create*` factories (popovers, panels, views) | each component's own state: open-for, busy, error, its own fetched list | the factory | an `onChange` hook back into app.js, or a getter the factory reads at draw time |
54
- | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), what opens a turn (`TURN_PROMPT`, `bubbles.js`: a prompt or a wake's note, the same turns `Steer.turn_opening?` counts for a card's or the recap's `turns_since`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
54
+ | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), what opens a turn (`TURN_PROMPT`, `bubbles.js`: a prompt or a wake's note, the same turns `Steer.turn_opening?` counts for a card's or the recap's `turns_since`), an opening's id for a card's stamp (`data-turn-id` on a prompt, `data-opening-id` on a wake's note or `/continue no`'s reason line; `card_stamps.js` places a snapshot's card after the stamped opening, by its count only without one), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
55
55
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
56
56
  | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
57
57
 
@@ -102,7 +102,8 @@ when it has outputs and none is `sent`. An edit-only item, a failed run's file o
102
102
  pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_working_set`, `EventFanout`'s):
103
103
  - after each `:generation_started`, in `build_stream_event_handler` (`EventFanout`), from the running turn's
104
104
  conversation (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
105
- (`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
105
+ (`TurnContext#settings`' `llm_context`; `EventFanout#working_set_llm_context`: the running turn's, the last turn's
106
+ until it has its own). Both loops start a generation after `apply_llm_context!`, so the set is
106
107
  what that request sends;
107
108
  - once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
108
109
  turn-end apply has run by then);
data/docs/plugins.md CHANGED
@@ -1387,6 +1387,8 @@ block_renderers:
1387
1387
  it refuses with "No memories to build".
1388
1388
  - The first installed bundle (by name) with a language draws it. A bundle whose `requires_chi` this chi doesn't meet
1389
1389
  draws nothing.
1390
+ - `chi-ui` is reserved: chi draws it itself (inline widgets, [internals/inline-widgets.md](internals/inline-widgets.md)),
1391
+ so a manifest that lists it is refused.
1390
1392
  - **What it needs of the page:** `web.markdown` on (a default-off setting, and the commonmarker gem). With it off a
1391
1393
  fence has no language in the page and nothing draws, though a hint in the bundle's prompt section still reaches
1392
1394
  the model: say so in the bundle's README.
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, web-search, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, inline-widgets, web-search, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
data/docs/sessions.md CHANGED
@@ -6,7 +6,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
6
6
 
7
7
  **Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
8
8
 
9
- **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`Engine#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
9
+ **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`TurnStages::VerifyAnchors#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
10
10
 
11
11
  **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
12
12
 
@@ -110,7 +110,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
110
110
 
111
111
  - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
112
112
  - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
113
- - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
113
+ - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it and its file is 30 s old (`SessionRetention::SCRATCH_START_GRACE`, the worker start grace: `chi scratch` saves the session before the worker it spawns takes the owner lock). `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
114
114
  - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
115
115
 
116
116
  ## Session file
@@ -141,7 +141,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
141
141
  | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
142
142
  | `scratch` | A `chi scratch` session, deleted when its terminal leaves. | creation |
143
143
  | `last_turn` | How the last turn ended (`LastTurn`), for a parent's wait and the web's notifications. | the Engine, at a turn's end |
144
- | `verdicts` | Verdict cards recorded by completed Verify turns, in append order. | `Engine#record_verdict`, `VerifyFresh` |
144
+ | `verdicts` | Verdict cards recorded by completed Verify turns, in append order. | `TurnStages::VerifyAnchors#record_verdict`, `VerifyFresh` |
145
145
  | `delegate` | Started by the `delegate` tool (a fork has a `parent_id` but isn't one). | `Tools::Delegate` via `spawn_session` |
146
146
  | `llm_context` | The session's own LLM context values (strategy, apply rule, budget), over the model's; `null` with none. | creation (`--llm-context`), `/llm-context`; copied by a fork and a continue, not a delegate child |
147
147
  | `continues` | The previous link of a chain; `null` otherwise. See [Session chains](#session-chains). | `SessionContinue` |
data/docs/testing.md CHANGED
@@ -60,6 +60,21 @@ paths (replace them with placeholders), no PATH tools (the suite pins `rg` as fo
60
60
  that sets PATH to test the lookup is tagged `:real_rg`), no Ruby-version-dependent `inspect` (a Hash's or an
61
61
  exception's text changes between Ruby versions: write the string yourself).
62
62
 
63
+ The whole-turn goldens (`spec/engine_turn_golden_spec.rb`, `spec/fixtures/turn_goldens/`) run a real
64
+ `Engine#run_turn` (its own KernelLoop, ChatLoop, ToolRunner, Gate and GuardrailWiring) over `FakeProviderServer` on
65
+ the native (llama.cpp `/completion`, Qwen tool calls), `api: openai` and `api: anthropic` wires: a turn of two tool
66
+ iterations (a muted `memory_read` and a `read`, then a `write` a config rule asks about, allowed through the question
67
+ flow), a `before_turn` hook switching the model (the request names the new model with the old one's sampling), and a
68
+ review participant's turn whose `write` the review boundary refuses. Each golden pins every request (the chi-set
69
+ headers, the body in its key order, the native prompt as text), the turn's events (keys sorted; the context-status
70
+ estimates and clock values masked), each `before_tool_call` payload, the approval asked and the session's messages.
71
+ System 1, the toolkit suggestion, the context judge, the recap and the warm-up are off by config, so the order is the
72
+ turn's own. The arch P3 refactors (TurnContext, turn stages, ToolSurface) keep them byte-identical: a phase that
73
+ changes one stops and asks. An intended change elsewhere: `UPDATE_PROMPTS=1 bundle exec rspec
74
+ spec/engine_turn_golden_spec.rb`, then read `git diff spec/fixtures/turn_goldens/`; a failure writes what the run got
75
+ to `tmp/turn_goldens/<golden>.actual` and names the `diff` to run. Changes to the turn path (engine, kernel loop, chat loop,
76
+ tool runner, gate, guardrail wiring, system prompt, tool declarations, registry) pick it in `script/affected-specs`.
77
+
63
78
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
64
79
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
65
80
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
@@ -245,7 +260,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
245
260
  | `reply_wait_looks.rb` | `ReplyWaitLooks`: counts a `ReplyWait`'s polls, so a step waits for the looks instead of a delay |
246
261
  | `stamps.rb` | *helper*: `next_stamp`, distinct ordered times for the lib's `now:` seams; the pinned history clock |
247
262
  | `task_dirs.rb` | `with_task_dir`: a tasks' folder of its own, every task stopped and its recorders joined after |
248
- | `test_kernel.rb` | `TestKernel`: `test_kernel` (a real `KernelLoop` to stub `#run` on) and `test_client` for Engine specs |
263
+ | `test_kernel.rb` | `TestKernel`: `test_kernel` (a real `KernelLoop` to stub `#run` on; its options are `KernelLoop::Services` fields), `attach_services` (swap some of a kernel's services), `run_context` and `test_client` for Engine specs |
249
264
  | `thinking_off.rb` | shared context "thinking off": `SAMAGOTCHI_THINKING_LEVEL=off` for the example |
250
265
  | `virtual_terminal.rb` | `VirtualTerminal`: just enough VT100 to check what a Screen leaves on the terminal |
251
266
  | `waiting.rb` | *helper*: `SpecWaiting` (`wait_for`, `wait_until`, `join!`, `pop!`, …), the test kit's helpers ([Waiting and time](#waiting-and-time)) |
@@ -13,7 +13,6 @@ module Samagotchi
13
13
  # thoughts and call markup inline: both hosts' thoughts come out, a thought
14
14
  # cut off before it closed too, then the calls and the wire tokens.
15
15
  module AnswerText
16
- QWEN_THINK = %r{<think>.*?</think>}m
17
16
  # The template opened the block, so the text starts inside it.
18
17
  TEMPLATE_OPENED_THINK = %r{\A.*?</think>}m
19
18
 
@@ -25,7 +24,7 @@ module Samagotchi
25
24
  text = text.to_s.scrub
26
25
  template_opened = !text.include?("<think>")
27
26
  gemma = ToolCallParser::Gemma.new(ModelProfile.gemma4)
28
- text = gemma.strip_thought(text).gsub(QWEN_THINK, "")
27
+ text = gemma.strip_thought(text).gsub(OutputFormatter::QWEN_THINK_RE, "")
29
28
  text = text.sub(TEMPLATE_OPENED_THINK, "") if template_opened
30
29
  text = ToolCallParser.strip_calls(gemma.strip_tool_calls(text), "<tool_call>", "</tool_call>")
31
30
  OutputFormatter.strip_markup(text) # drops a thought cut off before it closed
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
+ autoload :InlineWidgets, File.expand_path("inline_widgets", __dir__)
4
5
  autoload :Log, File.expand_path("log", __dir__)
5
6
  autoload :VERSION, File.expand_path("version", __dir__)
6
7
  module MemoryBundle
@@ -34,6 +35,9 @@ module Samagotchi
34
35
  # A language a block can carry: a fence's first word, downcased
35
36
  # (MarkdownRenderer sets it as data-lang under this same pattern).
36
37
  LANG = /\A[a-z0-9_+-]{1,32}\z/
38
+ # Languages core draws itself: a bundle's renderer for one would be a
39
+ # second drawer for the same block.
40
+ RESERVED = [InlineWidgets::LANG].freeze
37
41
 
38
42
  module_function
39
43
 
@@ -60,6 +64,7 @@ module Samagotchi
60
64
  unless lang.is_a?(String) && lang.match?(LANG)
61
65
  raise Invalid, "block_renderers: lang #{lang.inspect} isn't a fence's language (lowercase letters, digits, _ + -; at most 32)"
62
66
  end
67
+ raise Invalid, "block_renderers: #{lang} is drawn by chi itself; a bundle can't render it" if RESERVED.include?(lang)
63
68
 
64
69
  load = data["load"]
65
70
  unless load.is_a?(Array) && !load.empty? && load.all? { |file| file.is_a?(String) && file.match?(MemoryBundle::Manifest::SCRIPT_FILE) }
@@ -9,16 +9,22 @@ module Samagotchi
9
9
  # the recap share (Steer.turn_opening?); the store's turn log only orders
10
10
  # the ids. So a turn with no opening (a reminder, a Continue, a failed or
11
11
  # rolled-back one, a prompt stopped before the model) counts nothing, and
12
- # an opening with no id (/continue no's reason, Qwen's recovery line) counts
13
- # as it does on the page. Nothing orders such an opening against an entry, so
14
- # an entry says how many it came after (+past_idless+, #idless_after). Pure:
15
- # no Engine, no file.
12
+ # an opening with no turn id (/continue no's reason) counts as it does on
13
+ # the page. Nothing orders such an opening against an entry, so
14
+ # an entry says how many it came after (+past_idless+, #idless_after), and
15
+ # which one that is when it has an id (+after_opening+, #after_opening).
16
+ #
17
+ # The same place as a stamp (#opening_of): the id of the opening the entry
18
+ # comes after (a prompt's turn_id, a line's opening_id), which the page
19
+ # finds by its bubble, not by counting. Pure: no Engine, no file.
16
20
  class CardPlaces
17
21
  # @param turn_log [Array<String>] the turn ids, oldest first
18
22
  # @param messages [Array<Hash>] the messages the page draws
19
23
  def initialize(turn_log, messages)
20
24
  positions = turn_log.each_with_index.to_h
21
- @ids = Array(messages).select { |message| Steer.turn_opening?(message) }.map { |message| message[:turn_id] || message["turn_id"] }
25
+ openings = Array(messages).select { |message| Steer.turn_opening?(message) }
26
+ @ids = openings.map { |message| message[:turn_id] || message["turn_id"] }
27
+ @stamps = openings.map { |message| stamp_of(message) }
22
28
  @openings = @ids.size
23
29
  @opened = @ids.compact.to_set
24
30
  @places = @ids.map { |id| positions[id] }
@@ -45,13 +51,44 @@ module Samagotchi
45
51
  entry[:base_since].to_i + since
46
52
  end
47
53
 
54
+ # The opening +entry+ comes after, as #turns_since counts: its id (a
55
+ # prompt's turn_id, a line's opening_id), the page's stamp. nil when it
56
+ # comes before every opening, when that opening has no id (a line an
57
+ # older chi wrote), or for an entry from an older file (+base_since+):
58
+ # the page counts those (turns_since).
59
+ # @param entry [Hash] a store's entry: :turn_id, :past_idless, :after_opening, :base_since
60
+ # @return [String, nil]
61
+ def opening_of(entry)
62
+ return nil if entry[:base_since]
63
+
64
+ at_or_before = counted_from(entry[:turn_id])
65
+ return nil unless at_or_before
66
+
67
+ run = at_or_before..(at_or_before + idless_run(at_or_before))
68
+ # The line it saved it came after, while that line is in the run (as
69
+ # the count past it, the two save together); else the count's.
70
+ after = entry[:after_opening] && @stamps.index(entry[:after_opening])
71
+ @stamps[after && run.cover?(after) ? after : [at_or_before + entry[:past_idless].to_i, run.end].min]
72
+ end
73
+
74
+ # The id of the last opening with no turn id right after the one
75
+ # +turn_id+ counts from (#idless_after's run), nil when there is none or
76
+ # it has no id either.
77
+ # @return [String, nil]
78
+ def after_opening(turn_id)
79
+ at_or_before = counted_from(turn_id)
80
+ return nil unless at_or_before
81
+
82
+ run = idless_run(at_or_before)
83
+ run.positive? ? @stamps[at_or_before + run] : nil
84
+ end
85
+
48
86
  # The openings with no id right after the one +turn_id+ counts from: what
49
87
  # an entry shown now has come after. A rollback can only shorten the run, so
50
88
  # a count read back against it never eats a later turn.
51
89
  # @return [Integer]
52
90
  def idless_after(turn_id)
53
- position = @positions.fetch(turn_id, -1)
54
- at_or_before = position >= 0 ? @last_at_or_before[position] : nil
91
+ at_or_before = counted_from(turn_id)
55
92
  at_or_before ? idless_run(at_or_before) : 0
56
93
  end
57
94
 
@@ -70,6 +107,18 @@ module Samagotchi
70
107
 
71
108
  private
72
109
 
110
+ # The last opening whose turn is at or before +turn_id+'s in the log
111
+ # (an index into the openings), nil for none.
112
+ def counted_from(turn_id)
113
+ position = @positions.fetch(turn_id, -1)
114
+ position >= 0 ? @last_at_or_before[position] : nil
115
+ end
116
+
117
+ def stamp_of(message)
118
+ id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
119
+ id.is_a?(String) && !id.empty? ? id : nil
120
+ end
121
+
73
122
  def idless_run(opening)
74
123
  @ids.drop(opening + 1).take_while(&:nil?).size
75
124
  end