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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +2 -1
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +6 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +15 -2
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +6 -4
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +34 -7
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/timing.js +5 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -0
- 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
|
-
|
|
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.
|
|
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+ (
|
|
198
|
-
// another tab has it in front.
|
|
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
|
-
|
|
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":
|