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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +3 -2
- data/docs/bundles.md +21 -0
- data/docs/cli.md +10 -8
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +4 -4
- data/docs/internals/attached-context.md +8 -6
- data/docs/internals/block-renderers.md +8 -1
- data/docs/internals/bridge.md +18 -5
- data/docs/internals/engine.md +221 -100
- data/docs/internals/inline-widgets.md +136 -0
- data/docs/internals/objectives.md +8 -6
- data/docs/internals/participants.md +5 -5
- data/docs/internals/prompt-caching.md +12 -6
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/plugins.md +2 -0
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +16 -1
- data/lib/samagotchi/answer_text.rb +1 -2
- data/lib/samagotchi/block_renderers.rb +5 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command/check.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
- data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -125
- data/lib/samagotchi/engine/guardrails.rb +4 -2
- data/lib/samagotchi/engine/messages.rb +9 -6
- data/lib/samagotchi/engine/objectives.rb +22 -90
- data/lib/samagotchi/engine/participants.rb +15 -10
- data/lib/samagotchi/engine/plugins.rb +3 -1
- data/lib/samagotchi/engine/questions.rb +1 -1
- data/lib/samagotchi/engine/side_requests.rb +5 -2
- data/lib/samagotchi/engine/toolkit.rb +33 -190
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +16 -39
- data/lib/samagotchi/engine/verify.rb +7 -143
- data/lib/samagotchi/engine.rb +162 -99
- data/lib/samagotchi/engine_peers.rb +1 -1
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/event_fanout.rb +18 -12
- data/lib/samagotchi/guardrail_wiring.rb +83 -82
- data/lib/samagotchi/guardrails/gate.rb +41 -40
- data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/targets.rb +6 -4
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +3 -1
- data/lib/samagotchi/inline_widgets.rb +325 -0
- data/lib/samagotchi/kernel_loop.rb +194 -216
- data/lib/samagotchi/llm/backend.rb +3 -1
- data/lib/samagotchi/llm/chat_loop.rb +66 -42
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/llm_context_forget.rb +5 -3
- data/lib/samagotchi/model_binding.rb +3 -3
- data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
- data/lib/samagotchi/question_desk.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +1 -1
- data/lib/samagotchi/session_manager.rb +8 -13
- data/lib/samagotchi/session_retention.rb +24 -4
- data/lib/samagotchi/steer.rb +12 -1
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +84 -61
- data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
- data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
- data/lib/samagotchi/testing/boundary.rb +7 -0
- data/lib/samagotchi/testing/bundle.rb +7 -5
- data/lib/samagotchi/testing/install.rb +3 -2
- data/lib/samagotchi/tool_activity.rb +8 -6
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tool_response.rb +4 -3
- data/lib/samagotchi/tool_runner.rb +93 -101
- data/lib/samagotchi/toolkit_binding.rb +40 -14
- data/lib/samagotchi/tools/deferred_tools.rb +17 -21
- data/lib/samagotchi/tools/registry.rb +43 -40
- data/lib/samagotchi/tools/tool_index.rb +135 -88
- data/lib/samagotchi/tools/tool_surface.rb +173 -0
- data/lib/samagotchi/turn_context.rb +67 -0
- data/lib/samagotchi/turn_flow.rb +7 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
- data/lib/samagotchi/turn_stages/objectives.rb +146 -0
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
- data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
- data/lib/samagotchi/turn_stages/warmup.rb +83 -0
- data/lib/samagotchi/turn_state.rb +46 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -1
- data/lib/samagotchi/web/markdown_renderer.rb +39 -14
- data/lib/samagotchi/web/message_display.rb +8 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/block_host.js +74 -199
- data/lib/samagotchi/web/public/block_renderers.css +2 -0
- data/lib/samagotchi/web/public/block_source.js +7 -4
- data/lib/samagotchi/web/public/block_watch.js +256 -0
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/composer_fold.js +3 -0
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -2
- data/lib/samagotchi/web/public/index.html +28 -9
- data/lib/samagotchi/web/public/inline_widgets.js +146 -0
- data/lib/samagotchi/web/public/owner_verify.js +1 -1
- data/lib/samagotchi/web/public/participant.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +1 -1
- data/lib/samagotchi/web/public/stage_watch.js +6 -2
- data/lib/samagotchi/web/public/text_roots.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/verify_target.js +1 -1
- data/lib/samagotchi/web/public/widget_picks.js +130 -0
- data/lib/samagotchi/web/public/widget_walk.js +230 -0
- data/lib/samagotchi/web/public/widgets.css +70 -0
- data/lib/samagotchi/web/session_access_routes.rb +8 -0
- data/lib/samagotchi/web/session_show.rb +4 -0
- data/lib/samagotchi/worker.rb +8 -5
- data/lib/samagotchi.rb +0 -1
- metadata +19 -2
- 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
|
|
120
|
-
as
|
|
121
|
-
failed one drops them
|
|
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`.
|
|
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 `
|
|
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` →
|
|
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
|
-
`
|
|
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 (`
|
|
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#
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
(`
|
|
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, `
|
|
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
|
|
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
|
-
(
|
|
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 (`
|
|
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. | `
|
|
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(
|
|
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
|
|
13
|
-
#
|
|
14
|
-
# an entry says how many it came after (+past_idless+, #idless_after)
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|