samagotchi 0.20.0 → 0.21.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +2 -1
  5. data/docs/plugins.md +22 -8
  6. data/docs/sessions.md +2 -2
  7. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  9. data/lib/samagotchi/bridge.rb +16 -4
  10. data/lib/samagotchi/bridge_client.rb +33 -9
  11. data/lib/samagotchi/bundle_command.rb +6 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +15 -2
  16. data/lib/samagotchi/events.rb +1 -1
  17. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  18. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  19. data/lib/samagotchi/log_subscriber.rb +4 -2
  20. data/lib/samagotchi/session_manager.rb +26 -9
  21. data/lib/samagotchi/session_metrics.rb +6 -4
  22. data/lib/samagotchi/steer.rb +1 -1
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  24. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  25. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  26. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  27. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  28. data/lib/samagotchi/version.rb +1 -1
  29. data/lib/samagotchi/web/public/app.js +34 -7
  30. data/lib/samagotchi/web/public/index.html +7 -0
  31. data/lib/samagotchi/web/public/notifications.js +8 -3
  32. data/lib/samagotchi/web/public/notify.js +49 -7
  33. data/lib/samagotchi/web/public/timing.js +5 -3
  34. data/lib/samagotchi/web/public/turn_events.js +16 -0
  35. metadata +1 -1
@@ -119,7 +119,16 @@ export function trackAttention(state, type, data = {}) {
119
119
  byId[s.id] = s;
120
120
  };
121
121
  if (type === "snapshot") {
122
- (Array.isArray(data.sessions) ? data.sessions : []).forEach(see);
122
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
123
+ sessions.forEach(see);
124
+ // The whole list: a session missing from it went while no frame said so
125
+ // (the hub away, or between two fetches), as if session_gone came.
126
+ const present = new Set(sessions.map((s) => s?.id));
127
+ for (const id of Object.keys(byId)) {
128
+ if (present.has(id)) continue;
129
+ close(byId[id], null);
130
+ delete byId[id];
131
+ }
123
132
  return { state: { seeded: true, byId }, attentions, closed };
124
133
  }
125
134
  if (type === "session") see(data.session);
