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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. 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
- export function recapTemplate(text, turnsSince = 0) {
53
- return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} open><summary>${recapLabel(turnsSince)}</summary><div class="recap-body">${text}</div></details>`;
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 before = recapPlace(turnPromptBubbles(historyEl), turnsSince);
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 (frameReceived || !live || es.readyState !== 2) return;
313
+ if (!live || es.readyState !== 2) return;
294
314
  live = false;
295
315
  try { es.close(); } catch (_) {}
296
- if (typeof onError === "function") onError();
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
- // The recap bubble's label: how many turns came after it (stale: a turn
257
- // started while the page showed it, count unknown).
258
- export function recapLabel(turnsSince, stale = false) {
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
- if (n === 1) return "recap · before the last turn";
261
- if (n > 1) return `recap · before the last ${n} turns`;
262
- return stale ? "recap · earlier" : "recap";
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) showEnded(json);
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
- drawnMessages = withToolSteps(data.messages || []).map((message, at) => {
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
- if (old?.sig === sig) return old;
278
- return { sig, node: keepToolsOpen(messageNode(message), old?.node) };
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.map((entry) => entry.node);
281
- if (data.current_turn) nodes.push(...currentTurnNodes());
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
- out.push({ role: "assistant", content: "", tools: pending });
72
+ flush();
66
73
  pending = [];
74
+ pendingAt = null;
67
75
  }
68
76
  out.push(message);
69
77
  }
70
- if (pending.length) out.push({ role: "assistant", content: "", tools: pending });
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) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
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
- addNoteBubble({ label: data.label, content: data.text || "" });
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
+ }