samagotchi 0.109.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +1 -1
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +97 -3
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +6 -3
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/plugins.md +1 -1
  11. data/docs/sessions.md +3 -1
  12. data/docs/sub-agent.md +1 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/context_note.rb +7 -3
  16. data/lib/samagotchi/engine.rb +20 -9
  17. data/lib/samagotchi/idle_recap.rb +1 -1
  18. data/lib/samagotchi/idle_reminders.rb +1 -1
  19. data/lib/samagotchi/kernel_loop.rb +12 -7
  20. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  21. data/lib/samagotchi/message_time.rb +104 -0
  22. data/lib/samagotchi/reminder_queue.rb +20 -3
  23. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  24. data/lib/samagotchi/run_options.rb +4 -1
  25. data/lib/samagotchi/session_access/contract.rb +1 -1
  26. data/lib/samagotchi/session_access/local.rb +11 -5
  27. data/lib/samagotchi/session_inbox.rb +7 -3
  28. data/lib/samagotchi/steer.rb +5 -3
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  30. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  32. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  33. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  34. data/lib/samagotchi/terminal_ui.rb +40 -5
  35. data/lib/samagotchi/tool_activity.rb +11 -0
  36. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +15 -6
  39. data/lib/samagotchi/web/participant_view.rb +12 -5
  40. data/lib/samagotchi/web/public/app.js +11 -3
  41. data/lib/samagotchi/web/public/bubbles.js +29 -7
  42. data/lib/samagotchi/web/public/format.js +72 -6
  43. data/lib/samagotchi/web/public/index.html +2 -0
  44. data/lib/samagotchi/web/public/participant.html +2 -0
  45. data/lib/samagotchi/web/public/participant.js +30 -7
  46. data/lib/samagotchi/web/public/participant_model.js +25 -2
  47. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  48. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  49. data/lib/samagotchi/web/public/tokens.css +2 -2
  50. data/lib/samagotchi/web/public/turn_events.js +3 -1
  51. data/lib/samagotchi/web/public/turn_html.js +15 -8
  52. data/lib/samagotchi/web/public/turn_view.js +8 -3
  53. data/lib/samagotchi/web/running.rb +39 -4
  54. data/lib/samagotchi/web/server.rb +87 -31
  55. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  56. data/lib/samagotchi/web/tunnel.rb +133 -0
  57. metadata +6 -1
@@ -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,9 @@
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";
12
13
  import { createAuthLost, lostAnswer } from "./auth_lost.js";
13
14
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
14
15
 
@@ -241,6 +242,9 @@ function setView(view) {
241
242
  function messageNode(message) {
242
243
  const user = message.role === "user";
243
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;
244
248
  if (user) {
245
249
  node.style.setProperty("--author-color", authorColor(message.author));
246
250
  const who = el("div", "p-who");
@@ -283,16 +287,35 @@ function toolsNode(rows, open = null) {
283
287
  return details;
284
288
  }
285
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
+
286
299
  function renderThread() {
287
300
  const thread = $("thread");
288
- 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) => {
289
304
  const sig = JSON.stringify(message);
290
305
  const old = drawnMessages[at];
291
- if (old?.sig === sig) return old;
292
- 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) };
293
309
  });
294
- const nodes = drawnMessages.map((entry) => entry.node);
295
- 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
+ }
296
319
  else if (data.waiting_for_owner) nodes.push(el("div", "p-waiting", "Waiting for the session's owner"));
297
320
  if (nodes.length === 0) nodes.push(el("div", "p-empty", "Nothing here yet. Comments and replies show here."));
298
321
  const main = $("main");
@@ -326,7 +349,7 @@ function currentTurnNodes() {
326
349
  const nodes = [];
327
350
  if (turn.prompt !== undefined || turn.automatic) {
328
351
  nodes.push(messageNode({ role: "user", content: turn.automatic ? "An automatic turn" : turn.prompt, author: turn.author,
329
- 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 }));
330
353
  }
331
354
  const reply = el("div", "p-msg reply live");
332
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
+ }
@@ -3,10 +3,10 @@
3
3
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
4
4
  --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
5
5
  --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
6
- --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3)}
6
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
7
7
  /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
8
8
  @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;--pick-border:#0969da;
9
9
  --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
10
10
  --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