@@ -152,7 +161,10 @@ export function attentionText(session, attention) {
152
161
  // (BroadcastChannel "chi-notify") which attention keys it has in front, and
153
162
  // a tab behind neither notifies nor badges those: it holds a new attention
154
163
  // for +holdMs+ in case the front tab's word is on its way, and drops a key
155
- // from its badge when the word comes later. Without BroadcastChannel every
164
+ // from its badge when the word comes later. Every tab behind badges its
165
+ // title, but only one shows the OS notification: each tab behind claims
166
+ // the key on the channel as it holds it, and at the end of the hold the
167
+ // lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
156
168
  // tab goes on alone, at once.
157
169
  export const NOTIFY_CHANNEL = "chi-notify";
158
170
  export const NOTIFY_HOLD_MS = 300;
@@ -162,16 +174,31 @@ const SEEN_TTL_MS = 10 * 60 * 1000;
162
174
  // @param channel a BroadcastChannel (postMessage, onmessage), or null
163
175
  // @param onSeen(keys) another tab has these keys in front (drop them from
164
176
  // the badge)
177
+ // @param id this tab's claim (random by default)
165
178
  // @return {seenInFront(keys), offer(attention, deliver), drop(keys)}
166
179
  export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSeen = () => {},
167
- schedule = setTimeout, cancel = clearTimeout, now = Date.now } = {}) {
180
+ schedule = setTimeout, cancel = clearTimeout, now = Date.now, id = randomTabId() } = {}) {
168
181
  const seen = new Map(); // key → when
169
182
  const pending = new Map(); // key → timer
183
+ const claims = new Map(); // key → {ids: the other tabs' claims, at}
170
184
  const mark = (keys) => {
171
185
  const at = now();
172
186
  for (const [key, when] of seen) if (at - when > SEEN_TTL_MS) seen.delete(key);
173
187
  for (const key of keys) seen.set(key, at);
174
188
  };
189
+ const claimed = (key, tab) => {
190
+ const at = now();
191
+ for (const [k, claim] of claims) if (at - claim.at > SEEN_TTL_MS) claims.delete(k);
192
+ const claim = claims.get(key) || { ids: new Set(), at };
193
+ claim.ids.add(tab);
194
+ claims.set(key, claim);
195
+ };
196
+ // This tab's claim is the lowest: it shows the OS notification.
197
+ const won = (key) => {
198
+ const others = claims.get(key)?.ids || new Set();
199
+ claims.delete(key);
200
+ return [...others].every((other) => id < other);
201
+ };
175
202
  const drop = (keys) => {
176
203
  for (const key of keys) {
177
204
  if (!pending.has(key)) continue;
@@ -181,6 +208,10 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
181
208
  };
182
209
  if (channel) {
183
210
  channel.onmessage = (e) => {
211
+ if (e?.data?.type === "claim" && typeof e.data.key === "string" && typeof e.data.id === "string") {
212
+ claimed(e.data.key, e.data.id);
213
+ return;
214
+ }
184
215
  const keys = e?.data?.type === "seen" && Array.isArray(e.data.keys) ? e.data.keys.map(String) : [];
185
216
  if (keys.length === 0) return;
186
217
  mark(keys);
@@ -194,21 +225,25 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
194
225
  if (!channel || keys.length === 0) return;
195
226
  try { channel.postMessage({ type: "seen", keys }); } catch (_) {}
196
227
  },
197
- // A new attention in a tab behind: +deliver+ (notify, badge) unless
198
- // another tab has it in front. @return whether it may still be delivered
228
+ // A new attention in a tab behind: +deliver(notify)+ (badge; the OS
229
+ // notification when +notify+) unless another tab has it in front.
230
+ // @return whether it may still be delivered
199
231
  // An attention's own holdMs (a delegated child's approval) holds it
200
232
  // longer, alone too.
201
233
  offer(attention, deliver) {
202
234
  const key = attention.key;
203
235
  if (seen.has(key)) return false;
204
236
  if (!channel && !attention.holdMs) {
205
- deliver();
237
+ deliver(true);
206
238
  return true;
207
239
  }
208
240
  drop([key]);
241
+ if (channel) {
242
+ try { channel.postMessage({ type: "claim", key, id }); } catch (_) {}
243
+ }
209
244
  pending.set(key, schedule(() => {
210
245
  pending.delete(key);
211
- if (!seen.has(key)) deliver();
246
+ if (!seen.has(key)) deliver(won(key));
212
247
  }, Math.max(holdMs, attention.holdMs || 0)));
213
248
  return true;
214
249
  },
@@ -216,3 +251,10 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
216
251
  drop,
217
252
  };
218
253
  }
254
+
255
+ function randomTabId() {
256
+ try {
257
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
258
+ } catch (_) {}
259
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
260
+ }
@@ -26,8 +26,10 @@ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
26
 
27
27
  // The line a canceled turn ends with: "✕ canceled (stopped)", live from
28
28
  // turn_canceled's reason and after a re-render from the turn record's
29
- // cancellation_reason (records saved before it existed: no reason).
30
- export function cancelLineText(reason) {
29
+ // cancellation_reason (records saved before it existed: no reason). A
30
+ // hook's stop that names who (+by+, cancelled_by): "✕ stopped by loop-guard".
31
+ export function cancelLineText(reason, by) {
32
+ if (reason === "hook" && by) return `\u2715 stopped by ${by}`;
31
33
  return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
32
34
  }
33
35
 
@@ -35,7 +37,7 @@ export function cancelLineText(reason) {
35
37
  // record is not a canceled one.
36
38
  export function cancelLineHtml(record, escape) {
37
39
  if (record?.status !== "canceled") return "";
38
- return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason))}</div>`;
40
+ return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
39
41
  }
40
42
 
41
43
  export function elapsedSince(startedAt, now = Date.now()) {
@@ -401,6 +401,16 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
401
401
  }
402
402
  }
403
403
 
404
+ // A stream opened with no cursor (a worker woken after a render that had
405
+ // none) replays the worker's events from its start: a turn_started for a
406
+ // turn the page drew already (+turnRecords+, the render's timing) means
407
+ // that replay overlaps the render, and the page re-reads the session
408
+ // rather than drawing the turn twice (4.01).
409
+ export function replaysDrawnTurn(event, turnRecords = []) {
410
+ if (event?.type !== "turn_started" || !event.turn_id) return false;
411
+ return turnRecords.some((record) => record.id === event.turn_id);
412
+ }
413
+
404
414
  // A session view from the server (GET /api/sessions/:id on a live worker)
405
415
  // as the live events that would have drawn it: the turn in progress (its
406
416
  // parts in order), then the prompts queued behind it. app.js feeds these to
@@ -420,6 +430,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
420
430
  };
421
431
  for (const part of turn.parts || []) {
422
432
  switch (part.kind) {
433
+ // A generation the model is on: a join during a hold (no chunk yet)
434
+ // shows its live step.
435
+ case "generation":
436
+ closeText();
437
+ events.push({ type: "generation_started", iteration: part.iteration ?? null });
438
+ break;
423
439
  // The chunks carry the part's iteration, so the turn view groups a
424
440
  // join like the live stream (turn_model.js).
425
441
  case "thinking":
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov