samagotchi 0.134.0 → 0.135.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 +49 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +39 -34
- data/docs/configuration.md +1 -2
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/web-frontend.md +262 -0
- data/docs/sessions.md +2 -0
- data/docs/testing.md +27 -2
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/engine.rb +69 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
- data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
- data/lib/samagotchi/guardrails/pair_command.rb +387 -78
- data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
- data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +13 -15
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +3 -3
- data/lib/samagotchi/web/public/app.js +447 -340
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/composer.css +8 -0
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +8 -4
- data/lib/samagotchi/web/public/index.html +38 -9
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +2 -2
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +27 -92
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +5 -3
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +12 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# The web frontend: where state lives
|
|
2
|
+
|
|
3
|
+
A map of `lib/samagotchi/web/public/` for whoever changes it: where the page keeps what it knows, how a change
|
|
4
|
+
reaches the screen, and the guards every new piece should use. The modules' own header comments say what each one
|
|
5
|
+
does; this page says how they hang together. The server side is [architecture.md](../architecture.md) (Web);
|
|
6
|
+
the working set's overlay is [working-set.md](working-set.md), participants are [participants.md](participants.md).
|
|
7
|
+
|
|
8
|
+
No build step: `index.html` and `participant.html` load ES modules as they are, with lit-html (`vendor/`) as the
|
|
9
|
+
only library. Pure modules (`*_model.js`, `session_state.js`, `sessions_list.js`, `turn_range.js`, …) touch no DOM
|
|
10
|
+
and have `node --test` specs under `spec/web/public/` (happy-dom for the DOM ones); the pages as a whole are covered
|
|
11
|
+
by `npm run e2e` (`spec/e2e/`). `app.js` has no unit spec.
|
|
12
|
+
|
|
13
|
+
**New exports go in new modules.** The static JS is served at unversioned URLs. Today every load revalidates
|
|
14
|
+
(app.rb `serve_static` sends `Cache-Control: no-cache` with a content ETag, and `sw.js` has no fetch handler), but an
|
|
15
|
+
open tab still holds the modules it loaded, and a proxy that ignores `no-cache` can serve an old one: a new export
|
|
16
|
+
on an existing module, imported by a new version of another, then finds the old module without it and blanks the
|
|
17
|
+
page. A factory dep a new version stops passing is the same hazard the other way round: the store's redraws took
|
|
18
|
+
`updateInfoBar` and the chip controls out of `stream_handlers.js`'s deps (and `updateInfoBar`/`updateComposerMode`
|
|
19
|
+
out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
|
|
20
|
+
until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`,
|
|
21
|
+
`turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
|
|
22
|
+
`session_fields.js`, `feature_gates.js` and `revise_stack.js` are new modules for that reason.
|
|
23
|
+
|
|
24
|
+
## Two pages, two models
|
|
25
|
+
|
|
26
|
+
| Page | Entry | Model |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Owner page**: the start page and a session page | `index.html` → `app.js` | **Render once, then patch.** A session's open (`select` → `resync` → `renderSessionView`) draws the history from `GET /api/sessions/:id`; from then on each stream event changes the DOM in place (`stream_handlers.js`). The DOM *is* the log. The start page is the same app with `store.selected` null. |
|
|
29
|
+
| **Participant page**: a review or pair link | `participant.html` → `participant.js` | **Re-read at a turn boundary, then a keyed redraw.** It reads `GET …/review`, draws it, and streams; an event that ends or changes a turn (`reloads`) marks the data stale and the page reads it again. `drawnMessages` keeps a message's node while its signature is unchanged; a small `live` overlay (`participant_model.js applyLive`) draws the running turn on top. |
|
|
30
|
+
|
|
31
|
+
The participant page is **deliberately separate**: it shares leaf modules with the owner page (the annotate bar,
|
|
32
|
+
claim links, `composer_mount.js`, the verdict card and Verify fresh, `verify_target.js`, `verify_preset.js`,
|
|
33
|
+
`auth_lost.js`, `token_fragment.js`, `format.js`) but none of the data layer, the stream handling or the message
|
|
34
|
+
rendering. It has its own `api` (`{status, ok, body}`, never throws, its own ended / auth-lost handling) and its own
|
|
35
|
+
`EventSource`. Its parallel copies of the owner's Verify and claim helpers (`verifyFreshState`, `verifyAnchorFor`,
|
|
36
|
+
`showPendingClaim`, `CLAIM_ROOTS`) differ on purpose in places (the source kind `reply` vs `answer`, the server's
|
|
37
|
+
`fresh_running` vs `verify_fresh`, no home/cwd in its claim options). Don't converge them as a side effect of another
|
|
38
|
+
change.
|
|
39
|
+
|
|
40
|
+
## Where state lives (owner page)
|
|
41
|
+
|
|
42
|
+
| Holder | What | Who writes | How a change reaches the screen |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| **The session store** (`session_store.js`; app.js `store`) | `store.selected` (the open session's id), `store.current` and `store.sessions` (below) | through the store only (see The session store); other modules get getters (`selected: () => store.selected`, `state: () => store.current`) or the store itself (`stream_handlers.js`, `send_turn.js`, `session_actions.js`) | the store runs the redraws that read what changed (below); nobody calls them by hand |
|
|
45
|
+
| **app.js module scope**: about 20 top-level `let`s (`streamControl`, `eventsControl`, `pageCapabilities`/`pageEditorUrl`, `pendingVerifyAnchor`, `pendingClaim`, `resyncsInFlight`/`resyncAgain`, `resyncAttempts`, `scopeError`…) | the page's mode and its streams | app.js only; other modules get getters and hooks | the writer touches the redraw that reads it (`store.touch("infoBar")`: the stream, versions, `restartingIds`), or calls a redraw only it feeds |
|
|
46
|
+
| **`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`); `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`) |
|
|
47
|
+
| **`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` |
|
|
48
|
+
| **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 |
|
|
49
|
+
| **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`) | `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` 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) |
|
|
50
|
+
| **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
|
|
51
|
+
| **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
|
|
52
|
+
|
|
53
|
+
The start page's own choices (the model, the ⚙ panel, the 🧰 list) live in `start_page.js`'s closure, never in
|
|
54
|
+
`store.current`. A fresh chain link's picker reads both.
|
|
55
|
+
|
|
56
|
+
## The session store
|
|
57
|
+
|
|
58
|
+
`session_store.js` holds the open session's state; `session_fields.js` says what each field is. **Every write goes
|
|
59
|
+
through the store**, never `current.X = …`:
|
|
60
|
+
|
|
61
|
+
- `store.update({selected?, current?: {field: value}, sessions?})`: `current`'s fields are written into the same
|
|
62
|
+
object (it is replaced only by a session switch); `sessions` is always a new array (one changed in place isn't
|
|
63
|
+
seen).
|
|
64
|
+
- `store.setStatus(value, source, {streaming})`: the one way `status` changes (below).
|
|
65
|
+
- `store.replaceCurrent(state)`: `forgetSession` only; always a new object, so a stale async writer still holding the
|
|
66
|
+
old one writes there alone.
|
|
67
|
+
- `store.batch(fn)`, `store.touch(name)`, `store.on(name, fn)`: the redraws (below).
|
|
68
|
+
|
|
69
|
+
The ESLint block `samagotchi/session-writes` (eslint.config.js) enforces it in the six modules that hold the state
|
|
70
|
+
(app.js, `stream_handlers.js`, `send_turn.js`, `session_actions.js`, `start_page.js`, `working_set_view.js`): an
|
|
71
|
+
assignment, `++`, `delete` or `Object.assign` into `current`, `store.current` or `state()` is an error
|
|
72
|
+
(`spec/web/public/lint_session_writes.test.js` runs it on a sample). An alias (`const st = state(); st.x = …`) isn't
|
|
73
|
+
caught: don't write through one.
|
|
74
|
+
|
|
75
|
+
**Read `store.current` fresh after every await.** A `const cur = store.current` held across an await pins the object
|
|
76
|
+
from before it, which a session switch (`forgetSession`) may have replaced: app.js's `readTurnEnd`,
|
|
77
|
+
`mergeTurnTiming`, `resync` and `refreshTimingFromSession` read it again after each await. Keep `store.sessions`
|
|
78
|
+
reads fresh too.
|
|
79
|
+
|
|
80
|
+
### Fields and their redraws
|
|
81
|
+
|
|
82
|
+
`session_fields.js FIELDS` has one entry per `emptySessionState` key: its payload source (a partial re-read takes a
|
|
83
|
+
field only when the payload carries it: `session_payload.js` reads its sources from here), its hub card source
|
|
84
|
+
(`fieldsFromCard`: owner, workerVersion, workerFeatures, bridgeUp; never status), its redraws (store `ORDER` names)
|
|
85
|
+
and its writers. A new field starts in `emptySessionState` and gets an entry (its spec checks both).
|
|
86
|
+
|
|
87
|
+
A changed value (`!==`) marks its field's redraws; `sessions` marks `lists` and `infoBar` (the bar reads the list:
|
|
88
|
+
the chain's ← → links, the children chip, the archived button, the card's title, the fresh link's picker); `selected`
|
|
89
|
+
marks `paintActive`, `composer` and `infoBar`; `touch(name)` marks one redraw for an input the store doesn't hold
|
|
90
|
+
(the clock, versions, the stream, `liveTurn.running`). They run synchronously, once each, at the end of the outermost
|
|
91
|
+
`batch()` (at once for a bare write), in one `ORDER`: `lists`, `paintActive`, the chip popovers (`llmContextChip`,
|
|
92
|
+
`thinkingChip`, `reasoningChip`, `workingSetStrategy`), `featureGates`, `composer`, `infoBar`. `lists` comes before
|
|
93
|
+
the bar because `updateSessionTitle` reads the strip's active card; `composer` before the bar as `setTurnRunning`
|
|
94
|
+
runs them. Not a rAF (a background tab runs none, and the title badge tests run there; the e2e per-frame checks would
|
|
95
|
+
see the composer move a frame late) and not a microtask (code that reads the page right after a write would see it
|
|
96
|
+
stale).
|
|
97
|
+
|
|
98
|
+
**The redraws are registered, not called.** app.js registers each name once (`store.on`, at the bottom, once every
|
|
99
|
+
piece a redraw calls exists): `lists` → `lists.render()`, `paintActive` → `lists.paintActive()`, the chips →
|
|
100
|
+
`xControl.refresh()`, `workingSetStrategy` → `workingSet.strategyChanged()`, `featureGates` (below), `composer` →
|
|
101
|
+
`updateComposerMode()`, `infoBar` → `updateInfoBar()`. A writer writes and stops there: no `updateInfoBar()` after
|
|
102
|
+
a store write. A value written unchanged redraws nothing (a chunk with the same ctx %).
|
|
103
|
+
|
|
104
|
+
An input the store doesn't hold calls `store.touch(name)` where it changes: `infoBar` for the clock (liveTurn's
|
|
105
|
+
`onTick`), the versions (`noticeVersions`), `restartingIds`, the stream (`startStream`, `workerGone`), the working
|
|
106
|
+
set's and the participants' `onChange`, the copy-attach timer, the continue and review in-flight sets
|
|
107
|
+
(`session_actions.js`); `composer` and `infoBar` for `liveTurn.running` (`setTurnRunning`) and at the end of
|
|
108
|
+
`renderSessionView` (a whole view drawn). Left as they are, on purpose: the `narrowScreen` listener calls
|
|
109
|
+
`updateComposerMode()` (the bar doesn't read it), `applyStripHidden`/`applyView` call `updateSessionTitle()`, and
|
|
110
|
+
`closeStream`/`stream.restart` set `streamControl = null` without a redraw (a resync or a workerGone follows).
|
|
111
|
+
|
|
112
|
+
A function that writes more than once batches its synchronous part, so each redraw runs once: `select` (before its
|
|
113
|
+
`await resync()`), `clearSelection`, the hub's `snapshot` and `session` events, `workerGone`, `setTurnRunning`,
|
|
114
|
+
`turn_started` (inside `keepFollowing`, so the composer and the bar redraw before its scroll), `readTurnEnd` (after
|
|
115
|
+
its timing merge), `setUsedMemories`, and `renderSessionView`'s field write only: the replay's own
|
|
116
|
+
`setTurnRunning` redraws where they run, before `turnDom.flush()`. A stream handler writes one patch.
|
|
117
|
+
|
|
118
|
+
`batch(fn)`:
|
|
119
|
+
- `fn` is synchronous: one that returns a promise is refused (`console.error`, the redraws run at once). Batch an
|
|
120
|
+
async function's synchronous parts only, never across an await.
|
|
121
|
+
- The redraws run in `finally`, also when `fn` throws (the page shows what was written), then `fn`'s error is
|
|
122
|
+
rethrown; a redraw error meanwhile goes to `console.error`. A throw never leaves the store inside a batch.
|
|
123
|
+
- During a flush, a write, a touch or a batch's end only enqueues: the next pass runs it, after the current pass's
|
|
124
|
+
`ORDER`. Passes stop at 3 (`console.error` names the redraw). A redraw that throws doesn't stop the others; its
|
|
125
|
+
error is thrown after the flush.
|
|
126
|
+
|
|
127
|
+
### `status`
|
|
128
|
+
|
|
129
|
+
Six writers, each with its rule, all through `setStatus(value, source)` (`session_fields.js statusWrite`):
|
|
130
|
+
|
|
131
|
+
| source | writer | applies | the open session's card |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| `payload` | `renderSessionView` | always | left alone |
|
|
134
|
+
| `hub` | `followSelectedSession` | only while the stream isn't live | left alone (the card came from the hub) |
|
|
135
|
+
| `stream` | `setLiveStatus` (`turn_*`, `workerGone`'s idle) | always | `withLiveStatus`: status, owner, `updated_at` |
|
|
136
|
+
| `turnEnd` | `readTurnEnd`, the turn_canceled re-read (`refreshTimingFromSession`) | when the payload carries one (the caller skips it otherwise), and **not while a turn runs**: a re-read that lands after the next queued turn's `turn_started` doesn't write its stored "idle" over "running" (the test `mergeTurnTiming` keeps `activeTurn` by) | left alone: `readTurnEnd` writes its card itself after its timing merge, with `used_memory_names` |
|
|
137
|
+
| `stop` | `session_actions.js stoppedWorker` | always | left alone (`workerGone`'s `withoutWorker` changes it) |
|
|
138
|
+
|
|
139
|
+
`setStatus` answers whether it replaced the card; the new list redraws the lists and the bar by itself.
|
|
140
|
+
|
|
141
|
+
### Feature gates
|
|
142
|
+
|
|
143
|
+
A page action the open session's worker must support (its sidecar features and a live bridge) is a gate in
|
|
144
|
+
`feature_gates.js`, registered as the store's `featureGates` redraw: `{name, unavailable(current), redraw}`. It runs
|
|
145
|
+
when a field it reads changes (`workerFeatures`, `bridgeUp`), and a gate's `redraw` only when its answer flipped: a
|
|
146
|
+
restart's hub event that brings the feature enables it, the worker going (`workerGone`'s `bridgeUp = false`) says
|
|
147
|
+
it's unavailable. `forgetSession` and `renderSessionView` draw the history with the answer already, so they `prime()`
|
|
148
|
+
the gates in the batch of their write, and nothing fires. Today two gates: `verify` (Verify fresh on a verdict's
|
|
149
|
+
card, `redrawFresh`) and `revise` (the answers' Revise buttons and the composer's Revising chip, `redrawRevise`).
|
|
150
|
+
Both refuse only a live worker without the feature: a worker that isn't up is woken by the send as a new one, which
|
|
151
|
+
has it. A new action that needs a worker feature is one more entry.
|
|
152
|
+
|
|
153
|
+
### Exceptions
|
|
154
|
+
|
|
155
|
+
- **Writers in place (D8)**, named in `FIELDS`: live_timing.js sets `timing().activeTurn` (the running turn's own
|
|
156
|
+
clock; `mergeTurnTiming` keeps it), and verdict_fresh.js `startFresh` pushes onto `fresh.running`, which is
|
|
157
|
+
`store.current.verifyFresh`.
|
|
158
|
+
- **Events, not state (D9)**: these stay direct calls next to their write: a `/model`'s picker sync
|
|
159
|
+
(`syncModelPick`), the working set's `commandRan`, the context chips on a note, the participants reload, a
|
|
160
|
+
verdict's own `renderFresh`. (The working set's `strategyChanged` and the Verify gate's `redrawFresh` follow
|
|
161
|
+
fields: they are registered redraws.)
|
|
162
|
+
|
|
163
|
+
A partial re-read of the open session (a `?tail`/`?recent` read) goes through **`applySessionPayload(data,
|
|
164
|
+
{fields})`** in app.js (`session_payload.js sessionFieldsFrom`): the named fields read as a full load reads them, a
|
|
165
|
+
field whose source the payload doesn't carry left alone; its status goes through `setStatus(…, "turnEnd")`. Their
|
|
166
|
+
redraws follow. Never copy payload keys into `store.current` by hand.
|
|
167
|
+
|
|
168
|
+
The redraws, by their store names:
|
|
169
|
+
- `infoBar` (`updateInfoBar()`): the session bar's buttons and info line (`info_bar_model.js sessionBar` →
|
|
170
|
+
`info_bar.js`), the annotate bar, the fold controls, `updateSessionTitle()` and the fresh link's picker. Cheap: it
|
|
171
|
+
runs on most writes and every clock tick.
|
|
172
|
+
- `composer` (`updateComposerMode()`): the composer's placeholder, buttons and delivery line (selected, running,
|
|
173
|
+
owner).
|
|
174
|
+
- `llmContextChip`, `thinkingChip`, `reasoningChip` (`xControl.refresh()`): an info-bar chip's **open popover** (the
|
|
175
|
+
chip itself is drawn by the bar); `workingSetStrategy`: the working set's open list.
|
|
176
|
+
- `lists` / `paintActive` (`lists.render()` / `lists.paintActive()`): the strip and the all view from
|
|
177
|
+
`store.sessions`.
|
|
178
|
+
- `featureGates`: the gates above.
|
|
179
|
+
|
|
180
|
+
`forgetSession()` is the one reset: `select` and `clearSelection` both call it, so anything per-session that isn't
|
|
181
|
+
in `store.current` (capabilities, the markdown warning and tip, the init row, the Verify claim, the armed Revise,
|
|
182
|
+
the running turn's DOM) is cleared there, and the feature gates are primed for the empty session. A new per-session holder
|
|
183
|
+
(an armed composer anchor like the Verify claim or the Revise) goes into it. The armed Revise
|
|
184
|
+
(`pendingReviseAnchor`) is set only through `setReviseAnchor`, which redraws its chip and the Revise buttons (a
|
|
185
|
+
redraw only it feeds, not a store field).
|
|
186
|
+
|
|
187
|
+
## Render idioms
|
|
188
|
+
|
|
189
|
+
Three, for one job. Pick by what the piece is:
|
|
190
|
+
|
|
191
|
+
| Idiom | Where | Use it for |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| **lit `render(template, keptContainer)`** | most components (~26): the info bar, chips and their popovers, panels, the init row, the working set, `start_page.js` | anything redrawn while the user may be in it: lit keeps the nodes, so focus, typed text, an open `<details>` and a tooltip survive. **The default for new UI.** |
|
|
194
|
+
| **lit as a string builder**: `turn_html.js elementOf(template)`, `lists_view.js` (a fresh fragment, then `replaceChildren`) | history bubbles appended once; the session lists | a node built once and then owned by the DOM (history), or a list redrawn whole. `lists_view.js` throws away lit's diffing and binds card listeners per draw, with an `entering` tracker for its animations |
|
|
195
|
+
| **hand-built DOM** (`createElement`) | `question_cards.js`, `verdict_card.js`, `verdict_fresh.js`, `participant.js` (`el()`) and a few small ones | older pieces; don't add new ones this way |
|
|
196
|
+
|
|
197
|
+
Listeners: every factory is a page-lifetime singleton, so `addEventListener` without a remove is fine; a per-render
|
|
198
|
+
listener lands on a fresh node. A popover goes through `chip_popover.js createChipPopover` (its own outside-click,
|
|
199
|
+
Esc, placement and resize); `context_chips.js`, `model_picker.js`, `ref_menu.js`, `command_list.js` and the lists'
|
|
200
|
+
families/chains popovers still hand-roll theirs.
|
|
201
|
+
|
|
202
|
+
## Stale answers
|
|
203
|
+
|
|
204
|
+
The page switches sessions, reopens a popover or asks again while a fetch is out. An answer for the old ask must be
|
|
205
|
+
dropped. **`latest.js`** is the guard:
|
|
206
|
+
|
|
207
|
+
- `stillOn(get, at = get())`: `() => true` while `get()` is still `at`. For a one-off read about the open session
|
|
208
|
+
(`const still = stillOn(openId); … await …; if (!still()) return;`). app.js has `openId = () => store.selected`.
|
|
209
|
+
- `createLatest(get)`: `begin()` gives a guard that also goes stale when a newer ask begins; `drop()` makes every
|
|
210
|
+
ask out stale (a close, a reset). For a list that is re-read.
|
|
211
|
+
|
|
212
|
+
On it: `context_chips.js`, `project_picker.js`, `participants_panel.js` (`load`), `cards_place.js`
|
|
213
|
+
(`refreshCardBodies`), `stream_handlers.js` (`answer_display`), `working_set_view.js` (`fetchServer`) and app.js's
|
|
214
|
+
turn-end re-reads (`readTurnEnd`, `refreshTimingFromSession`, `mergeTurnTiming`) and init-row timers. Still by hand
|
|
215
|
+
(the id decides more than "drop it"): app.js `resync`/`retryResync` (the re-read budget, `resyncAgain`), the stream's
|
|
216
|
+
close, `session_actions.js` (continue/review open another session on purpose), `send_turn.js` (a failure for another
|
|
217
|
+
session becomes a toast), and `participant.js` (its own `loadSeq`, the separate page). A new await in a component
|
|
218
|
+
uses `latest.js`.
|
|
219
|
+
|
|
220
|
+
## API clients
|
|
221
|
+
|
|
222
|
+
- `data.js api(path, opts)`: the owner page's. Throws an `Error` with the server's words, `.status`, `.code` (the
|
|
223
|
+
server's error code: match on it, never on the words) and `.body`; a signed-out proxy answer (401, a redirect,
|
|
224
|
+
HTML) or a fetch that fails on a sign-in redirect reports to `auth_lost.js` (`onAuthLost`, set by app.js).
|
|
225
|
+
`cantText(what, error)` is the toast.
|
|
226
|
+
- `method_api.js methodApi(method, path, body)`: the same, as `notifications.js` and `push_devices.js` call it.
|
|
227
|
+
- `participant.js api`: its own (see above).
|
|
228
|
+
|
|
229
|
+
## Streams
|
|
230
|
+
|
|
231
|
+
| Stream | Opened by | Lifecycle |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| The hub, `GET /api/events` (`data.js openEvents`) | app.js `openSessionEvents` (scoped by `?dir`) | `snapshot` then `session` / `session_gone` → `applySessionEvent` → `allSessions`; EventSource reconnects itself (a fresh snapshot); `onClosed` (a proxy sign-in on reconnect): probe, reopen after `EVENTS_REOPEN_MS` |
|
|
234
|
+
| A second hub stream, unscoped | `notifications.js` (`allEvents`) on a project's page | the Home Screen app's device tidy-up across projects |
|
|
235
|
+
| The open session, `GET /api/sessions/:id/stream?from_seq=` (`data.js openStream`) | app.js `startStream` from `store.current.streamSeq` | events → `stream_handlers.js`. It closes itself on an error; the page resyncs (`resync`: a fresh render and a new stream from its cursor), once at once when it had opened, else after `STREAM_RETRY_MS` |
|
|
236
|
+
| The participant stream | `participant.js connect` | listens for `STREAM_EVENTS`, which is `Web::ParticipantView::EVENTS` (checked by `spec/web/participant_view_spec.rb`); reopened from fresh data when CLOSED |
|
|
237
|
+
|
|
238
|
+
A new event type for the owner page is a handler in `stream_handlers.js` (the participant view spec makes it decide
|
|
239
|
+
`EVENTS` or `DROPPED`); for the participant page also an entry in `STREAM_EVENTS`.
|
|
240
|
+
|
|
241
|
+
## Storage keys
|
|
242
|
+
|
|
243
|
+
Through `storage.js` (`readStored`/`writeStored`: storage can throw, a missing value is the default), except
|
|
244
|
+
`notifications.js`, which takes an injected storage for its specs. Per-browser choices only: never anything that must
|
|
245
|
+
reach another device or the server.
|
|
246
|
+
|
|
247
|
+
| Key | Module | What |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `chi_model`, `chi_model_recent` | `model_pick.js`, `model_picker.js`, `start_page.js` | the start page's model and its recent list |
|
|
250
|
+
| `chi_composer_collapsed` | `composer_fold.js` | the composer folded |
|
|
251
|
+
| `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
|
|
252
|
+
| `chi_strip_hidden` (`chi_zen`: the old name, read once) | app.js | the session strip hidden |
|
|
253
|
+
| `chi_stage_collapsed` | `stage_view.js` | the stage collapsed |
|
|
254
|
+
| `chi_working_set_collapsed` | `working_set_rail.js` | the working set rail collapsed |
|
|
255
|
+
| `chi_markdown_tip_dismissed` | `markdown_tip.js` | the markdown tip dismissed |
|
|
256
|
+
| `chi_update_available_shown` | app.js | update notices already shown |
|
|
257
|
+
| `chi_notify`, `chi_push_device` | `notifications.js` (an injected storage) | the bell's state, this device's push id |
|
|
258
|
+
| `chi_answer_fold:<session>` | `answer_fold.js`, `revise_stack.js` | the answers folded by hand, and the Revise stubs unfolded by hand (`stub:<turn id>`) (swept with the session list) |
|
|
259
|
+
| `chi_review_history:<grant>`, `chi_review_history_session:<session>` | `review_history.js` (participant page) | a participant's composer history |
|
|
260
|
+
|
|
261
|
+
sessionStorage: `chi_client_id` (`turn_events.js`: this tab's client id, kept over a reload mid-turn) and
|
|
262
|
+
`chi.authReloadAt` (`auth_lost.js`: one automatic reload a minute).
|
data/docs/sessions.md
CHANGED
|
@@ -6,6 +6,8 @@ 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`: 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?`), 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
|
+
|
|
9
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` (`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`.
|
|
10
12
|
|
|
11
13
|
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
|
data/docs/testing.md
CHANGED
|
@@ -24,7 +24,9 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
|
|
|
24
24
|
global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
|
|
25
25
|
It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
|
|
26
26
|
Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
|
|
27
|
-
is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
|
|
27
|
+
is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
|
|
28
|
+
modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
|
|
29
|
+
in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
|
|
28
30
|
|
|
29
31
|
CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
|
|
30
32
|
(`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
|
|
@@ -41,7 +43,7 @@ paths (replace them with placeholders), no PATH tools (the suite pins `rg` as fo
|
|
|
41
43
|
that sets PATH to test the lookup is tagged `:real_rg`), no Ruby-version-dependent `inspect` (a Hash's or an
|
|
42
44
|
exception's text changes between Ruby versions: write the string yourself).
|
|
43
45
|
|
|
44
|
-
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`
|
|
46
|
+
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
|
|
45
47
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
46
48
|
`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`,
|
|
47
49
|
`chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
|
|
@@ -69,6 +71,28 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
|
|
|
69
71
|
`repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
|
|
70
72
|
`CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
|
|
71
73
|
|
|
74
|
+
## Levels
|
|
75
|
+
|
|
76
|
+
Each level owns its own share; a detail is asserted once, at the lowest level that has it.
|
|
77
|
+
|
|
78
|
+
| Level | Ruby | Web | Owns |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| Unit | a class, its collaborators stubbed or passed in | `npm test` (node; happy-dom for lit) | the details and the variants: a message's text, each refusal, the edge cases |
|
|
81
|
+
| Functional | a component through its real seams in one process (an Engine on `test_kernel`, a real Bridge, `Web::App` on Rack) | chi web's routes (`spec/web/`, Rack) | the component's happy path and its main negative paths, and the payload it hands the next layer |
|
|
82
|
+
| Process / e2e | a real `bin/chi`, worker or script (`run_chi`, a spawned process) | Playwright (`npm run e2e`) | one run per mechanism (the wiring a lower level can't see: a require, a spawn, a real browser), and a few whole journeys |
|
|
83
|
+
|
|
84
|
+
- **The variants are the units'.** A higher-level spec says which mechanism it is the one test of and where its
|
|
85
|
+
variants live (`remote_attach_stream_spec.rb`, `steer_delivery_e2e_spec.rb`, `terminal_ui/attached_loop_bridge_spec.rb`).
|
|
86
|
+
- **One real chi per mechanism** (spec/support/chi_cli.rb): a file keeps one `run_chi` per mechanism and forks the
|
|
87
|
+
rest (`fork_chi`). A script that runs chi (a bundle's `scripts/pair`) likewise: its mechanism on real chi, its
|
|
88
|
+
variants on a fake one (`FakeChiContext`).
|
|
89
|
+
- **A spec that spawns per assertion gets a seam.** A unit spec that starts a process for every check (a `git`, a
|
|
90
|
+
Ruby boot) passes the collaborator in or fakes it: a guardrails `Context` takes `git:`, so give it one `GitInfo`
|
|
91
|
+
per example, as the wiring keeps one per turn.
|
|
92
|
+
- **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
|
|
93
|
+
a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
|
|
94
|
+
variant to a shared fixture (`spec/fixtures/composer_routes.json`).
|
|
95
|
+
|
|
72
96
|
## Waiting and time
|
|
73
97
|
|
|
74
98
|
A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
|
|
@@ -184,6 +208,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
|
|
|
184
208
|
| `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
|
|
185
209
|
| `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
|
|
186
210
|
| `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
|
|
211
|
+
| `fake_chi_context.rb` | `FakeChiContext`: a fake `chi context add/show/push/rm/ls` for the peer bundles' `scripts/pair` variants, logging each call |
|
|
187
212
|
| `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
|
|
188
213
|
| `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
|
|
189
214
|
| `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative "atomic_file"
|
|
|
11
11
|
require_relative "bridge_secret"
|
|
12
12
|
require_relative "delivery"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
|
-
require_relative "
|
|
14
|
+
require_relative "turn_anchors"
|
|
15
15
|
|
|
16
16
|
require_relative "bridge/bounded_queue"
|
|
17
17
|
require_relative "bridge/card_store"
|
|
@@ -634,7 +634,7 @@ module Samagotchi
|
|
|
634
634
|
# turn (Guardrails::PairBoundary); a worker without it runs it as a
|
|
635
635
|
# review turn, and chi web says so (Delivery::PAIR_FEATURE).
|
|
636
636
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
637
|
-
VerifyAnchor::FEATURE,
|
|
637
|
+
VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
|
|
638
638
|
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
|
|
639
639
|
|
|
640
640
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
@@ -1073,7 +1073,10 @@ module Samagotchi
|
|
|
1073
1073
|
# request's principal; the Bridge is loopback and trusts its caller).
|
|
1074
1074
|
author = fetched(parsed, "author")
|
|
1075
1075
|
author = nil unless author.is_a?(String) && !author.empty?
|
|
1076
|
-
|
|
1076
|
+
# A Verify turn's claim, a Revise turn's draft; a participant can't
|
|
1077
|
+
# Revise (chi web drops it first, this is the worker's own layer).
|
|
1078
|
+
anchors = TurnAnchors.from(parsed)
|
|
1079
|
+
anchors = anchors.without_revises if Steer.participant_author?(author)
|
|
1077
1080
|
if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
|
|
1078
1081
|
return [{ "Allow" => "POST" }, 400,
|
|
1079
1082
|
{ error: "missing_fields", detail: "session_id and prompt are required" }]
|
|
@@ -1135,7 +1138,7 @@ module Samagotchi
|
|
|
1135
1138
|
delivery = Delivery::QUEUE if @accumulator.participant_turn?
|
|
1136
1139
|
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
1137
1140
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1138
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1141
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors).tap do |ok|
|
|
1139
1142
|
next unless ok
|
|
1140
1143
|
|
|
1141
1144
|
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
@@ -1299,9 +1302,11 @@ module Samagotchi
|
|
|
1299
1302
|
|
|
1300
1303
|
# Write a turn into this session's input dir, reusing the file IPC the
|
|
1301
1304
|
# worker polls. Never calls run_turn across the boundary.
|
|
1302
|
-
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil,
|
|
1305
|
+
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil,
|
|
1306
|
+
anchors: nil)
|
|
1303
1307
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1304
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1308
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1309
|
+
anchors: anchors)
|
|
1305
1310
|
end
|
|
1306
1311
|
|
|
1307
1312
|
# ── HTTP plumbing ────────────────────────────────────────────────────────
|
|
@@ -14,8 +14,10 @@ require_relative "bridge_client/event_stream"
|
|
|
14
14
|
require_relative "bridge_client/stream_request"
|
|
15
15
|
|
|
16
16
|
module Samagotchi
|
|
17
|
-
# Loaded on first use (a Verify
|
|
18
|
-
# budget
|
|
17
|
+
# Loaded on first use (any turn's input, a Verify or a Revise turn's
|
|
18
|
+
# anchor): keeps the bundle test kit's load budget
|
|
19
|
+
# (spec/samagotchi/testing/sandbox_spec.rb).
|
|
20
|
+
autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
|
|
19
21
|
autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
|
|
20
22
|
|
|
21
23
|
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
@@ -192,16 +194,17 @@ module Samagotchi
|
|
|
192
194
|
# owner; sent only when given (SessionManager.deliver_turn)
|
|
193
195
|
# @param pending_limit [Integer, nil] with a participant's +author+: how
|
|
194
196
|
# many of their turns may be running or waiting (429 too_many_turns past it)
|
|
197
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
198
|
+
# turn's draft: their own body keys ("verify", "revises")
|
|
195
199
|
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
|
|
196
|
-
|
|
200
|
+
anchors: nil)
|
|
197
201
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
198
202
|
body[:no_interrupt] = true if no_interrupt
|
|
199
203
|
body[:author] = author if author
|
|
200
204
|
body[:pending_limit] = pending_limit if author && pending_limit
|
|
201
205
|
body[:images] = images if images && !images.empty?
|
|
202
206
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
203
|
-
|
|
204
|
-
body[:verify] = anchor.to_file if anchor
|
|
207
|
+
body.merge!(TurnAnchors.from(anchors).to_file) if anchors
|
|
205
208
|
body[:deadline] = deadline
|
|
206
209
|
post("turn", body, read_body: true)
|
|
207
210
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -330,8 +330,6 @@ module Samagotchi
|
|
|
330
330
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli], lifecycle: :web_start),
|
|
331
331
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli], lifecycle: :web_start),
|
|
332
332
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
|
|
333
|
-
# The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
|
|
334
|
-
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
335
333
|
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
336
334
|
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
337
335
|
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "steer"
|
|
|
15
15
|
require_relative "turn_note"
|
|
16
16
|
require_relative "turn_endings"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "revise_anchor"
|
|
18
19
|
require_relative "verify_anchor"
|
|
19
20
|
require_relative "web/participant_view"
|
|
20
21
|
require_relative "verdict"
|
|
@@ -1631,10 +1632,11 @@ module Samagotchi
|
|
|
1631
1632
|
seconds = turn.elapsed
|
|
1632
1633
|
synchronize_events do
|
|
1633
1634
|
kept, event = yield(seconds)
|
|
1634
|
-
|
|
1635
|
+
revises = stamp_opening(kept, turn, outcome)
|
|
1635
1636
|
replace_session_messages(turn.session, kept) if kept
|
|
1636
1637
|
verdict = record_verdict(turn, outcome)
|
|
1637
1638
|
event[:verdict] = verdict if verdict
|
|
1639
|
+
event[:revises] = revises.to_h if revises
|
|
1638
1640
|
emit_working_set(turn.on_event, turn.session, turn.session.messages)
|
|
1639
1641
|
turn.session.status = Session::STATUS_IDLE
|
|
1640
1642
|
record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
|
|
@@ -1647,16 +1649,63 @@ module Samagotchi
|
|
|
1647
1649
|
end
|
|
1648
1650
|
private :end_turn
|
|
1649
1651
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
+
# The turn's anchors, stamped on its opening message (the prompt, or a
|
|
1653
|
+
# wake turn's note) in the messages it keeps: display metadata, never
|
|
1654
|
+
# in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
|
|
1655
|
+
# A Verify turn's claim however it ended; a Revise turn's draft only
|
|
1656
|
+
# once it completed with an answer (#revision_target). Returns the
|
|
1657
|
+
# stamped ReviseAnchor (the end event's revises), or nil.
|
|
1658
|
+
def stamp_opening(messages, turn, outcome)
|
|
1659
|
+
return unless messages.is_a?(Array)
|
|
1660
|
+
|
|
1661
|
+
revises = revision_target(messages, turn, outcome)
|
|
1662
|
+
fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
|
|
1663
|
+
return if fields.empty?
|
|
1652
1664
|
|
|
1653
1665
|
opening = messages.reverse.find do |message|
|
|
1654
|
-
(message[:
|
|
1655
|
-
(message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1666
|
+
Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1656
1667
|
end
|
|
1657
|
-
|
|
1668
|
+
return unless opening
|
|
1669
|
+
|
|
1670
|
+
opening.merge!(fields)
|
|
1671
|
+
revises
|
|
1672
|
+
end
|
|
1673
|
+
private :stamp_opening
|
|
1674
|
+
|
|
1675
|
+
# The turn a Revise turn revised (D6: "this turn revised that one"):
|
|
1676
|
+
# only a completed turn with an answer (AnswerTail) has revised
|
|
1677
|
+
# anything. A draft no prompt in the messages carries is no target (a
|
|
1678
|
+
# stale page, a rolled-back turn); one a stamped turn already revised
|
|
1679
|
+
# is re-targeted to its stack's top, the newest version, which the
|
|
1680
|
+
# person meant ("this draft").
|
|
1681
|
+
def revision_target(messages, turn, outcome)
|
|
1682
|
+
return unless turn.revises && outcome == "completed"
|
|
1683
|
+
|
|
1684
|
+
slice = AnswerTail.turn_slice(messages, turn.id)
|
|
1685
|
+
return unless slice&.any? { |message| AnswerTail.answer?(message) }
|
|
1686
|
+
|
|
1687
|
+
target = turn.revises.turn_id
|
|
1688
|
+
seen = [turn.id]
|
|
1689
|
+
until seen.include?(target)
|
|
1690
|
+
return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
|
|
1691
|
+
|
|
1692
|
+
seen << target
|
|
1693
|
+
newer = messages.reverse.find do |message|
|
|
1694
|
+
Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
|
|
1695
|
+
end
|
|
1696
|
+
return ReviseAnchor.new(turn_id: target) unless newer
|
|
1697
|
+
|
|
1698
|
+
target = (newer[:turn_id] || newer["turn_id"]).to_s
|
|
1699
|
+
end
|
|
1700
|
+
reject_revise_anchor(turn.revises, :cycle)
|
|
1701
|
+
end
|
|
1702
|
+
private :revision_target
|
|
1703
|
+
|
|
1704
|
+
def reject_revise_anchor(anchor, reason)
|
|
1705
|
+
Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1706
|
+
nil
|
|
1658
1707
|
end
|
|
1659
|
-
private :
|
|
1708
|
+
private :reject_revise_anchor
|
|
1660
1709
|
|
|
1661
1710
|
def verified_anchor(session, anchor)
|
|
1662
1711
|
return unless anchor
|
|
@@ -2150,8 +2199,9 @@ module Samagotchi
|
|
|
2150
2199
|
# Continue that starts cold (after a model change).
|
|
2151
2200
|
# +wall+: the wall time it started (MessageTime.now, once): the
|
|
2152
2201
|
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2202
|
+
# +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
|
|
2153
2203
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2154
|
-
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
|
|
2204
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
|
|
2155
2205
|
def resumed? = resumed ? true : false
|
|
2156
2206
|
|
|
2157
2207
|
# The turn's boundary events carry the origin only when it names a
|
|
@@ -2215,6 +2265,9 @@ module Samagotchi
|
|
|
2215
2265
|
# @param id [String, nil] the turn's id (a new uuid when nil): a caller
|
|
2216
2266
|
# that put the turn's first message in the session already (a context
|
|
2217
2267
|
# wake's note) names it with this id
|
|
2268
|
+
# @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
|
|
2269
|
+
# @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
|
|
2270
|
+
# the prompt at the turn's end, metadata only (#stamp_opening)
|
|
2218
2271
|
#
|
|
2219
2272
|
# An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
|
|
2220
2273
|
# session as after a Stop (the prompt, the loop's finished tool
|
|
@@ -2226,9 +2279,11 @@ module Samagotchi
|
|
|
2226
2279
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2227
2280
|
# one-line summary:.
|
|
2228
2281
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2229
|
-
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil,
|
|
2282
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, revises: nil,
|
|
2283
|
+
input_waiting: nil)
|
|
2230
2284
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2231
|
-
continue: continue, id: id, verify: verify,
|
|
2285
|
+
continue: continue, id: id, verify: verify, revises: revises,
|
|
2286
|
+
input_waiting: input_waiting)
|
|
2232
2287
|
if continue
|
|
2233
2288
|
turn.resumed = resumed
|
|
2234
2289
|
turn.resume_from = resume_from
|
|
@@ -2276,7 +2331,8 @@ module Samagotchi
|
|
|
2276
2331
|
# The turn's state before anything can fail it: the session running,
|
|
2277
2332
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2278
2333
|
# is set here and cleared in #release_turn only.
|
|
2279
|
-
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil,
|
|
2334
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
|
|
2335
|
+
input_waiting: nil)
|
|
2280
2336
|
origin = TurnOrigin.of(origin)
|
|
2281
2337
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2282
2338
|
# author, boundary and cap), whoever started it.
|
|
@@ -2304,7 +2360,8 @@ module Samagotchi
|
|
|
2304
2360
|
|
|
2305
2361
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
2306
2362
|
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
2307
|
-
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify)
|
|
2363
|
+
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
|
|
2364
|
+
revises: ReviseAnchor.from(revises))
|
|
2308
2365
|
end
|
|
2309
2366
|
private :begin_turn
|
|
2310
2367
|
|