samagotchi 0.20.0 → 0.22.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 +77 -1
- data/docs/architecture.md +5 -3
- data/docs/cli.md +2 -1
- data/docs/configuration.md +11 -0
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +11 -5
- data/lib/samagotchi/answer_command.rb +7 -3
- 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/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/continue_offer.rb +34 -9
- data/lib/samagotchi/engine.rb +47 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/errors.rb +31 -0
- data/lib/samagotchi/llm/openai_chat.rb +33 -7
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/question_desk.rb +1 -7
- data/lib/samagotchi/reply_wait.rb +12 -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/turn_state.rb +22 -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 +2 -1
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
46
46
|
} from "./timing.js";
|
|
47
47
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
48
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
|
+
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
49
49
|
|
|
50
50
|
const $ = (s) => document.querySelector(s);
|
|
51
51
|
const topStripEl = $("#topStrip");
|
|
@@ -134,6 +134,9 @@ const questionCards = createQuestionCards({
|
|
|
134
134
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
135
135
|
// has to start.
|
|
136
136
|
let selectSeq = null;
|
|
137
|
+
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
138
|
+
// new turn has started on it yet: see followWokenWorker.
|
|
139
|
+
let streamFromGuess = false;
|
|
137
140
|
// The last input_merged had an origin with no bubble: its merged text
|
|
138
141
|
// (pending_input_merged) gets one.
|
|
139
142
|
let unmatchedMerge = false;
|
|
@@ -165,6 +168,7 @@ let scopeError = null;
|
|
|
165
168
|
async function refresh() {
|
|
166
169
|
try {
|
|
167
170
|
allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
|
|
171
|
+
notifications.noticeList(allSessions);
|
|
168
172
|
} catch (e) {
|
|
169
173
|
if (e.status !== 400 || !scope.dir) throw e;
|
|
170
174
|
scopeError = e.message;
|
|
@@ -1341,7 +1345,7 @@ function onCardAction(e) {
|
|
|
1341
1345
|
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1342
1346
|
.then(() => {
|
|
1343
1347
|
// The command woke the worker: follow its stream if none is open.
|
|
1344
|
-
|
|
1348
|
+
followWokenWorker();
|
|
1345
1349
|
})
|
|
1346
1350
|
.catch((error) => {
|
|
1347
1351
|
err.textContent = error.message;
|
|
@@ -1416,6 +1420,7 @@ function updateInfoBar() {
|
|
|
1416
1420
|
archiveBtn.style.display = selected ? "" : "none";
|
|
1417
1421
|
const archived = selectedArchived();
|
|
1418
1422
|
archiveBtn.textContent = archived ? "unarchive" : "archive";
|
|
1423
|
+
archiveBtn.classList.toggle("archived", archived);
|
|
1419
1424
|
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1420
1425
|
}
|
|
1421
1426
|
if (!selected) {
|
|
@@ -1593,7 +1598,18 @@ function setUsedMemories(names) {
|
|
|
1593
1598
|
}
|
|
1594
1599
|
}
|
|
1595
1600
|
|
|
1601
|
+
// A turn or command just woke the session's worker: follow its stream if
|
|
1602
|
+
// none is open, from the render's cursor. A render with none (no worker
|
|
1603
|
+
// then) streams from 0, the start of a new worker's events; should that
|
|
1604
|
+
// worker have been up already, turn_started says so (replaysDrawnTurn).
|
|
1605
|
+
function followWokenWorker() {
|
|
1606
|
+
if (streamControl?.live) return;
|
|
1607
|
+
startStream(selected, selectSeq ?? 0);
|
|
1608
|
+
streamFromGuess = selectSeq === null;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1596
1611
|
function startStream(id, fromSeq) {
|
|
1612
|
+
streamFromGuess = false;
|
|
1597
1613
|
if (streamControl) {
|
|
1598
1614
|
streamControl.close();
|
|
1599
1615
|
streamControl = null;
|
|
@@ -1675,7 +1691,8 @@ const streamHandlers = {
|
|
|
1675
1691
|
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
1676
1692
|
// input_merged names them (their bubbles turn "steered"), then
|
|
1677
1693
|
// pending_input_merged carries the merged text, shown only for prompts
|
|
1678
|
-
// this tab has no bubble for.
|
|
1694
|
+
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
1695
|
+
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
1679
1696
|
input_merged: (data) => applyPromptOps(data),
|
|
1680
1697
|
pending_input_merged: (data) => {
|
|
1681
1698
|
applyPromptOps(data);
|
|
@@ -1683,6 +1700,16 @@ const streamHandlers = {
|
|
|
1683
1700
|
},
|
|
1684
1701
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1685
1702
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1703
|
+
if (streamFromGuess) {
|
|
1704
|
+
streamFromGuess = false;
|
|
1705
|
+
// A replay of a turn the render drew: re-read and stream from there.
|
|
1706
|
+
if (replaysDrawnTurn(data, current.timing.turnRecords)) {
|
|
1707
|
+
streamControl?.close();
|
|
1708
|
+
streamControl = null;
|
|
1709
|
+
resync();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1686
1713
|
// A finished turn still in the stage goes first: the start op looks
|
|
1687
1714
|
// for a queued prompt among its rows.
|
|
1688
1715
|
turnDom.handOff?.({ immediate: true });
|
|
@@ -1729,7 +1756,7 @@ const streamHandlers = {
|
|
|
1729
1756
|
}
|
|
1730
1757
|
},
|
|
1731
1758
|
turn_canceled: (data) => {
|
|
1732
|
-
endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
|
|
1759
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
|
|
1733
1760
|
setLiveStatus("idle");
|
|
1734
1761
|
refreshTimingFromSession();
|
|
1735
1762
|
},
|
|
@@ -1965,7 +1992,7 @@ async function handleSendTurn() {
|
|
|
1965
1992
|
echo.remove();
|
|
1966
1993
|
promptEl.value = "";
|
|
1967
1994
|
fitPrompt();
|
|
1968
|
-
|
|
1995
|
+
followWokenWorker();
|
|
1969
1996
|
return;
|
|
1970
1997
|
}
|
|
1971
1998
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
@@ -1987,7 +2014,7 @@ async function handleSendTurn() {
|
|
|
1987
2014
|
// events in between. Without one, POST /turn just woke a worker: its
|
|
1988
2015
|
// events start after the seq the session was rendered at (0 for a new
|
|
1989
2016
|
// worker process).
|
|
1990
|
-
|
|
2017
|
+
followWokenWorker();
|
|
1991
2018
|
} catch (e) {
|
|
1992
2019
|
echo.remove();
|
|
1993
2020
|
// The turn never reached the worker's queue: an early restore waiting
|
|
@@ -2028,7 +2055,7 @@ async function handleSendCommand(line) {
|
|
|
2028
2055
|
fitPrompt();
|
|
2029
2056
|
refreshHistory();
|
|
2030
2057
|
// The command woke the worker: follow its stream if none is open.
|
|
2031
|
-
|
|
2058
|
+
followWokenWorker();
|
|
2032
2059
|
} catch (e) {
|
|
2033
2060
|
if (/owned_by_tui|409/.test(e.message)) {
|
|
2034
2061
|
current.owner = "tui";
|
|
@@ -606,6 +606,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
606
606
|
#infoBar .meta .mem-chip{display:inline;order:-1}
|
|
607
607
|
#infoBar .meta .dir{word-break:normal}
|
|
608
608
|
#infoBar button{padding:4px 8px}
|
|
609
|
+
/* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
|
|
610
|
+
#infoBar>button{font-size:0;line-height:0;padding:5px 7px}
|
|
611
|
+
#infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
612
|
+
#infoBar #infoArchiveBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E")}
|
|
613
|
+
#infoBar #infoArchiveBtn.archived{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
|
|
614
|
+
#infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
|
|
615
|
+
#infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
|
|
609
616
|
}
|
|
610
617
|
#empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
|
|
611
618
|
#empty.hidden{display:none !important}
|
|
@@ -35,7 +35,7 @@ function defaultChannel() {
|
|
|
35
35
|
// @param storage () => localStorage (it can throw)
|
|
36
36
|
// @param channel the BroadcastChannel to the other chi tabs, or null
|
|
37
37
|
// @param gateOptions passed to createNotifyGate (specs: schedule, now)
|
|
38
|
-
// @return {noticeSessions(type, data), setBaseTitle(title), clearBadge()}
|
|
38
|
+
// @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
|
|
39
39
|
export function createNotifications({
|
|
40
40
|
button,
|
|
41
41
|
doc = globalThis.document,
|
|
@@ -125,11 +125,12 @@ export function createNotifications({
|
|
|
125
125
|
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
126
126
|
} else {
|
|
127
127
|
for (const a of next.attentions) {
|
|
128
|
-
|
|
128
|
+
// Every tab behind badges; one of them notifies (notify.js).
|
|
129
|
+
notifyGate.offer(a, (notify) => {
|
|
129
130
|
if (tabInFront()) return;
|
|
130
131
|
badgeKeys.add(a.key);
|
|
131
132
|
setTitle();
|
|
132
|
-
showNotification(attention.byId[a.sessionId], a);
|
|
133
|
+
if (notify) showNotification(attention.byId[a.sessionId], a);
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
}
|
|
@@ -159,6 +160,10 @@ export function createNotifications({
|
|
|
159
160
|
|
|
160
161
|
return {
|
|
161
162
|
noticeSessions,
|
|
163
|
+
// The list fetched without a hub (app.js refresh): a snapshot.
|
|
164
|
+
noticeList(sessions) {
|
|
165
|
+
noticeSessions("snapshot", { sessions });
|
|
166
|
+
},
|
|
162
167
|
// The title without the badge ("Chi · <project>").
|
|
163
168
|
setBaseTitle(title) {
|
|
164
169
|
baseTitle = title;
|
|
@@ -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":
|
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.
|
|
4
|
+
version: 0.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -270,6 +270,7 @@ files:
|
|
|
270
270
|
- lib/samagotchi/llm/native_backend.rb
|
|
271
271
|
- lib/samagotchi/llm/native_tool_normalizer.rb
|
|
272
272
|
- lib/samagotchi/llm/openai_chat.rb
|
|
273
|
+
- lib/samagotchi/llm/prompt_cache.rb
|
|
273
274
|
- lib/samagotchi/llm/turn_settings.rb
|
|
274
275
|
- lib/samagotchi/llm/usage.rb
|
|
275
276
|
- lib/samagotchi/llm/utf8_scrub.rb
|