samagotchi 0.170.0 → 0.171.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 +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +37 -23
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +2 -2
- data/docs/internals/bridge.md +5 -4
- data/docs/internals/context-telemetry.md +13 -1
- data/docs/internals/engine.md +4 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +1 -1
- data/docs/internals/participants.md +235 -119
- data/docs/internals/web-frontend.md +164 -33
- data/docs/internals/working-set.md +69 -5
- data/docs/releasing.md +2 -1
- data/docs/sessions.md +6 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
- data/lib/samagotchi/bridge.rb +39 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +13 -0
- data/lib/samagotchi/engine.rb +5 -1
- data/lib/samagotchi/event_fanout.rb +22 -1
- data/lib/samagotchi/grants.rb +5 -0
- data/lib/samagotchi/guardrails/review_paths.rb +2 -2
- data/lib/samagotchi/image_store.rb +51 -12
- data/lib/samagotchi/kernel_loop.rb +7 -1
- data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
- data/lib/samagotchi/llm_context_stale.rb +4 -1
- data/lib/samagotchi/participant_messages.rb +39 -0
- data/lib/samagotchi/participant_revise.rb +61 -0
- data/lib/samagotchi/participant_uploads.rb +98 -0
- data/lib/samagotchi/reply_wait/poll.rb +5 -1
- data/lib/samagotchi/revise_anchor.rb +9 -0
- data/lib/samagotchi/send_command.rb +5 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager/delivery.rb +7 -0
- data/lib/samagotchi/session_stores.rb +2 -0
- data/lib/samagotchi/tool_runner.rb +39 -5
- data/lib/samagotchi/tools/memory.rb +26 -7
- data/lib/samagotchi/turn_anchors.rb +0 -3
- data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
- data/lib/samagotchi/verify_anchor.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -77
- data/lib/samagotchi/web/participant_access.rb +129 -26
- data/lib/samagotchi/web/participant_images.rb +185 -0
- data/lib/samagotchi/web/participant_review.rb +19 -138
- data/lib/samagotchi/web/participant_show.rb +240 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
- data/lib/samagotchi/web/participant_view.rb +331 -24
- data/lib/samagotchi/web/public/annotate_bar.css +1 -2
- data/lib/samagotchi/web/public/annotate_bar.js +1 -2
- data/lib/samagotchi/web/public/app.js +124 -88
- data/lib/samagotchi/web/public/block_host.js +7 -4
- data/lib/samagotchi/web/public/bubbles.js +9 -1
- data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
- data/lib/samagotchi/web/public/chip_popover.js +5 -4
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer.css +4 -6
- data/lib/samagotchi/web/public/composer_mode.js +11 -6
- data/lib/samagotchi/web/public/composer_mount.js +4 -4
- data/lib/samagotchi/web/public/ctx.js +12 -3
- data/lib/samagotchi/web/public/delivery_line.js +34 -5
- data/lib/samagotchi/web/public/history_shape.js +5 -1
- data/lib/samagotchi/web/public/images.js +7 -1
- data/lib/samagotchi/web/public/index.html +40 -4
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +5 -4
- data/lib/samagotchi/web/public/owner_claim.js +3 -3
- data/lib/samagotchi/web/public/owner_verify.js +0 -2
- data/lib/samagotchi/web/public/participant_bar.js +108 -0
- data/lib/samagotchi/web/public/participant_main.js +289 -0
- data/lib/samagotchi/web/public/participant_model.js +5 -292
- data/lib/samagotchi/web/public/participant_names.js +6 -1
- data/lib/samagotchi/web/public/participant_revise.js +66 -0
- data/lib/samagotchi/web/public/participants_panel.js +3 -12
- data/lib/samagotchi/web/public/question_cards.js +14 -3
- data/lib/samagotchi/web/public/refs.js +53 -36
- data/lib/samagotchi/web/public/reload_check.js +68 -11
- data/lib/samagotchi/web/public/revise_stack.js +21 -10
- data/lib/samagotchi/web/public/send_turn.js +24 -6
- data/lib/samagotchi/web/public/session_fields.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +12 -4
- data/lib/samagotchi/web/public/session_sync.js +9 -2
- data/lib/samagotchi/web/public/session_view.js +17 -7
- data/lib/samagotchi/web/public/stage_asking.js +6 -4
- data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
- data/lib/samagotchi/web/public/stage_view.js +18 -4
- data/lib/samagotchi/web/public/stage_waiting.js +74 -6
- data/lib/samagotchi/web/public/storage.js +22 -0
- data/lib/samagotchi/web/public/stream_handlers.js +2 -1
- data/lib/samagotchi/web/public/tokens.css +1 -1
- data/lib/samagotchi/web/public/turn_block.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -6
- data/lib/samagotchi/web/public/turn_html.js +9 -2
- data/lib/samagotchi/web/public/turn_range.js +1 -2
- data/lib/samagotchi/web/public/verify_target.js +2 -4
- data/lib/samagotchi/web/public/viewer.js +59 -0
- data/lib/samagotchi/web/public/viewer_self.js +53 -0
- data/lib/samagotchi/web/public/working_set.js +11 -5
- data/lib/samagotchi/web/public/working_set_view.js +53 -8
- data/lib/samagotchi/web/public/ws_cards.js +89 -0
- data/lib/samagotchi/web/public/ws_peek.js +106 -0
- data/lib/samagotchi/web/public/ws_preview.css +126 -0
- data/lib/samagotchi/web/public/ws_reader.js +319 -0
- data/lib/samagotchi/web/public/ws_timeline.js +200 -0
- data/lib/samagotchi/web/public/ws_way_in.js +25 -0
- data/lib/samagotchi/web/session_control.rb +39 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +75 -28
- data/lib/samagotchi/web/viewer.rb +70 -0
- data/lib/samagotchi/web/working_set_routes.rb +132 -0
- data/lib/samagotchi/worker.rb +19 -14
- data/lib/samagotchi/working_set.rb +260 -8
- metadata +20 -6
- data/lib/samagotchi/web/public/participant.html +0 -218
- data/lib/samagotchi/web/public/participant.js +0 -719
- data/lib/samagotchi/web/public/participant_annotate.js +0 -76
- data/lib/samagotchi/web/public/review_history.js +0 -72
- data/lib/samagotchi/web/public/tools_overview.js +0 -42
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
import { fullTime, gapText } from "./format.js";
|
|
1
|
+
// A participant's author and colour, pure: the one place the web spells a
|
|
2
|
+
// participant's author ("p:<grant id>"), shared by the badges on their
|
|
3
|
+
// prompts (participant_names.js), the owner's participants panel, the
|
|
4
|
+
// question cards, notifications and the viewer's own identity
|
|
5
|
+
// (viewer_self.js).
|
|
7
6
|
|
|
8
7
|
// A participant's author: "p:" and its grant id, as the server writes it
|
|
9
8
|
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
@@ -25,289 +24,3 @@ export function authorSlot(author) {
|
|
|
25
24
|
export function authorColor(author) {
|
|
26
25
|
return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
// Who wrote a user message or started a turn, as the page names them.
|
|
30
|
-
export function authorLabel(item) {
|
|
31
|
-
if (item?.mine) return "You";
|
|
32
|
-
if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
|
|
33
|
-
if (item?.automatic) return "Automatic";
|
|
34
|
-
return "Owner";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// "read docs/plan.md", or the tool's name alone.
|
|
38
|
-
export function toolLine(row) {
|
|
39
|
-
return row?.path ? `${row.tool} ${row.path}` : String(row?.tool || "tool");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// The conversation with each run of text-less tool steps (a reply that
|
|
43
|
-
// only called tools) folded into the next reply's rows, or one row group
|
|
44
|
-
// when no reply follows: one "3 tools: …" line instead of three.
|
|
45
|
-
// A folded run with no reply after it keeps its last step's time (at).
|
|
46
|
-
export function withToolSteps(messages) {
|
|
47
|
-
const out = [];
|
|
48
|
-
let pending = [];
|
|
49
|
-
let pendingAt = null;
|
|
50
|
-
const flush = () => out.push({ role: "assistant", content: "", tools: pending, ...(pendingAt ? { at: pendingAt } : {}) });
|
|
51
|
-
for (const message of messages) {
|
|
52
|
-
const toolOnly = message.role === "assistant" && !String(message.content || "").trim() && message.tools?.length;
|
|
53
|
-
if (toolOnly) {
|
|
54
|
-
pending = pending.concat(message.tools);
|
|
55
|
-
pendingAt = message.at || pendingAt;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (pending.length) {
|
|
59
|
-
if (message.role === "assistant") {
|
|
60
|
-
out.push({ ...message, tools: pending.concat(message.tools || []) });
|
|
61
|
-
pending = [];
|
|
62
|
-
pendingAt = null;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
flush();
|
|
66
|
-
pending = [];
|
|
67
|
-
pendingAt = null;
|
|
68
|
-
}
|
|
69
|
-
out.push(message);
|
|
70
|
-
}
|
|
71
|
-
if (pending.length) flush();
|
|
72
|
-
return out;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// The thread's gap dividers (format.js gapText, the owner's page's words):
|
|
76
|
-
// before each message, and before the turn in progress (+currentAt+, its
|
|
77
|
-
// turn_started's at), the divider's text or null. A gap is marked only
|
|
78
|
-
// where a person's message or a turn starts, measured from the last
|
|
79
|
-
// message's time before it: never inside a turn's work.
|
|
80
|
-
export function threadGaps(messages, { currentAt = null, now = Date.now() } = {}) {
|
|
81
|
-
let last = null;
|
|
82
|
-
const before = (messages || []).map((message) => {
|
|
83
|
-
const text = message?.role === "user" && last && message.at ? gapText(last, message.at, now) : null;
|
|
84
|
-
if (message?.at) last = message.at;
|
|
85
|
-
return text;
|
|
86
|
-
});
|
|
87
|
-
return { before, current: last && currentAt ? gapText(last, currentAt, now) : null };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// The server's words (ParticipantMessages, a relay's detail) as the page
|
|
91
|
-
// shows them: a sentence, never reworded; null with none.
|
|
92
|
-
export function sentence(detail) {
|
|
93
|
-
const text = typeof detail === "string" ? detail.trim() : "";
|
|
94
|
-
if (!text) return null;
|
|
95
|
-
return `${text[0].toUpperCase()}${text.slice(1)}${/[.!?…]$/.test(text) ? "" : "."}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// A comment refused (POST …/turn), in words: the server's detail, else
|
|
99
|
-
// the status.
|
|
100
|
-
export function refusalText(status, body) {
|
|
101
|
-
const said = sentence(body?.detail);
|
|
102
|
-
if (said) return said;
|
|
103
|
-
if (status === 0) return "Not sent: can't reach the session. Send it again.";
|
|
104
|
-
return `Not sent (HTTP ${status}).`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// A queued comment not taken back (POST …/dequeue): the server's detail,
|
|
108
|
-
// else a turn that already started (the Bridge's 409, no detail), else the
|
|
109
|
-
// status.
|
|
110
|
-
export function dequeueText(status, body) {
|
|
111
|
-
const said = sentence(body?.detail);
|
|
112
|
-
if (said) return said;
|
|
113
|
-
if (body?.error === "already_started") return "Not taken back: it already started.";
|
|
114
|
-
if (status === 0) return "Not taken back: can't reach the session.";
|
|
115
|
-
return `Not taken back (HTTP ${status}).`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// A 401 from chi web: the link ended, revoked or expired, in the server's
|
|
119
|
-
// words; with none (no session in the link), where to get a new one.
|
|
120
|
-
export function endedText(body) {
|
|
121
|
-
return sentence(body?.detail) || "Ask the session's owner for a new link.";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Whether a review read that failed is read again: chi web unreachable
|
|
125
|
-
// (status 0) or a 5xx (a worker starting, a proxy); a 404 (gone) or
|
|
126
|
-
// another 4xx is final, a 401 the ended page.
|
|
127
|
-
export function retries(status) {
|
|
128
|
-
return status === 0 || status >= 500;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// The wait before the next read of the review after +failures+ failed in
|
|
132
|
-
// a row: 3 s, doubling, at most 30 s.
|
|
133
|
-
export function retryDelay(failures) {
|
|
134
|
-
return Math.min(3000 * 2 ** Math.max(0, failures), 30000);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// The seq of a stream cursor `<seq>-<epoch>` (or a plain seq); null for none.
|
|
138
|
-
export function cursorSeq(eventId) {
|
|
139
|
-
const m = /^(\d+)/.exec(String(eventId || ""));
|
|
140
|
-
return m ? Number(m[1]) : null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// The filtered stream's URL: from the review data's cursor, `<seq>-<epoch>`
|
|
144
|
-
// as the owner's page sends it (another worker answers it with a reset,
|
|
145
|
-
// which the page re-reads at), or (read with no worker) joining with a
|
|
146
|
-
// snapshot, which arrives as a bare marker the page re-reads at, never as
|
|
147
|
-
// a replay of old turns. The owner's participant view asks for the
|
|
148
|
-
// participant filter (?as=participant).
|
|
149
|
-
export function streamURL(sessionId, eventId, { owner = false } = {}) {
|
|
150
|
-
const params = new URLSearchParams();
|
|
151
|
-
if (cursorSeq(eventId) !== null) params.set("from_seq", String(eventId));
|
|
152
|
-
else params.set("snapshot", "1");
|
|
153
|
-
if (owner) params.set("as", "participant");
|
|
154
|
-
const query = params.toString();
|
|
155
|
-
return `/api/sessions/${encodeURIComponent(sessionId)}/stream${query ? `?${query}` : ""}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// The events after which the page reads its review data again: a turn's
|
|
159
|
-
// start or end, the queue, the owner's question, a snapshot or reset.
|
|
160
|
-
const RELOAD = new Set(["turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued",
|
|
161
|
-
"input_merged", "question_requested", "question_answered", "question_cancelled", "continue_offered",
|
|
162
|
-
"continue_resolved", "snapshot", "reset"]);
|
|
163
|
-
export function reloads(type) {
|
|
164
|
-
return RELOAD.has(type);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// The events after which the live turn drawn is no longer the running one
|
|
168
|
-
// (it ended, another started, the stream started over): the page draws no
|
|
169
|
-
// live event until its re-read, whose stream replays the ones after it.
|
|
170
|
-
const TURN_BOUNDARY = new Set(["turn_started", "turn_completed", "turn_canceled", "turn_failed", "snapshot", "reset"]);
|
|
171
|
-
export function endsLiveTurn(type) {
|
|
172
|
-
return TURN_BOUNDARY.has(type);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// The live turn between reloads: its text by step (a restarted step's
|
|
176
|
-
// text is void) and its tool rows. Returns the new live state.
|
|
177
|
-
export function liveFrom(current) {
|
|
178
|
-
return {
|
|
179
|
-
texts: current?.text ? { 0: current.text } : {},
|
|
180
|
-
tools: Array.isArray(current?.tools) ? current.tools.map((row) => ({ ...row })) : [],
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function applyLive(live, event) {
|
|
185
|
-
const next = { texts: { ...live.texts }, tools: live.tools.slice() };
|
|
186
|
-
const step = Number.isInteger(event?.iteration) ? event.iteration : 0;
|
|
187
|
-
switch (event?.type) {
|
|
188
|
-
case "generation_chunk":
|
|
189
|
-
next.texts[step] = (next.texts[step] || "") + String(event.text || "");
|
|
190
|
-
break;
|
|
191
|
-
case "generation_retrying":
|
|
192
|
-
if (event.restarted) delete next.texts[step];
|
|
193
|
-
break;
|
|
194
|
-
case "tool_call_started":
|
|
195
|
-
next.tools.push({ tool: event.tool, ...(event.path ? { path: event.path } : {}), status: "running",
|
|
196
|
-
iteration: event.iteration, call_index: event.call_index });
|
|
197
|
-
break;
|
|
198
|
-
case "tool_call_completed": {
|
|
199
|
-
const at = next.tools.findLastIndex((row) => row.iteration === event.iteration && row.call_index === event.call_index);
|
|
200
|
-
if (at >= 0) next.tools[at] = { ...next.tools[at], status: event.status || "ok" };
|
|
201
|
-
break;
|
|
202
|
-
}
|
|
203
|
-
default:
|
|
204
|
-
return live;
|
|
205
|
-
}
|
|
206
|
-
return next;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// The live turn's reply text so far, its steps in order.
|
|
210
|
-
export function liveText(live) {
|
|
211
|
-
return Object.keys(live.texts).map(Number).sort((a, b) => a - b).map((k) => live.texts[k]).join("");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// The Plan view's subject: the one picked by path when it is still there,
|
|
215
|
-
// else the first; null with no subjects (not a review session).
|
|
216
|
-
export function pickSubject(subjects, path) {
|
|
217
|
-
const list = Array.isArray(subjects) ? subjects : [];
|
|
218
|
-
return list.find((s) => s.path === path) || list[0] || null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// What the Plan view shows for a subject: rendered markdown, plain text,
|
|
222
|
-
// or why it can't (gone, or no longer a project file a participant may
|
|
223
|
-
// read); +note+ when the text is cut.
|
|
224
|
-
export function subjectBody(subject) {
|
|
225
|
-
if (!subject) return { kind: "none" };
|
|
226
|
-
if (subject.missing) return { kind: "missing", text: `${subject.path} can't be shown: it is gone, or no longer a project file you may read.` };
|
|
227
|
-
const note = subject.truncated ? "Only the start of this file is shown: it is long." : null;
|
|
228
|
-
return subject.html ? { kind: "html", html: subject.html, note } : { kind: "text", text: subject.text || "", note };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Whether the subjects differ from what the Plan view drew (a re-read at
|
|
232
|
-
// a turn boundary redraws it only then, keeping its scroll).
|
|
233
|
-
export function subjectsKey(subjects) {
|
|
234
|
-
return JSON.stringify((subjects || []).map((s) => [s.path, s.missing ? null : s.text, !!s.truncated]));
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Which view a narrow screen shows: "thread" by default, "plan" only when
|
|
238
|
-
// there is a subject to show.
|
|
239
|
-
export function narrowView(wanted, subjects) {
|
|
240
|
-
return wanted === "plan" && (subjects || []).length ? "plan" : "thread";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// A participant's usage row (the review data's viewer.usage: the fields
|
|
244
|
-
// their grant's exposure shows, ParticipantExposure): one short item per
|
|
245
|
-
// field it has, each {key, text, title, level}; level "" / "warn" (most of
|
|
246
|
-
// the cap spent, the queue full) / "over" (the cap reached). Only the
|
|
247
|
-
// participant's own figures ever arrive; a field the level doesn't show is
|
|
248
|
-
// absent and draws nothing. [] with no usage (the owner's view).
|
|
249
|
-
export const USAGE_WARN = 0.8;
|
|
250
|
-
const MINUTE_MS = 60 * 1000;
|
|
251
|
-
const HOUR_MS = 60 * MINUTE_MS;
|
|
252
|
-
const DAY_MS = 24 * HOUR_MS;
|
|
253
|
-
const dollars = (n) => `$${(Number(n) || 0).toFixed(2)}`;
|
|
254
|
-
const isNum = (n) => typeof n === "number" && Number.isFinite(n);
|
|
255
|
-
|
|
256
|
-
// "6 d left", "5 h left", "12 min left", "ended"; "" for no or bad input.
|
|
257
|
-
export function leftText(iso, now = Date.now()) {
|
|
258
|
-
const at = Date.parse(iso || "");
|
|
259
|
-
if (!Number.isFinite(at)) return "";
|
|
260
|
-
const left = at - now;
|
|
261
|
-
if (left <= 0) return "ended";
|
|
262
|
-
if (left >= 2 * DAY_MS) return `${Math.round(left / DAY_MS)} d left`;
|
|
263
|
-
if (left >= 2 * HOUR_MS) return `${Math.floor(left / HOUR_MS)} h left`;
|
|
264
|
-
return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// What the page says a participant's turn does, by the session's mode
|
|
268
|
-
// (viewer.mode: ParticipantMode.live on the server): a review turn only
|
|
269
|
-
// reads; a pair turn (a working session's) acts with the session's tools
|
|
270
|
-
// under the owner's guardrails. +tag+ is a message's: its own stamp
|
|
271
|
-
// (participant_mode), a message without one a review's.
|
|
272
|
-
export const MODE_COPY = {
|
|
273
|
-
review: {
|
|
274
|
-
note: "You can comment and ask; the model answers in a turn that can only read. The owner sees everything.",
|
|
275
|
-
hint: "The model answers in a read-only turn; the owner sees everything.",
|
|
276
|
-
tag: "review",
|
|
277
|
-
},
|
|
278
|
-
pair: {
|
|
279
|
-
note: "You can ask the model to work in this session: your turns use its tools, writes included, under the owner's "
|
|
280
|
-
+ "guardrails, and what needs approval goes to the owner. The owner sees everything.",
|
|
281
|
-
hint: "The model works in this session; the owner approves what needs it and sees everything.",
|
|
282
|
-
tag: "pair",
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
export const modeCopy = (mode) => MODE_COPY[mode] || MODE_COPY.review;
|
|
286
|
-
|
|
287
|
-
export function usageItems(usage, now = Date.now()) {
|
|
288
|
-
if (!usage || typeof usage !== "object") return [];
|
|
289
|
-
const items = [];
|
|
290
|
-
if (isNum(usage.spent)) {
|
|
291
|
-
const cap = isNum(usage.cap) ? usage.cap : null;
|
|
292
|
-
const share = cap ? usage.spent / cap : 0;
|
|
293
|
-
const level = cap === null ? "" : usage.spent >= cap ? "over" : share >= USAGE_WARN ? "warn" : "";
|
|
294
|
-
items.push({ key: "spent", text: cap === null ? `${dollars(usage.spent)} spent` : `${dollars(usage.spent)} of ${dollars(cap)} cap`,
|
|
295
|
-
title: cap === null ? "What your comments have cost the owner so far"
|
|
296
|
-
: `What your comments have cost the owner so far, of the ${dollars(cap)} cap they set`, level });
|
|
297
|
-
}
|
|
298
|
-
if (isNum(usage.queued) && isNum(usage.queue_limit)) {
|
|
299
|
-
items.push({ key: "queued", text: `${usage.queued}/${usage.queue_limit} queued`,
|
|
300
|
-
title: `Your comments waiting for their turn: up to ${usage.queue_limit} can wait behind a running one`,
|
|
301
|
-
level: usage.queued >= usage.queue_limit ? "warn" : "" });
|
|
302
|
-
}
|
|
303
|
-
const left = leftText(usage.expires_at, now);
|
|
304
|
-
if (left) {
|
|
305
|
-
const day = Date.parse(usage.expires_at) - now < DAY_MS;
|
|
306
|
-
items.push({ key: "expires_at", text: left === "ended" ? "link ended" : `link: ${left}`,
|
|
307
|
-
title: `Your link ends ${fullTime(usage.expires_at)}`, level: day ? "warn" : "" });
|
|
308
|
-
}
|
|
309
|
-
if (typeof usage.model === "string" && usage.model) {
|
|
310
|
-
items.push({ key: "model", text: usage.model, title: "The model answering in this session", level: "" });
|
|
311
|
-
}
|
|
312
|
-
return items;
|
|
313
|
-
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// badge drawn before the list came, with neither, says UNNAMED until
|
|
6
6
|
// nameUnnamedBadges names it.
|
|
7
7
|
import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
|
|
8
|
+
import { viewerSelf } from "./viewer_self.js";
|
|
8
9
|
|
|
9
10
|
export const UNNAMED = "a participant";
|
|
10
11
|
|
|
@@ -38,7 +39,11 @@ export function authorBadge(author, name = null, mode = null) {
|
|
|
38
39
|
// tools; a review turn, or one whose mode isn't known (a queued prompt, a
|
|
39
40
|
// message from before the stamp), could only read.
|
|
40
41
|
export const PAIR = "pair";
|
|
41
|
-
|
|
42
|
+
// A participant looking (viewer_self.js) reads a pair turn by its name alone.
|
|
43
|
+
export const authorBadgeText = (name, mode = null) => {
|
|
44
|
+
if (mode === PAIR) return viewerSelf() ? name : `${name} · pair`;
|
|
45
|
+
return `${name} · review`;
|
|
46
|
+
};
|
|
42
47
|
export const authorBadgeTitle = (name, mode = null) => (mode === PAIR
|
|
43
48
|
? `A turn from ${name}, a participant: it acted with this session's tools, its asks came to you`
|
|
44
49
|
: `A review comment from ${name}, a participant: its turn could only read`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Revise on a participant's page: the answers to their own messages only,
|
|
2
|
+
// as chi web and the worker decide it (ParticipantRevise, from the author
|
|
3
|
+
// the server stamped; this is the page's mirror, so it shows a button only
|
|
4
|
+
// where a send would be taken). revise_stack.js draws the stacks for every
|
|
5
|
+
// viewer; this module answers its mayRevise, and app.js wires it for a
|
|
6
|
+
// participant (viewer.can("revise_own_turn")).
|
|
7
|
+
//
|
|
8
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
9
|
+
// export on an old module would blank the page.
|
|
10
|
+
import { viewerSelf } from "./viewer_self.js";
|
|
11
|
+
import { workerPredates } from "./worker_features.js";
|
|
12
|
+
|
|
13
|
+
// The worker feature their Revise needs (ReviseAnchor::PARTICIPANT_FEATURE):
|
|
14
|
+
// a worker that checks it at the stamp.
|
|
15
|
+
export const PARTICIPANT_REVISE_FEATURE = "participant_revise";
|
|
16
|
+
|
|
17
|
+
// The button's title and the chip's words while a live worker lacks it.
|
|
18
|
+
// The copy of ParticipantMessages::REVISE_UNAVAILABLE (its Ruby owner), the
|
|
19
|
+
// only one on the page: a refused send shows the server's own detail.
|
|
20
|
+
export const PARTICIPANT_REVISE_UNAVAILABLE = "this session's worker is too old for you to revise an answer: ask the session's owner to restart it";
|
|
21
|
+
|
|
22
|
+
// The pre-send gate (worker_features.js's rule): a live worker without it.
|
|
23
|
+
export function participantReviseUnavailable(workerFeatures, bridgeUp) {
|
|
24
|
+
return workerPredates(workerFeatures, bridgeUp, PARTICIPANT_REVISE_FEATURE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Whether a prompt bubble is +author+'s: its data-author (a reload's, a live
|
|
28
|
+
// echo's), or this tab's own echo.
|
|
29
|
+
function ownPrompt(prompt, author) {
|
|
30
|
+
return !!prompt && (prompt.dataset?.author === author || prompt.dataset?.own === "1");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Rules 1–2 on historyTurns' list (+turns+, revise_stack.js), in one
|
|
34
|
+
// backward pass: the turns +author+ may revise, each opening with their
|
|
35
|
+
// prompt, answered, not a Verify turn, and with only their own turns after
|
|
36
|
+
// it (none of the owner's, another participant's or a wake turn's). The
|
|
37
|
+
// owner's `!` lines never reach their page, so they count for nothing here
|
|
38
|
+
// either. @return Set of turns
|
|
39
|
+
export function revisableOwn(turns, author) {
|
|
40
|
+
const ok = new Set();
|
|
41
|
+
if (!author || !Array.isArray(turns)) return ok;
|
|
42
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
43
|
+
const t = turns[i];
|
|
44
|
+
const own = !t.wake && ownPrompt(t.prompt, author);
|
|
45
|
+
if (!own) break;
|
|
46
|
+
if (t.answer && !t.prompt.hasAttribute?.("data-verify")) ok.add(t);
|
|
47
|
+
}
|
|
48
|
+
return ok;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Whether the turn at +index+ is one of revisableOwn's.
|
|
52
|
+
export function mayReviseOwn(turns, index, author) {
|
|
53
|
+
return Array.isArray(turns) && revisableOwn(turns, author).has(turns[index]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// revise_stack.js's mayRevise for the viewer looking (viewer_self.js), false
|
|
57
|
+
// while their composer is closed (+closed+(): cap reached, link ended,
|
|
58
|
+
// session stopped). One pass per decorate: its +turns+ list is read once.
|
|
59
|
+
export function participantMayRevise(closed = () => false) {
|
|
60
|
+
let last = null;
|
|
61
|
+
return (turn, turns) => {
|
|
62
|
+
if (closed()) return false;
|
|
63
|
+
if (last?.turns !== turns) last = { turns, ok: revisableOwn(turns, viewerSelf()?.author) };
|
|
64
|
+
return last.ok.has(turn);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
// expiry, cap, and behind an auth proxy that names the signed-in user their
|
|
6
6
|
// email, which binds the link to that account) gives the link once, to
|
|
7
7
|
// copy, share (a phone's share sheet)
|
|
8
|
-
// or scan (its QR)
|
|
9
|
-
// participant sees this session (through the server's filter), in a new
|
|
10
|
-
// tab. The panel reads GET /api/sessions/:id/participants, again after
|
|
8
|
+
// or scan (its QR). The panel reads GET /api/sessions/:id/participants, again after
|
|
11
9
|
// every change and on a participants_changed event.
|
|
12
10
|
//
|
|
13
11
|
// The names it learns feed the author badges on participants' prompts
|
|
@@ -149,11 +147,6 @@ export function revokeConfirmText(name) {
|
|
|
149
147
|
return `Revoke ${name}'s link? Their page and stream stop and their queued comments are dropped. This can't be undone: a new invite gives a new link.`;
|
|
150
148
|
}
|
|
151
149
|
|
|
152
|
-
// The participant view's address: the participant page as the owner sees it.
|
|
153
|
-
export function viewURL(sessionId) {
|
|
154
|
-
return `/?as=participant#s=${encodeURIComponent(sessionId)}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
150
|
function rowTemplate(row, { capFor }) {
|
|
158
151
|
const actions = row.active ? html`<span class="pp-actions">${capFor === row.id
|
|
159
152
|
? html`<form class="pp-cap-form" data-id=${row.id}><input name="cap" type="text" inputmode="decimal" autocomplete="off"
|
|
@@ -193,7 +186,7 @@ export const EMPTY_REVIEW = "Nobody yet. Invite a person (or an agent) to review
|
|
|
193
186
|
export const EMPTY_PAIR = "Nobody yet. Invite a person (or an agent) to work with you here: their turns act with this session's tools under your guardrails, and their asks come to you.";
|
|
194
187
|
|
|
195
188
|
// The popover's content: the participants, the Invite form (or the link it
|
|
196
|
-
// gave)
|
|
189
|
+
// gave).
|
|
197
190
|
// +working+: the server's warning for a session that isn't a review session
|
|
198
191
|
// (shown before the Invite, with Start a review of it), null for one.
|
|
199
192
|
// +pair+: a participant invited now gets pair turns (the list's mode: a
|
|
@@ -229,9 +222,7 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
|
|
|
229
222
|
?selected=${d === DEFAULT_EXPIRY_DAYS}>${d === 1 ? "1 day" : `${d} days`}</option>`)}</select></span></label
|
|
230
223
|
><label>Cap $<input name="cap" type="text" inputmode="decimal" placeholder="2" autocomplete="off" aria-label="Spending cap, dollars"></label
|
|
231
224
|
><button type="submit" class="pp-invite-btn" ?disabled=${busy}>Invite</button></div></form>`}
|
|
232
|
-
|
|
233
|
-
class="pp-view" href=${viewURL(sessionId)} target="_blank" rel="noopener"
|
|
234
|
-
title="The participant page for this session, as a participant sees it: through the same filter">Participant view ↗</a></div>`;
|
|
225
|
+
${invite ? html`<div class="ctx-pop-actions pp-foot"><button type="button" data-act="another">Invite another</button></div>` : nothing}`;
|
|
235
226
|
}
|
|
236
227
|
|
|
237
228
|
// The control: el (#infoText, where the chip is drawn), session() the open
|
|
@@ -24,7 +24,8 @@ import { clientLabel, isOwn } from "./turn_events.js";
|
|
|
24
24
|
import { continueLine } from "./commands.js";
|
|
25
25
|
import { plainText } from "./plain_line.js";
|
|
26
26
|
import { noContactFill } from "./no_contact_fill.js";
|
|
27
|
-
import {
|
|
27
|
+
import { removeSession } from "./storage.js";
|
|
28
|
+
import { CONTINUE_KEY, foldKey, questionKey } from "./stage_waiting.js";
|
|
28
29
|
|
|
29
30
|
// A card's text fields' name: not a contact field (no_contact_fill.js).
|
|
30
31
|
const CARD_NOTE = "chi-card-note";
|
|
@@ -50,6 +51,8 @@ const CARD_NOTE = "chi-card-note";
|
|
|
50
51
|
// @param onPending (pending|null) called whenever the open question changes:
|
|
51
52
|
// the page touches the redraws that read pendingQuestion() (the session
|
|
52
53
|
// bar's waiting chip, docs/internals/web-frontend.md write -> redraw)
|
|
54
|
+
// @param answers false for a viewer who may not answer (viewer.js
|
|
55
|
+
// answer_cards): no question or continue card is drawn at all
|
|
53
56
|
// @param onSettled (key) a card stopped waiting (answered, cancelled,
|
|
54
57
|
// replaced or removed; the continue card resolved or dropped), by its
|
|
55
58
|
// stage_waiting.js key: the stage's waiting cards as data
|
|
@@ -61,7 +64,7 @@ export function createQuestionCards({
|
|
|
61
64
|
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
62
65
|
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
|
|
63
66
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
64
|
-
onPending = () => {}, onSettled = () => {},
|
|
67
|
+
onPending = () => {}, onSettled = () => {}, answers = true,
|
|
65
68
|
}) {
|
|
66
69
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
67
70
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -85,6 +88,7 @@ export function createQuestionCards({
|
|
|
85
88
|
// +said+: the asking reply when the caller knows it (a reloaded card: its
|
|
86
89
|
// step's text, "" for none); else the running turn's (askingText).
|
|
87
90
|
function renderQuestionCard(pq, { said = null } = {}) {
|
|
91
|
+
if (!answers) return;
|
|
88
92
|
const action = nextCardAction({ cardId: questionEl?.dataset.qid ?? null, pendingId: pending?.id ?? null }, pq);
|
|
89
93
|
if (action === "same") return;
|
|
90
94
|
// A resolved card stays where it was; only a pending one is replaced.
|
|
@@ -520,6 +524,11 @@ export function createQuestionCards({
|
|
|
520
524
|
// After the collapse: a card left open by hand renders at once.
|
|
521
525
|
if (full) renderSaidOnOpen(questionEl, full, asked, refs);
|
|
522
526
|
resolvedId = pending.id;
|
|
527
|
+
// Answered or cancelled for good: the fold this tab kept for it is gone
|
|
528
|
+
// (stage_waiting.js). No session id, no key: foldKey(null) is null, and a
|
|
529
|
+
// removeItem(null) would delete the key "null".
|
|
530
|
+
const fold = foldKey(sessionId());
|
|
531
|
+
if (fold) removeSession(fold);
|
|
523
532
|
onSettled(questionKey(questionEl.dataset.qid));
|
|
524
533
|
setPending(null);
|
|
525
534
|
}
|
|
@@ -614,7 +623,7 @@ export function createQuestionCards({
|
|
|
614
623
|
}
|
|
615
624
|
|
|
616
625
|
function renderContinueCard(offer) {
|
|
617
|
-
if (continueEl?.isConnected) return;
|
|
626
|
+
if (!answers || continueEl?.isConnected) return;
|
|
618
627
|
// A step-limit question is up: it is the card (an older worker has none).
|
|
619
628
|
if (isContinue(pending)) return;
|
|
620
629
|
removeHintIfEmpty();
|
|
@@ -678,6 +687,8 @@ export function createQuestionCards({
|
|
|
678
687
|
continueEl = null;
|
|
679
688
|
if (!card) return;
|
|
680
689
|
card.classList.add("resolved");
|
|
690
|
+
const fold = foldKey(sessionId());
|
|
691
|
+
if (fold) removeSession(fold);
|
|
681
692
|
onSettled(CONTINUE_KEY);
|
|
682
693
|
card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
|
|
683
694
|
const note = doc.createElement("div");
|