11
11
  --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
12
- --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3)}}
12
+ --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3);--time-gap:var(--text-faint)}}
@@ -340,7 +340,9 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
340
340
  const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
341
341
  // A participant's turn names them (TurnAccumulator keeps author, author_name).
342
342
  const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}) } : {};
343
- events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...cutAfter, ...author }, turn.images));
343
+ // Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
344
+ const at = typeof turn.at === "string" ? { at: turn.at } : {};
345
+ events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...author }, turn.images));
344
346
  let textIteration = null; // a text bubble is open for this iteration
345
347
  const closeText = () => {
346
348
  if (textIteration !== null) events.push({ type: "generation_completed" });
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
8
8
  import { commandBlockTemplate, rowHover } from "./command_view.js";
9
9
  import { copySource } from "./copy.js";
10
10
  import { activityDiffTemplate } from "./diff_view.js";
11
- import { clip, steerSender } from "./format.js";
11
+ import { clip, fullTime, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
14
  import { handedOverId } from "./task_stop.js";
@@ -219,9 +219,15 @@ export function shellCommandMessage(content) {
219
219
  // A `!cmd`'s history bubble: the same markup the live page draws for a
220
220
  // command (app.js's drawCommandBubble): the line, the output in a
221
221
  // pre.command-output.
222
- export function commandBubbleTemplate({ line, text }) {
222
+ export function commandBubbleTemplate({ line, text, at = null }) {
223
223
  const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
224
- return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
224
+ return html`<div class="bubble command" title=${timeTitle(at)}><div class="command-line">${line}</div>${body}</div>`;
225
+ }
226
+
227
+ // A bubble's hover: when it happened (its message's `at`, MessageTime), in
228
+ // full local time; no title when unknown.
229
+ export function timeTitle(at) {
230
+ return fullTime(at) || nothing;
225
231
  }
226
232
 
227
233
  // A user message's body: runs of `>` lines become <blockquote>s, the rest
@@ -257,9 +263,10 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
257
263
  const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
258
264
  const answerAt = at ?? nothing;
259
265
  const source = rendered || user ? copySource(message) : nothing;
260
- if (rendered) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
261
- if (user) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
262
- return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
266
+ const title = timeTitle(message?.at);
267
+ if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
268
+ if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
269
+ return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
263
270
  }
264
271
 
265
272
  // A context note's bubble (background the user or another session pushed
@@ -267,7 +274,7 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
267
274
  // text.
268
275
  export function noteBubbleTemplate(note) {
269
276
  const from = note?.label ? `note from ${note.label}` : "note";
270
- return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
277
+ return html`<div class="bubble note" title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
271
278
  }
272
279
 
273
280
  // A plugin's steer (a nudge put into the running turn): one line that
@@ -295,7 +302,7 @@ export function userBubbleTemplate(message, { label = null, state = null, step =
295
302
  const from = author
296
303
  ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
297
304
  : label ? html`<span class="origin-label">${label}</span>` : nothing;
298
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
305
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
299
306
  }
300
307
 
301
308
  // The notice of a turn that ended with no answer (the server's
@@ -42,6 +42,7 @@ import { createHold } from "./hold.js";
42
42
  import { linkedLine } from "./notice_links.js";
43
43
  import { taskCommand, taskStopButton } from "./task_stop.js";
44
44
  import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
45
+ import { groupGaps, timeGapTemplate } from "./time_gaps.js";
45
46
 
46
47
  // ---- The templates: one step, its thinking, its label ----
47
48
 
@@ -847,7 +848,7 @@ function failedLineTemplate(record) {
847
848
  // web.working_set, never the turn view's): each block stamped with its
848
849
  // turn's ordinal and its rows with theirs (working_set_view.js), and the
849
850
  // turn's footprint line after it.
850
- export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
851
+ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now() }) {
851
852
  const timingTemplate = (group) => {
852
853
  const record = group.record;
853
854
  // Numbered by its place in the turn records, as live (a failed turn
@@ -868,7 +869,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
868
869
  // as its command bubble, as live does — not as a user bubble.
869
870
  const userTemplate = (m, opts = {}) => {
870
871
  const shell = shellCommandMessage(m?.content);
871
- if (shell) return commandBubbleTemplate(shell);
872
+ if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
872
873
  return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
873
874
  };
874
875
  // The first text after a delegate report merged mid-turn, unless it is
@@ -886,7 +887,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
886
887
  const outlines = [];
887
888
  const blockTurns = [];
888
889
  let turnOrdinal = -1;
889
- for (const group of turnGroups(items, timing)) {
890
+ const groups = turnGroups(items, timing);
891
+ // A quiet stretch before a group: its divider first (time_gaps.js).
892
+ const gaps = groupGaps(groups, items, now);
893
+ for (const [k, group] of groups.entries()) {
894
+ if (gaps[k]) parts.push(timeGapTemplate(gaps[k]));
890
895
  if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
891
896
  if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
892
897
  if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
@@ -4,18 +4,23 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../session"
7
+ require_relative "info_probe"
8
+ require_relative "public_url"
7
9
 
8
10
  module Samagotchi
9
11
  module Web
10
12
  # Where this machine's chi web listens: $XDG_STATE_HOME/samagotchi/web.json
11
- # ({pid, port}), written once its port is bound and removed when it
13
+ # ({pid, port}, and public_url when it runs with one: config's or
14
+ # --tunnel's), written once its port is bound and removed when it
12
15
  # stops (by that process only). A command that names chi web in what it
13
16
  # prints (chi share's link) reads the port here before config's
14
17
  # web.port, which a chi web started with --port or another config
15
18
  # doesn't follow. The file is a hint: a chi web killed hard leaves it,
16
- # so a reader probes the port (InfoProbe) before trusting it.
19
+ # so a reader probes the port (InfoProbe) before trusting it. One slot:
20
+ # a second chi web on another port takes it over.
17
21
  module Running
18
22
  FILE = "web.json"
23
+ PROBE_TIMEOUT = 1.0
19
24
 
20
25
  module_function
21
26
 
@@ -24,9 +29,10 @@ module Samagotchi
24
29
  end
25
30
 
26
31
  # @return [String] +path+
27
- def record(port:, path: self.path, pid: Process.pid)
32
+ # @param public_url [String, nil] the https origin it runs behind
33
+ def record(port:, path: self.path, pid: Process.pid, public_url: nil)
28
34
  FileUtils.mkdir_p(File.dirname(path))
29
- AtomicFile.write(path, JSON.generate({ pid: pid, port: port }))
35
+ AtomicFile.write(path, JSON.generate({ pid: pid, port: port, public_url: public_url }.compact))
30
36
  end
31
37
 
32
38
  # Removes the file when this process wrote it (a second chi web's
@@ -41,6 +47,35 @@ module Samagotchi
41
47
  value.is_a?(Integer) && value.positive? ? value : nil
42
48
  end
43
49
 
50
+ # The public_url the running chi web recorded (a --tunnel's, which no
51
+ # config has), for a link that names it: only while the chi web that
52
+ # answers /api/info on the recorded port is the process that wrote the
53
+ # file (its pid), and only an https origin. A record left by a chi web
54
+ # killed hard, with a reused pid or another chi web (another state dir)
55
+ # on that port, is nil. The origin is this file's, never the probe's.
56
+ # @param probe [#call] (port) → InfoProbe's answer
57
+ # @return [PublicUrl, nil]
58
+ def live_public_url(path: self.path, probe: ->(port) { InfoProbe.call("127.0.0.1", port, timeout: PROBE_TIMEOUT) })
59
+ data = read(path) or return nil
60
+ pid, port, url = data.values_at("pid", "port", "public_url")
61
+ return nil unless pid.is_a?(Integer) && pid.positive? && port.is_a?(Integer) && port.positive? && url.is_a?(String)
62
+ return nil unless alive?(pid)
63
+
64
+ info = probe.call(port)
65
+ return nil unless info.is_a?(Hash) && info["pid"] == pid
66
+
67
+ PublicUrl.from_config(url: url)
68
+ rescue PublicUrl::Invalid
69
+ nil
70
+ end
71
+
72
+ def alive?(pid)
73
+ Process.kill(0, pid)
74
+ true
75
+ rescue Errno::ESRCH, Errno::EPERM
76
+ false
77
+ end
78
+
44
79
  def read(path)
45
80
  data = JSON.parse(File.read(path))
46
81
  data.is_a?(Hash) ? data : nil