samagotchi 0.108.0 → 0.110.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 +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +52 -10
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +47 -10
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +12 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// notices, plugins' steers, command and note bubbles. The markup is lit
|
|
5
5
|
// (turn_html.js elementOf); createBubbles puts them in the history.
|
|
6
6
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
7
|
-
import { normalize, recapLabel, recapPlace } from "./format.js";
|
|
7
|
+
import { fullTime, normalize, recapLabel, recapPlace } from "./format.js";
|
|
8
8
|
import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
|
|
9
9
|
import { thumbsTemplate } from "./images.js";
|
|
10
10
|
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
@@ -12,6 +12,7 @@ import { keepFollowing } from "./scroll.js";
|
|
|
12
12
|
import { linkedLine } from "./notice_links.js";
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
|
+
import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
|
|
15
16
|
|
|
16
17
|
export function removeQueuedBubble(bubble) {
|
|
17
18
|
if (!bubble) return;
|
|
@@ -49,8 +50,11 @@ export function endBubbleTemplate(text, { stopped = false } = {}) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// +createdAt+: when it was written (its label's time, kept for the stale
|
|
54
|
+
// label as data-created-at).
|
|
55
|
+
export function recapTemplate(text, turnsSince = 0, createdAt = null, now = Date.now()) {
|
|
56
|
+
const created = typeof createdAt === "string" && createdAt ? createdAt : nothing;
|
|
57
|
+
return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} data-created-at=${created} open><summary>${recapLabel(turnsSince, false, createdAt, now)}</summary><div class="recap-body">${text}</div></details>`;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
@@ -228,14 +232,16 @@ export function createBubbles({
|
|
|
228
232
|
// the end of the history. A turn starting leaves it in place, marked
|
|
229
233
|
// earlier; only a new recap replaces it.
|
|
230
234
|
|
|
231
|
-
function showRecap(text, turnsSince = 0) {
|
|
235
|
+
function showRecap(text, turnsSince = 0, createdAt = null) {
|
|
232
236
|
if (!text) return;
|
|
233
237
|
removeRecap();
|
|
234
|
-
const details = elementOf(recapTemplate(text, turnsSince));
|
|
238
|
+
const details = elementOf(recapTemplate(text, turnsSince, createdAt));
|
|
235
239
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
236
240
|
// Never in the stage: a recap is between turns.
|
|
237
241
|
turnDom.settleFold?.();
|
|
238
|
-
const
|
|
242
|
+
const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
|
|
243
|
+
// Above that turn's gap divider too: the recap was written before the quiet stretch ended.
|
|
244
|
+
const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
|
|
239
245
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -244,7 +250,7 @@ export function createBubbles({
|
|
|
244
250
|
historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
|
|
245
251
|
el.classList.add("stale");
|
|
246
252
|
const summary = el.querySelector("summary");
|
|
247
|
-
if (summary) summary.textContent = recapLabel(0, true);
|
|
253
|
+
if (summary) summary.textContent = recapLabel(0, true, el.dataset.createdAt || null);
|
|
248
254
|
});
|
|
249
255
|
}
|
|
250
256
|
|
|
@@ -282,6 +288,9 @@ export function createBubbles({
|
|
|
282
288
|
const id = selected();
|
|
283
289
|
if (id) div.dataset.keptFor = id;
|
|
284
290
|
drawCommandBubble(div, view);
|
|
291
|
+
// When it happened (a reminder's at): the hover, as a reload's bubbles.
|
|
292
|
+
const when = fullTime(view.at);
|
|
293
|
+
if (when) div.title = when;
|
|
285
294
|
placeFollowing(div);
|
|
286
295
|
}
|
|
287
296
|
|
|
@@ -295,8 +304,21 @@ export function createBubbles({
|
|
|
295
304
|
placeFollowing(elementOf(noteBubbleTemplate(note)));
|
|
296
305
|
}
|
|
297
306
|
|
|
307
|
+
// A quiet stretch passed (time_gaps.js): its divider above +before+ (a
|
|
308
|
+
// turn's prompt bubble, wherever it sits), else at the end.
|
|
309
|
+
function addTimeGap(text, { before = null } = {}) {
|
|
310
|
+
if (!text) return null;
|
|
311
|
+
const el = timeGapEl(text);
|
|
312
|
+
if (before?.parentNode) {
|
|
313
|
+
keepFollowing(historyEl, () => before.parentNode.insertBefore(el, before));
|
|
314
|
+
return el;
|
|
315
|
+
}
|
|
316
|
+
return placeFollowing(el);
|
|
317
|
+
}
|
|
318
|
+
|
|
298
319
|
return {
|
|
299
320
|
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
300
321
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
322
|
+
addTimeGap,
|
|
301
323
|
};
|
|
302
324
|
}
|
|
@@ -1,9 +1,27 @@
|
|
|
1
|
+
import { authProbe, lostAnswer } from "./auth_lost.js";
|
|
2
|
+
|
|
3
|
+
// What the page does when an answer says the sign-in is gone (auth_lost.js;
|
|
4
|
+
// app.js sets it). A no-op until then.
|
|
5
|
+
let authLostHandler = () => {};
|
|
6
|
+
export function onAuthLost(handler) {
|
|
7
|
+
authLostHandler = typeof handler === "function" ? handler : () => {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Accept: application/json: an auth proxy whose session ended answers 401
|
|
11
|
+
// {} (oauth2-proxy) rather than a redirect to its sign-in. A fetch that
|
|
12
|
+
// fails outright asks authProbe whether that was a sign-in redirect.
|
|
1
13
|
export function api(path, opts = {}) {
|
|
2
14
|
const { fetchImpl = globalThis.fetch } = opts;
|
|
3
15
|
return fetchImpl(path, {
|
|
4
|
-
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
5
16
|
...opts,
|
|
17
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...(opts.headers || {}) },
|
|
18
|
+
}).catch((error) => {
|
|
19
|
+
if (error instanceof TypeError) {
|
|
20
|
+
authProbe(fetchImpl).then((state) => { if (state === "lost") authLostHandler(`fetch ${path}`); }, () => {});
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
6
23
|
}).then(async (r) => {
|
|
24
|
+
if (lostAnswer(r)) authLostHandler(`${r.status} ${path}`);
|
|
7
25
|
const j = await r.json().catch(() => ({}));
|
|
8
26
|
if (!r.ok) {
|
|
9
27
|
// The server's words as they are: what the page shows the user. The
|
|
@@ -279,10 +297,12 @@ export function openStream(id, fromSeq, handlers = {}, opts = {}) {
|
|
|
279
297
|
// only when the browser gave up before the first frame (a chi web without
|
|
280
298
|
// a hub answers 503, a bad ?dir 400): the caller falls back to fetching.
|
|
281
299
|
// Later errors are left to EventSource's own reconnect, and a reconnect
|
|
282
|
-
// always starts with a fresh snapshot
|
|
300
|
+
// always starts with a fresh snapshot, unless the browser gave up for good
|
|
301
|
+
// (readyState CLOSED: an auth proxy answered the reconnect with its sign-in
|
|
302
|
+
// page): onClosed then, and the caller probes and opens it again.
|
|
283
303
|
// @return {{close(): void}}
|
|
284
304
|
export function openEvents(handlers = {}, opts = {}) {
|
|
285
|
-
const { EventSourceImpl = globalThis.EventSource, dir, onError } = opts;
|
|
305
|
+
const { EventSourceImpl = globalThis.EventSource, dir, onError, onClosed } = opts;
|
|
286
306
|
const url = dir ? `/api/events?dir=${encodeURIComponent(dir)}` : "/api/events";
|
|
287
307
|
const es = new EventSourceImpl(url);
|
|
288
308
|
let frameReceived = false;
|
|
@@ -290,10 +310,14 @@ export function openEvents(handlers = {}, opts = {}) {
|
|
|
290
310
|
let live = true;
|
|
291
311
|
es.onerror = () => {
|
|
292
312
|
// readyState 2 (CLOSED): the browser won't retry on its own.
|
|
293
|
-
if (
|
|
313
|
+
if (!live || es.readyState !== 2) return;
|
|
294
314
|
live = false;
|
|
295
315
|
try { es.close(); } catch (_) {}
|
|
296
|
-
if (
|
|
316
|
+
if (!frameReceived) {
|
|
317
|
+
if (typeof onError === "function") onError();
|
|
318
|
+
} else if (typeof onClosed === "function") {
|
|
319
|
+
onClosed();
|
|
320
|
+
}
|
|
297
321
|
};
|
|
298
322
|
return {
|
|
299
323
|
close() {
|
|
@@ -253,13 +253,79 @@ export function relativeTime(iso, now = Date.now()) {
|
|
|
253
253
|
return d.getFullYear() === new Date(now).getFullYear() ? date : `${date} ${d.getFullYear()}`;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
256
|
+
// ── Wall times (MessageTime's twin, lib/samagotchi/message_time.rb) ──
|
|
257
|
+
// A history entry's `at` (local ISO), a turn record's started_at, an
|
|
258
|
+
// event's at / created_at: the bubbles' hover and the gap dividers. Every
|
|
259
|
+
// helper takes its times (ISO text or epoch ms) and +now+ explicitly.
|
|
260
|
+
|
|
261
|
+
// The quiet time after which the page marks a gap (MessageTime::GAP_SECONDS).
|
|
262
|
+
export const GAP_SECONDS = 600;
|
|
263
|
+
|
|
264
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
265
|
+
|
|
266
|
+
// +time+ (ISO text, epoch ms or a Date) as a Date, or null.
|
|
267
|
+
export function timeOf(time) {
|
|
268
|
+
const t = time instanceof Date ? time.getTime() : typeof time === "number" ? time : typeof time === "string" ? Date.parse(time) : NaN;
|
|
269
|
+
return Number.isFinite(t) ? new Date(t) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
273
|
+
const hhmm = (d) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
274
|
+
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
275
|
+
// "Sat 24 Oct 09:10" (MessageTime.day_clock).
|
|
276
|
+
const dayClock = (d) => `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${hhmm(d)}`;
|
|
277
|
+
|
|
278
|
+
// "14:32", local time; on another local day than +now+ (when given) the
|
|
279
|
+
// day form "Sat 24 Oct 09:10". "" for no or bad input.
|
|
280
|
+
export function wallClock(time, now = null) {
|
|
281
|
+
const d = timeOf(time);
|
|
282
|
+
if (!d) return "";
|
|
283
|
+
const today = now === null ? null : timeOf(now);
|
|
284
|
+
return today && !sameDay(d, today) ? dayClock(d) : hhmm(d);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A bubble's hover: "Sat 24 Oct 2026 14:32:05", local time; "" for none.
|
|
288
|
+
export function fullTime(time) {
|
|
289
|
+
const d = timeOf(time);
|
|
290
|
+
if (!d) return "";
|
|
291
|
+
return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()} ${hhmm(d)}:${pad2(d.getSeconds())}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// "47 min", "3 h", "2 d": whole units, rounded down (MessageTime.ago; the
|
|
295
|
+
// scale of relativeTime).
|
|
296
|
+
export function agoText(seconds) {
|
|
297
|
+
const s = Number(seconds) || 0;
|
|
298
|
+
if (s < 3600) return `${Math.max(Math.floor(s / 60), 0)} min`;
|
|
299
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h`;
|
|
300
|
+
return `${Math.floor(s / 86400)} d`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The divider between activity that ended at +prevEnd+ and activity that
|
|
304
|
+
// starts at +nextStart+: null below GAP_SECONDS (or when either is
|
|
305
|
+
// unknown), else "── 14:32 · 47 min later ──" (MessageTime.gap_line). The
|
|
306
|
+
// date is added when the local day changed, and (the page reads old
|
|
307
|
+
// history too) when the next start is on another day than +now+.
|
|
308
|
+
export function gapText(prevEnd, nextStart, now = Date.now()) {
|
|
309
|
+
const prev = timeOf(prevEnd);
|
|
310
|
+
const next = timeOf(nextStart);
|
|
311
|
+
if (!prev || !next) return null;
|
|
312
|
+
const seconds = (next - prev) / 1000;
|
|
313
|
+
if (seconds < GAP_SECONDS) return null;
|
|
314
|
+
const today = timeOf(now);
|
|
315
|
+
const time = sameDay(prev, next) && (!today || sameDay(next, today)) ? hhmm(next) : dayClock(next);
|
|
316
|
+
return `\u2500\u2500 ${time} \u00b7 ${agoText(seconds)} later \u2500\u2500`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The recap bubble's label: when it was written (+createdAt+, wallClock
|
|
320
|
+
// against +now+), how many turns came after it (stale: a turn started while
|
|
321
|
+
// the page showed it, count unknown).
|
|
322
|
+
export function recapLabel(turnsSince, stale = false, createdAt = null, now = Date.now()) {
|
|
259
323
|
const n = Number(turnsSince) || 0;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
324
|
+
const time = wallClock(createdAt, now);
|
|
325
|
+
const head = time ? `recap · ${time}` : "recap";
|
|
326
|
+
if (n === 1) return `${head} · before the last turn`;
|
|
327
|
+
if (n > 1) return `${head} · before the last ${n} turns`;
|
|
328
|
+
return stale ? `${head} · earlier` : head;
|
|
263
329
|
}
|
|
264
330
|
|
|
265
331
|
// The info bar's memory chip on a phone (where the "mem: …" line goes),
|
|
@@ -51,6 +51,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
51
51
|
.bubble.output.boxed{overflow:hidden;transition:max-height 360ms ease}
|
|
52
52
|
.bubble.output.revealing>:not(.copy-btn){animation:fadeIn 240ms ease}
|
|
53
53
|
@media (prefers-reduced-motion:reduce){.bubble.output.boxed{transition:none}.bubble.output.revealing>:not(.copy-btn){animation:none}}
|
|
54
|
+
/* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
|
|
55
|
+
.time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
|
|
54
56
|
.turn-timing{margin-top:8px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
55
57
|
.turn-timing.live{color:var(--accent)}
|
|
56
58
|
.bubble.output.markdown{white-space:normal}
|
|
@@ -67,6 +67,8 @@ body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica
|
|
|
67
67
|
main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
68
68
|
.p-thread{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
|
|
69
69
|
.p-empty{color:var(--text-muted);text-align:center;padding:40px 0}
|
|
70
|
+
/* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
|
|
71
|
+
.time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
|
|
70
72
|
.p-msg{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
71
73
|
.p-who{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px}
|
|
72
74
|
.p-who b{font-weight:600;color:var(--author-color,var(--author-owner))}
|
|
@@ -7,8 +7,19 @@
|
|
|
7
7
|
// POST …/turn (text only); their queued ones come back with Dequeue.
|
|
8
8
|
import {
|
|
9
9
|
authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
10
|
-
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
|
|
10
|
+
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps,
|
|
11
11
|
} from "./participant_model.js";
|
|
12
|
+
import { fullTime } from "./format.js";
|
|
13
|
+
import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
14
|
+
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
15
|
+
|
|
16
|
+
// A link's #code= opened in a browser that already has a cookie: traded
|
|
17
|
+
// first, the page reloads without it.
|
|
18
|
+
// This page has a session already: the trade would replace it (a link
|
|
19
|
+
// someone sent to swap this browser's session for theirs), so ask first.
|
|
20
|
+
if (fragmentCode(globalThis.location?.hash)
|
|
21
|
+
&& await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
|
|
22
|
+
const authLost = createAuthLost();
|
|
12
23
|
|
|
13
24
|
const $ = (id) => document.getElementById(id);
|
|
14
25
|
const body = document.body.dataset;
|
|
@@ -47,17 +58,21 @@ function el(tag, cls, text) {
|
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
// A request's answer; a network failure is status 0 (the page says it
|
|
50
|
-
// can't reach the session), never a throw.
|
|
61
|
+
// can't reach the session), never a throw. A 401 with chi's words is the
|
|
62
|
+
// link ending (revoked, expired); one without (an auth proxy's own 401 {}),
|
|
63
|
+
// a redirect or an HTML answer is the sign-in ending (auth_lost.js).
|
|
51
64
|
async function api(path, options = {}) {
|
|
52
65
|
let res;
|
|
53
66
|
try {
|
|
54
|
-
res = await fetch(path, { credentials: "same-origin", ...options
|
|
67
|
+
res = await fetch(path, { credentials: "same-origin", ...options,
|
|
68
|
+
headers: { Accept: "application/json", ...(options.headers || {}) } });
|
|
55
69
|
} catch {
|
|
56
70
|
return { status: 0, ok: false, body: null };
|
|
57
71
|
}
|
|
58
72
|
let json = null;
|
|
59
73
|
try { json = await res.json(); } catch { json = null; }
|
|
60
|
-
if (res.status === 401)
|
|
74
|
+
if (lostAnswer(res) && !(res.status === 401 && json?.error)) authLost(`${res.status} ${path}`);
|
|
75
|
+
else if (res.status === 401) showEnded(json);
|
|
61
76
|
return { status: res.status, ok: res.ok, body: json };
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -227,6 +242,9 @@ function setView(view) {
|
|
|
227
242
|
function messageNode(message) {
|
|
228
243
|
const user = message.role === "user";
|
|
229
244
|
const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
|
|
245
|
+
// When it happened, on hover (the owner's page's bubbles do the same).
|
|
246
|
+
const when = fullTime(message.at);
|
|
247
|
+
if (when) node.title = when;
|
|
230
248
|
if (user) {
|
|
231
249
|
node.style.setProperty("--author-color", authorColor(message.author));
|
|
232
250
|
const who = el("div", "p-who");
|
|
@@ -269,16 +287,35 @@ function toolsNode(rows, open = null) {
|
|
|
269
287
|
return details;
|
|
270
288
|
}
|
|
271
289
|
|
|
290
|
+
// A gap divider (the owner's page's .time-gap line), kept while its text is.
|
|
291
|
+
function gapNode(text, old) {
|
|
292
|
+
if (!text) return null;
|
|
293
|
+
if (old?.textContent === text) return old;
|
|
294
|
+
const node = el("div", "time-gap", text);
|
|
295
|
+
node.setAttribute("role", "separator");
|
|
296
|
+
return node;
|
|
297
|
+
}
|
|
298
|
+
|
|
272
299
|
function renderThread() {
|
|
273
300
|
const thread = $("thread");
|
|
274
|
-
|
|
301
|
+
const messages = withToolSteps(data.messages || []);
|
|
302
|
+
const gaps = threadGaps(messages, { currentAt: data.current_turn?.at || null });
|
|
303
|
+
drawnMessages = messages.map((message, at) => {
|
|
275
304
|
const sig = JSON.stringify(message);
|
|
276
305
|
const old = drawnMessages[at];
|
|
277
|
-
|
|
278
|
-
|
|
306
|
+
const gap = gapNode(gaps.before[at], old?.gap);
|
|
307
|
+
if (old?.sig === sig) return { ...old, gap };
|
|
308
|
+
return { sig, gap, node: keepToolsOpen(messageNode(message), old?.node) };
|
|
279
309
|
});
|
|
280
|
-
const nodes = drawnMessages.
|
|
281
|
-
if (data.current_turn)
|
|
310
|
+
const nodes = drawnMessages.flatMap((entry) => (entry.gap ? [entry.gap, entry.node] : [entry.node]));
|
|
311
|
+
if (data.current_turn) {
|
|
312
|
+
const gap = gapNode(gaps.current, thread.querySelector(":scope > .time-gap.current"));
|
|
313
|
+
if (gap) {
|
|
314
|
+
gap.classList.add("current");
|
|
315
|
+
nodes.push(gap);
|
|
316
|
+
}
|
|
317
|
+
nodes.push(...currentTurnNodes());
|
|
318
|
+
}
|
|
282
319
|
else if (data.waiting_for_owner) nodes.push(el("div", "p-waiting", "Waiting for the session's owner"));
|
|
283
320
|
if (nodes.length === 0) nodes.push(el("div", "p-empty", "Nothing here yet. Comments and replies show here."));
|
|
284
321
|
const main = $("main");
|
|
@@ -312,7 +349,7 @@ function currentTurnNodes() {
|
|
|
312
349
|
const nodes = [];
|
|
313
350
|
if (turn.prompt !== undefined || turn.automatic) {
|
|
314
351
|
nodes.push(messageNode({ role: "user", content: turn.automatic ? "An automatic turn" : turn.prompt, author: turn.author,
|
|
315
|
-
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic }));
|
|
352
|
+
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at }));
|
|
316
353
|
}
|
|
317
354
|
const reply = el("div", "p-msg reply live");
|
|
318
355
|
fillLive(reply, $("liveReply"));
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// refusal in words, the stream's URL, and the live turn as filtered events
|
|
4
4
|
// build it (ParticipantView on the server decides what arrives at all).
|
|
5
5
|
|
|
6
|
+
import { gapText } from "./format.js";
|
|
7
|
+
|
|
6
8
|
// A participant's author: "p:" and its grant id, as the server writes it
|
|
7
9
|
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
8
10
|
export const PARTICIPANT_PREFIX = "p:";
|
|
@@ -47,30 +49,51 @@ export function toolsSummary(rows) {
|
|
|
47
49
|
// The conversation with each run of text-less tool steps (a reply that
|
|
48
50
|
// only called tools) folded into the next reply's rows, or one row group
|
|
49
51
|
// when no reply follows: one "3 tools: …" line instead of three.
|
|
52
|
+
// A folded run with no reply after it keeps its last step's time (at).
|
|
50
53
|
export function withToolSteps(messages) {
|
|
51
54
|
const out = [];
|
|
52
55
|
let pending = [];
|
|
56
|
+
let pendingAt = null;
|
|
57
|
+
const flush = () => out.push({ role: "assistant", content: "", tools: pending, ...(pendingAt ? { at: pendingAt } : {}) });
|
|
53
58
|
for (const message of messages) {
|
|
54
59
|
const toolOnly = message.role === "assistant" && !String(message.content || "").trim() && message.tools?.length;
|
|
55
60
|
if (toolOnly) {
|
|
56
61
|
pending = pending.concat(message.tools);
|
|
62
|
+
pendingAt = message.at || pendingAt;
|
|
57
63
|
continue;
|
|
58
64
|
}
|
|
59
65
|
if (pending.length) {
|
|
60
66
|
if (message.role === "assistant") {
|
|
61
67
|
out.push({ ...message, tools: pending.concat(message.tools || []) });
|
|
62
68
|
pending = [];
|
|
69
|
+
pendingAt = null;
|
|
63
70
|
continue;
|
|
64
71
|
}
|
|
65
|
-
|
|
72
|
+
flush();
|
|
66
73
|
pending = [];
|
|
74
|
+
pendingAt = null;
|
|
67
75
|
}
|
|
68
76
|
out.push(message);
|
|
69
77
|
}
|
|
70
|
-
if (pending.length)
|
|
78
|
+
if (pending.length) flush();
|
|
71
79
|
return out;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
// The thread's gap dividers (format.js gapText, the owner's page's words):
|
|
83
|
+
// before each message, and before the turn in progress (+currentAt+, its
|
|
84
|
+
// turn_started's at), the divider's text or null. A gap is marked only
|
|
85
|
+
// where a person's message or a turn starts, measured from the last
|
|
86
|
+
// message's time before it: never inside a turn's work.
|
|
87
|
+
export function threadGaps(messages, { currentAt = null, now = Date.now() } = {}) {
|
|
88
|
+
let last = null;
|
|
89
|
+
const before = (messages || []).map((message) => {
|
|
90
|
+
const text = message?.role === "user" && last && message.at ? gapText(last, message.at, now) : null;
|
|
91
|
+
if (message?.at) last = message.at;
|
|
92
|
+
return text;
|
|
93
|
+
});
|
|
94
|
+
return { before, current: last && currentAt ? gapText(last, currentAt, now) : null };
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
// The server's words (ParticipantMessages, a relay's detail) as the page
|
|
75
98
|
// shows them: a sentence, never reworded; null with none.
|
|
76
99
|
export function sentence(detail) {
|
|
@@ -36,10 +36,13 @@
|
|
|
36
36
|
// setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
|
|
37
37
|
// showHookNotice(data), onInitEvent(type, data), upsertCard(card),
|
|
38
38
|
// refreshCardBodies(), syncModelPick() the page's hooks
|
|
39
|
+
// gapClock the page's last activity (time_gaps.js
|
|
40
|
+
// createGapClock; renderHistory seeds it)
|
|
41
|
+
// addTimeGap(text, {before}) a gap's divider (bubbles.js)
|
|
39
42
|
|
|
40
43
|
import { routeChunk } from "./chunk_router.js";
|
|
41
44
|
import { extractCtxPct, generationTokens } from "./ctx.js";
|
|
42
|
-
import { failedTurnText } from "./format.js";
|
|
45
|
+
import { failedTurnText, fullTime } from "./format.js";
|
|
43
46
|
import { cancelLineText } from "./timing.js";
|
|
44
47
|
import { keepFollowing } from "./scroll.js";
|
|
45
48
|
import { shownElsewhere } from "./card.js";
|
|
@@ -60,7 +63,17 @@ export function createStreamHandlers(deps) {
|
|
|
60
63
|
appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
|
|
61
64
|
commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
|
|
62
65
|
showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies, syncModelPick,
|
|
66
|
+
gapClock = null, addTimeGap = () => null,
|
|
63
67
|
} = deps;
|
|
68
|
+
// Activity starts (a turn, a reminder, a note) at +time+ (the event's;
|
|
69
|
+
// none: now): a gap's divider above +before+ (else at the end) when a
|
|
70
|
+
// quiet stretch passed since the last activity.
|
|
71
|
+
function gapBefore(time, before = null) {
|
|
72
|
+
const text = gapClock?.start(time);
|
|
73
|
+
if (text) addTimeGap(text, { before });
|
|
74
|
+
}
|
|
75
|
+
// Activity ended (a turn's end): the next gap counts from now.
|
|
76
|
+
const activityEnded = () => gapClock?.end(null);
|
|
64
77
|
|
|
65
78
|
return {
|
|
66
79
|
generation_chunk: (data) => {
|
|
@@ -137,6 +150,12 @@ export function createStreamHandlers(deps) {
|
|
|
137
150
|
markRecapStale();
|
|
138
151
|
shownTexts.turnStarted();
|
|
139
152
|
const promptBubble = applyPromptOps(data);
|
|
153
|
+
// The turn's wall time (a replay's: its start): the divider above its
|
|
154
|
+
// prompt, or where the turn goes when it has none; its bubble's hover.
|
|
155
|
+
const startedAt = data.at || data.started_at || null;
|
|
156
|
+
gapBefore(startedAt, promptBubble);
|
|
157
|
+
const when = fullTime(startedAt);
|
|
158
|
+
if (when && promptBubble?.setAttribute && !promptBubble.title) promptBubble.title = when;
|
|
140
159
|
liveTurn.cutAfter = data.cut_after ?? null;
|
|
141
160
|
liveTurn.serverGen = null;
|
|
142
161
|
// A participant's review turn: its id and author, for Apply at its end
|
|
@@ -152,6 +171,7 @@ export function createStreamHandlers(deps) {
|
|
|
152
171
|
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
153
172
|
}),
|
|
154
173
|
turn_completed: async (data) => {
|
|
174
|
+
activityEnded();
|
|
155
175
|
// A participant's turn: their spend and turns in the panel moved.
|
|
156
176
|
if (data.origin?.author) participantsControl?.reload();
|
|
157
177
|
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
@@ -196,6 +216,7 @@ export function createStreamHandlers(deps) {
|
|
|
196
216
|
}
|
|
197
217
|
},
|
|
198
218
|
turn_canceled: (data) => {
|
|
219
|
+
activityEnded();
|
|
199
220
|
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
200
221
|
setLiveStatus("idle");
|
|
201
222
|
refreshTimingFromSession();
|
|
@@ -205,6 +226,7 @@ export function createStreamHandlers(deps) {
|
|
|
205
226
|
// (prompt_restored), and the prompts queued behind it run as usual. A turn
|
|
206
227
|
// that got to tool steps stays as it is (kept_steps; no prompt_restored).
|
|
207
228
|
turn_failed: (data) => {
|
|
229
|
+
activityEnded();
|
|
208
230
|
const text = failedTurnText(data);
|
|
209
231
|
// Only a rolled-back turn's line waits for its prompt_restored.
|
|
210
232
|
liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
|
|
@@ -314,10 +336,16 @@ export function createStreamHandlers(deps) {
|
|
|
314
336
|
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
315
337
|
},
|
|
316
338
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
317
|
-
reminder_injected: (data) =>
|
|
339
|
+
reminder_injected: (data) => {
|
|
340
|
+
gapBefore(data.at);
|
|
341
|
+
addCommandBubble({ label: null, line: reminderText(data), text: "", at: data.at });
|
|
342
|
+
},
|
|
318
343
|
// A context note joined the conversation (between turns; no turn runs).
|
|
344
|
+
// A note is activity of its own: it starts nothing, so it ends there too.
|
|
319
345
|
context_added: (data) => {
|
|
320
|
-
|
|
346
|
+
const at = data.at || data.created_at || null;
|
|
347
|
+
gapBefore(at);
|
|
348
|
+
addNoteBubble({ label: data.label, content: data.text || "", at });
|
|
321
349
|
// An attached context source's note: its chip changed.
|
|
322
350
|
if (data.context_source) contextChips.refresh();
|
|
323
351
|
},
|
|
@@ -385,7 +413,7 @@ export function createStreamHandlers(deps) {
|
|
|
385
413
|
recap_ready: (data) => {
|
|
386
414
|
// After the turn it covers, never above it.
|
|
387
415
|
turnDom.handOff?.({ immediate: true });
|
|
388
|
-
if (!liveTurn.running) showRecap(data.recap);
|
|
416
|
+
if (!liveTurn.running) showRecap(data.recap, 0, data.created_at || null);
|
|
389
417
|
},
|
|
390
418
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
391
419
|
// A notice during a turn is a row of its current step. One the page
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The history's gap dividers: one dim line ("── 14:32 · 47 min later ──",
|
|
2
|
+
// format.js gapText) where a quiet stretch of GAP_SECONDS or more passed
|
|
3
|
+
// between the end of one activity and the start of the next (a turn, a
|
|
4
|
+
// note, a reminder). Nothing during active work: a long turn followed at
|
|
5
|
+
// once by another has none (measured end to start).
|
|
6
|
+
//
|
|
7
|
+
// The times: a history entry's `at` (MessageTime, the server forwards it),
|
|
8
|
+
// else for a turn its record's started_at and started_at + duration_ms
|
|
9
|
+
// (sessions saved before stamps). Live: the events' at / created_at, else
|
|
10
|
+
// the page's clock (createGapClock).
|
|
11
|
+
//
|
|
12
|
+
// The divider is its own element (.time-gap), never a .bubble or a
|
|
13
|
+
// .turn-work: code that walks those (turn prompts, recap placement, the
|
|
14
|
+
// stage's hand-off) never sees it.
|
|
15
|
+
import { html } from "./vendor/lit-html.js";
|
|
16
|
+
import { elementOf } from "./turn_html.js";
|
|
17
|
+
import { gapText, timeOf } from "./format.js";
|
|
18
|
+
|
|
19
|
+
export const TIME_GAP_CLASS = "time-gap";
|
|
20
|
+
|
|
21
|
+
export function timeGapTemplate(text) {
|
|
22
|
+
return html`<div class=${TIME_GAP_CLASS} role="separator">${text}</div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function timeGapEl(text) {
|
|
26
|
+
return elementOf(timeGapTemplate(text));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The history items a group (timing.js turnGroups) holds, by index.
|
|
30
|
+
function groupIndexes(group) {
|
|
31
|
+
if (group.kind === "note" || group.kind === "steer") return [group.i];
|
|
32
|
+
if (group.kind === "answer") return [group.answer];
|
|
33
|
+
const steps = group.steps || [];
|
|
34
|
+
return [
|
|
35
|
+
group.user, ...steps.map((step) => step.i), ...steps.flatMap((step) => step.steers || []),
|
|
36
|
+
...(group.steers || []), ...(group.merged || []), group.answer, group.emptyAnswer,
|
|
37
|
+
].filter((i) => Number.isInteger(i));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A group's start and end (epoch ms, null when unknown): its first stamped
|
|
41
|
+
// entry's time, else its turn record's start; its last activity: the later
|
|
42
|
+
// of its last stamped entry and its record's end (a turn's tool calls and
|
|
43
|
+
// hooks run after its last message).
|
|
44
|
+
export function groupTimes(group, items) {
|
|
45
|
+
const stamps = groupIndexes(group).map((i) => timeOf(items[i]?.at)?.getTime()).filter(Number.isFinite);
|
|
46
|
+
const record = group.record || null;
|
|
47
|
+
const recordStart = timeOf(record?.started_at)?.getTime();
|
|
48
|
+
const duration = Number(record?.duration_ms);
|
|
49
|
+
const recordEnd = Number.isFinite(recordStart) && Number.isFinite(duration) ? recordStart + duration : NaN;
|
|
50
|
+
const start = stamps.length ? Math.min(...stamps) : Number.isFinite(recordStart) ? recordStart : null;
|
|
51
|
+
const ends = [...stamps, recordEnd].filter(Number.isFinite);
|
|
52
|
+
return { start, end: ends.length ? Math.max(...ends) : start };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// For each group, the divider text before it, or null. A group with no
|
|
56
|
+
// known time draws none and hides the gap across it (no divider measured
|
|
57
|
+
// over a stretch whose times are unknown).
|
|
58
|
+
export function groupGaps(groups, items, now = Date.now()) {
|
|
59
|
+
let prevEnd = null;
|
|
60
|
+
return groups.map((group) => {
|
|
61
|
+
const { start, end } = groupTimes(group, items);
|
|
62
|
+
const text = prevEnd !== null && start !== null ? gapText(prevEnd, start, now) : null;
|
|
63
|
+
prevEnd = end;
|
|
64
|
+
return text;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The last activity the history shows (epoch ms), or null: the live
|
|
69
|
+
// dividers' starting point.
|
|
70
|
+
export function lastActivity(groups, items) {
|
|
71
|
+
for (let k = groups.length - 1; k >= 0; k -= 1) {
|
|
72
|
+
const { end } = groupTimes(groups[k], items);
|
|
73
|
+
if (end !== null) return end;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The live page's last activity: seeded from the drawn history, moved at
|
|
79
|
+
// every start and end of activity. +now+: the page's clock (epoch ms).
|
|
80
|
+
export function createGapClock(now = () => Date.now()) {
|
|
81
|
+
let last = null;
|
|
82
|
+
const at = (time) => timeOf(time)?.getTime() ?? now();
|
|
83
|
+
return {
|
|
84
|
+
// The history was drawn (a render, a resync): its last activity.
|
|
85
|
+
seed(time) { last = timeOf(time)?.getTime() ?? null; },
|
|
86
|
+
// Activity starts at +time+ (an event's at; none: now): the divider's
|
|
87
|
+
// text when a gap passed since the last, else null.
|
|
88
|
+
start(time) {
|
|
89
|
+
const t = at(time);
|
|
90
|
+
const text = last === null ? null : gapText(last, t, now());
|
|
91
|
+
last = Math.max(last ?? t, t);
|
|
92
|
+
return text;
|
|
93
|
+
},
|
|
94
|
+
// Activity ended (a turn's end): the next gap counts from here.
|
|
95
|
+
end(time) { last = at(time); },
|
|
96
|
+
get last() { return last; },
|
|
97
|
+
};
|
|
98
|
+
}
|