samagotchi 0.34.0 → 0.36.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/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The open session's running turn as the page follows it, in one place:
|
|
2
|
+
// whether a turn runs, its timing line (live_timing.js), the prompt-bubble
|
|
3
|
+
// flags of its merges, and the turn-end reads it waits on. forget() is the
|
|
4
|
+
// one reset (a session left, or its rendering started over).
|
|
5
|
+
import { createLiveTiming } from "./live_timing.js";
|
|
6
|
+
|
|
7
|
+
// +timingDeps+: createLiveTiming's.
|
|
8
|
+
export function createLiveTurn(timingDeps) {
|
|
9
|
+
return {
|
|
10
|
+
running: false,
|
|
11
|
+
timing: createLiveTiming(timingDeps),
|
|
12
|
+
// The last input_merged had an origin with no bubble: its merged text
|
|
13
|
+
// (pending_input_merged) gets one ("report": a delegate report's).
|
|
14
|
+
unmatchedMerge: false,
|
|
15
|
+
// The running turn is one chi ran for delegate reports and its report
|
|
16
|
+
// bubble isn't drawn yet: that bubble starts the turn.
|
|
17
|
+
wakeStart: false,
|
|
18
|
+
// The turn-end re-read (turn_completed): the final answer's rendered
|
|
19
|
+
// markdown, the status and the timing. An answer_display re-read waits
|
|
20
|
+
// for it.
|
|
21
|
+
endRead: Promise.resolve(),
|
|
22
|
+
// The wait of a turn_completed with display_pending for its
|
|
23
|
+
// answer_display (turn_events.js displayWait). The worker may die in
|
|
24
|
+
// between and never send one, so forget() releases it as well as its
|
|
25
|
+
// own timeout: it must never hold the turn's answer forever.
|
|
26
|
+
displayPending: null,
|
|
27
|
+
// The last turn_failed's line, shown again after prompt_restored's resync.
|
|
28
|
+
failedText: null,
|
|
29
|
+
|
|
30
|
+
releaseDisplay() {
|
|
31
|
+
this.displayPending?.finish();
|
|
32
|
+
this.displayPending = null;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Everything that belongs to one turn's following, but `running` (the
|
|
36
|
+
// page's setTurnRunning redraws the composer with it).
|
|
37
|
+
forget() {
|
|
38
|
+
this.timing.drop();
|
|
39
|
+
this.unmatchedMerge = false;
|
|
40
|
+
this.wakeStart = false;
|
|
41
|
+
this.releaseDisplay();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The page's session list as a projection of the hub's events
|
|
2
2
|
// (GET /api/events): a pure reducer over the list the page holds.
|
|
3
3
|
import { waitingOn } from "./notify.js";
|
|
4
|
+
import { delegatesSummary } from "./format.js";
|
|
4
5
|
|
|
5
6
|
// @param list the current list (never mutated)
|
|
6
7
|
// @param type "snapshot" | "session" | "session_gone"
|
|
@@ -46,25 +47,87 @@ export function sortedByUpdated(list) {
|
|
|
46
47
|
return list.slice().sort((a, b) => updatedMs(b) - updatedMs(a));
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
50
|
+
// The list as families: a delegate (delegate: true, the delegate tool's
|
|
51
|
+
// child) whose parent is listed joins its parent's family, at any depth;
|
|
52
|
+
// the rest head one. A fork (a parent_id, delegate false) is not folded: it
|
|
53
|
+
// heads a family of its own, its ↳ chip naming the parent. A family is
|
|
54
|
+
// {head, members: [{session, depth}]}, the members depth first (each child
|
|
55
|
+
// followed by its own delegates, siblings in the list's order, depth 1 for
|
|
56
|
+
// the head's children). Orphans (a parent not listed) and loners are
|
|
57
|
+
// families with no members. Every session shows once: the members of a
|
|
58
|
+
// parent cycle (A→B→A) no head reaches form a family headed by the cycle's
|
|
59
|
+
// smallest id (an order held by head id never changes with updated_at).
|
|
60
|
+
// @param order "family_ms": by the family's newest member, newest first
|
|
61
|
+
// (the all view, re-sorted on every render); "list": a family takes the
|
|
62
|
+
// place of its first-listed member and nothing re-sorts (the strip, which
|
|
63
|
+
// keeps applySessionEvent's no-jump order)
|
|
64
|
+
export function families(list, { order = "family_ms" } = {}) {
|
|
54
65
|
const ids = new Set(list.map((s) => s.id));
|
|
66
|
+
const grouped = (s) => !!s.delegate && s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id;
|
|
55
67
|
const children = new Map();
|
|
56
68
|
for (const s of list) {
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
69
|
+
if (!grouped(s)) continue;
|
|
70
|
+
if (!children.has(s.parent_id)) children.set(s.parent_id, []);
|
|
71
|
+
children.get(s.parent_id).push(s);
|
|
61
72
|
}
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
73
|
+
const kids = (s) => children.get(s.id) || [];
|
|
74
|
+
const reached = new Set();
|
|
75
|
+
const reach = (s) => {
|
|
76
|
+
if (reached.has(s.id)) return;
|
|
77
|
+
reached.add(s.id);
|
|
78
|
+
kids(s).forEach(reach);
|
|
79
|
+
};
|
|
65
80
|
const heads = list.filter((s) => !grouped(s));
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
heads.forEach(reach);
|
|
82
|
+
// A session no head reaches hangs off a parent cycle: its smallest id leads.
|
|
83
|
+
const byId = new Map(list.map((s) => [s.id, s]));
|
|
84
|
+
for (const s of list) {
|
|
85
|
+
if (reached.has(s.id)) continue;
|
|
86
|
+
const path = [];
|
|
87
|
+
let at = s;
|
|
88
|
+
while (at && !path.includes(at.id) && !reached.has(at.id)) {
|
|
89
|
+
path.push(at.id);
|
|
90
|
+
at = byId.get(at.parent_id);
|
|
91
|
+
}
|
|
92
|
+
if (at && path.includes(at.id)) {
|
|
93
|
+
const head = byId.get(path.slice(path.indexOf(at.id)).sort()[0]);
|
|
94
|
+
heads.push(head);
|
|
95
|
+
reach(head);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const index = new Map(list.map((s, i) => [s.id, i]));
|
|
99
|
+
const out = heads.map((head) => {
|
|
100
|
+
const members = [];
|
|
101
|
+
const shown = new Set([head.id]);
|
|
102
|
+
const emit = (s, depth) => kids(s).forEach((c) => {
|
|
103
|
+
if (shown.has(c.id)) return;
|
|
104
|
+
shown.add(c.id);
|
|
105
|
+
members.push({ session: c, depth });
|
|
106
|
+
emit(c, depth + 1);
|
|
107
|
+
});
|
|
108
|
+
emit(head, 1);
|
|
109
|
+
return { head, members };
|
|
110
|
+
});
|
|
111
|
+
const all = (f) => [f.head, ...f.members.map((m) => m.session)];
|
|
112
|
+
if (order === "list") {
|
|
113
|
+
const first = new Map(out.map((f) => [f.head.id, Math.min(...all(f).map((s) => index.get(s.id)))]));
|
|
114
|
+
return out.sort((a, b) => first.get(a.head.id) - first.get(b.head.id));
|
|
115
|
+
}
|
|
116
|
+
const ms = new Map(out.map((f) => [f.head.id, Math.max(...all(f).map(updatedMs))]));
|
|
117
|
+
return out.sort((a, b) => ms.get(b.head.id) - ms.get(a.head.id) || index.get(a.head.id) - index.get(b.head.id));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A family's id: its head's (what an order held by id keeps).
|
|
121
|
+
export const familyId = (f) => f.head.id;
|
|
122
|
+
|
|
123
|
+
// The families' sessions, a head then its members: the all view's order.
|
|
124
|
+
export function flattenFamilies(fams) {
|
|
125
|
+
return fams.flatMap((f) => [f.head, ...f.members.map((m) => m.session)]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Whether any of a family (its head or a member) waits on the user.
|
|
129
|
+
export function familyWaits(f) {
|
|
130
|
+
return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session));
|
|
68
131
|
}
|
|
69
132
|
|
|
70
133
|
const WAITING_BADGES = {
|
|
@@ -76,13 +139,15 @@ const WAITING_BADGES = {
|
|
|
76
139
|
|
|
77
140
|
// A card's "needs you" badge: what the session waits on the user for
|
|
78
141
|
// (notify.js waitingOn), or null.
|
|
142
|
+
// @param inFamily shown as a row of its parent's family: a relayed approval
|
|
143
|
+
// says its plain kind there ("in parent" would name the card it is in)
|
|
79
144
|
// @return null | {kind, text, title}
|
|
80
|
-
export function waitingBadge(summary) {
|
|
145
|
+
export function waitingBadge(summary, { inFamily = false } = {}) {
|
|
81
146
|
const waiting = waitingOn(summary);
|
|
82
147
|
if (!waiting) return null;
|
|
83
148
|
// A delegate's approval waiting in its parent's card (the approval relay).
|
|
84
149
|
const parent = summary.pending_question?.relayed_to;
|
|
85
|
-
if (parent && waiting.reason !== "card") {
|
|
150
|
+
if (parent && !inFamily && waiting.reason !== "card") {
|
|
86
151
|
return { kind: waiting.reason, text: `in parent ${parent}`,
|
|
87
152
|
title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
|
|
88
153
|
}
|
|
@@ -114,24 +179,155 @@ export function waitingSearchText(summary) {
|
|
|
114
179
|
return parts.join(" ");
|
|
115
180
|
}
|
|
116
181
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!
|
|
127
|
-
|
|
182
|
+
// A family's chip numbers: its members at any depth, by state, and how
|
|
183
|
+
// many are live (format.js delegatesSummary, which the info bar's ⑂ chip
|
|
184
|
+
// counts with too). null for a family with no members.
|
|
185
|
+
export function familySummary(f) {
|
|
186
|
+
return delegatesSummary(f.members.map((m) => m.session));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
|
|
190
|
+
export function familyChipText(sum) {
|
|
191
|
+
if (!sum) return "";
|
|
192
|
+
const waiting = sum.counts?.waiting || 0;
|
|
193
|
+
return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
|
|
194
|
+
sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Whether a family's list is open: the user's toggle (+toggles+, head id →
|
|
198
|
+
// bool) wins; else open when a member waits on the user (the head's own
|
|
199
|
+
// wait is on its card already) or a search matched a member.
|
|
200
|
+
export function familyOpen(f, toggles, { matched = false } = {}) {
|
|
201
|
+
if (toggles?.has(f.head.id)) return !!toggles.get(f.head.id);
|
|
202
|
+
return matched || f.members.some((m) => waitingOn(m.session));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The all view's search over families: a family shows when its head or any
|
|
206
|
+
// member matches (+matches+: session → bool), with all its members (the
|
|
207
|
+
// rows that don't match give the context). matchIds: the members that do.
|
|
208
|
+
export function filterFamilies(fams, matches) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const f of fams) {
|
|
211
|
+
const matchIds = new Set(f.members.filter((m) => matches(m.session)).map((m) => m.session.id));
|
|
212
|
+
if (matchIds.size || matches(f.head)) out.push({ ...f, matchIds });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The families that wait on the user (familyWaits: any member) first, the
|
|
218
|
+
// rest after, each part in the order given: an answered one goes back to
|
|
219
|
+
// its place. The same array when none waits.
|
|
220
|
+
export function waitingFirst(fams) {
|
|
221
|
+
const waiting = fams.filter(familyWaits);
|
|
222
|
+
if (!waiting.length) return fams;
|
|
223
|
+
return [...waiting, ...fams.filter((f) => !familyWaits(f))];
|
|
128
224
|
}
|
|
129
225
|
|
|
130
226
|
// The list in an order shown before (+ids+): what the pointer is over
|
|
131
|
-
// doesn't move.
|
|
132
|
-
//
|
|
133
|
-
export function heldOrder(ids, list) {
|
|
227
|
+
// doesn't move. Items new since then go after, in the list's order; gone
|
|
228
|
+
// ones drop out. +idOf+ names an item: a session's id, or familyId.
|
|
229
|
+
export function heldOrder(ids, list, idOf = (s) => s.id) {
|
|
134
230
|
const at = new Map(ids.map((id, i) => [id, i]));
|
|
135
|
-
const known = list.filter((s) => at.has(s
|
|
136
|
-
return [...known, ...list.filter((s) => !at.has(s
|
|
231
|
+
const known = list.filter((s) => at.has(idOf(s))).sort((a, b) => at.get(idOf(a)) - at.get(idOf(b)));
|
|
232
|
+
return [...known, ...list.filter((s) => !at.has(idOf(s)))];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Whether +other+ is +id+ or one of its delegates, at any depth, by the
|
|
236
|
+
// list's parent_ids: an archive of +id+ stops +other+'s worker too.
|
|
237
|
+
export function inTree(list, id, other) {
|
|
238
|
+
const byId = new Map(list.map((s) => [s.id, s]));
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (let at = other; at && !seen.has(at); at = byId.get(at)?.parent_id) {
|
|
241
|
+
if (at === id) return true;
|
|
242
|
+
seen.add(at);
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Select mode (the all view's bulk archive) ─────────────────────────────
|
|
248
|
+
|
|
249
|
+
// The ids from +fromId+ to +toId+ in a drawn order, both included, in
|
|
250
|
+
// either direction: a Shift-click range. Just [toId] when +fromId+ isn't
|
|
251
|
+
// drawn (no earlier pick, or it went away).
|
|
252
|
+
export function rangeIds(order, fromId, toId) {
|
|
253
|
+
const to = order.indexOf(toId);
|
|
254
|
+
if (to < 0) return [];
|
|
255
|
+
const from = fromId == null ? -1 : order.indexOf(fromId);
|
|
256
|
+
if (from < 0) return [toId];
|
|
257
|
+
return from <= to ? order.slice(from, to + 1) : order.slice(to, from + 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// What a batch acts on: the picked ids that are shown, in the drawn order,
|
|
261
|
+
// the not-archived ones for an archive, the archived ones for an unarchive.
|
|
262
|
+
// A pick that isn't shown (the search hides it) stays picked, untouched.
|
|
263
|
+
// @param picked Set of ids
|
|
264
|
+
// @param shownIds the drawn order
|
|
265
|
+
// @param sessionsById Map id → session summary
|
|
266
|
+
export function batchTargets(picked, shownIds, archive, sessionsById) {
|
|
267
|
+
return shownIds.filter((id) => {
|
|
268
|
+
const s = sessionsById.get(id);
|
|
269
|
+
return picked.has(id) && s && !!s.archived === !archive;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A refusal's reason as the card shows it: the server's words, without
|
|
274
|
+
// the api() status suffix.
|
|
275
|
+
function refusalText(error) {
|
|
276
|
+
return String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Runs +act+ over +ids+ one at a time (a live session's stop waits on the
|
|
280
|
+
// server, and a parent's and its delegate's stops would race). An id an
|
|
281
|
+
// earlier answer already covered (a delegate of a parent before it, in its
|
|
282
|
+
// archived / unarchived / discarded list) is not sent again.
|
|
283
|
+
// @param act async (id) → {ok: true, result} | {ok: false, error}
|
|
284
|
+
// @param onProgress (doneCount, total) after each id
|
|
285
|
+
// @return {done, covered, skipped: [{id, code, reason}], gone, affected, flipped}
|
|
286
|
+
// done: the ids a request succeeded on (what an Undo targets);
|
|
287
|
+
// covered: picked ids an earlier answer covered; gone: 404s;
|
|
288
|
+
// affected: every id the answers listed (discarded ones too);
|
|
289
|
+
// flipped: the ids archived / unarchived (not the discarded)
|
|
290
|
+
export async function runBatch(ids, act, onProgress = () => {}) {
|
|
291
|
+
const out = { done: [], covered: [], skipped: [], gone: [], affected: new Set(), flipped: new Set() };
|
|
292
|
+
const targets = ids.slice();
|
|
293
|
+
for (let i = 0; i < targets.length; i++) {
|
|
294
|
+
const id = targets[i];
|
|
295
|
+
if (out.affected.has(id)) {
|
|
296
|
+
out.covered.push(id);
|
|
297
|
+
} else {
|
|
298
|
+
const answer = await act(id);
|
|
299
|
+
if (answer.ok) {
|
|
300
|
+
out.done.push(id);
|
|
301
|
+
const r = answer.result || {};
|
|
302
|
+
for (const list of [r.archived, r.unarchived]) (list || []).forEach((x) => { out.affected.add(x); out.flipped.add(x); });
|
|
303
|
+
(r.discarded || []).forEach((x) => out.affected.add(x));
|
|
304
|
+
out.affected.add(id);
|
|
305
|
+
} else if (answer.error?.status === 404) {
|
|
306
|
+
out.gone.push(id);
|
|
307
|
+
} else {
|
|
308
|
+
out.skipped.push({ id, code: answer.error?.code || null, reason: refusalText(answer.error) });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
onProgress(i + 1, targets.length);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The end toast of a batch: "Archived 3 (+2 delegates) · 1 skipped", or
|
|
317
|
+
// "None archived: 2 skipped" when nothing went.
|
|
318
|
+
// @param done the picked ids it archived (requested or covered)
|
|
319
|
+
// @param delegates the others the cascade took along
|
|
320
|
+
export function batchToastText({ done, delegates = 0, skipped = 0, archive }) {
|
|
321
|
+
const verb = archive ? "Archived" : "Unarchived";
|
|
322
|
+
const skippedText = `${skipped} skipped`;
|
|
323
|
+
if (!done) return `None ${verb.toLowerCase()}${skipped ? `: ${skippedText}` : ""}`;
|
|
324
|
+
const more = delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : "";
|
|
325
|
+
return `${verb} ${done}${more}${skipped ? ` · ${skippedText}` : ""}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The numbers batchToastText takes, from runBatch's outcome.
|
|
329
|
+
export function batchCounts(outcome, archive) {
|
|
330
|
+
const picked = new Set([...outcome.done, ...outcome.covered]);
|
|
331
|
+
const delegates = [...outcome.flipped].filter((id) => !picked.has(id)).length;
|
|
332
|
+
return { done: picked.size, delegates, skipped: outcome.skipped.length, archive };
|
|
137
333
|
}
|
|
@@ -19,9 +19,12 @@ export function stripColumns(width) {
|
|
|
19
19
|
// approval, a card), so a hidden strip still says one needs an answer.
|
|
20
20
|
// Plain text parts; live and waiting are "" when none. Zero sessions keep
|
|
21
21
|
// the bare label.
|
|
22
|
-
|
|
22
|
+
// @param cards how many cards the list makes (a parent's delegates fold
|
|
23
|
+
// into its card: one), the count's number; live and waiting count every
|
|
24
|
+
// session, a waiting delegate is still one thing waiting on the user
|
|
25
|
+
export function stripShowParts(sessions, { cards = null } = {}) {
|
|
23
26
|
const list = sessions || [];
|
|
24
|
-
const n = list.length;
|
|
27
|
+
const n = cards ?? list.length;
|
|
25
28
|
const liveN = list.filter((s) => s && s.owner === "worker").length;
|
|
26
29
|
const waitingN = list.filter((s) => waitingOn(s)).length;
|
|
27
30
|
return {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The turn's pieces as HTML, one builder for the live page and a reload:
|
|
2
|
+
// a tool row, a prompt bubble and the notice of a turn with no answer. The
|
|
3
|
+
// live page makes its elements from the same markup a reload writes
|
|
4
|
+
// (htmlElement), so the two can't drift apart. Pure but htmlElement: `node --test` covers it.
|
|
5
|
+
import { toolName } from "./activity.js";
|
|
6
|
+
import { commandBlockHtml, rowHover } from "./command_view.js";
|
|
7
|
+
import { copySourceAttr } from "./copy.js";
|
|
8
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
9
|
+
import { escapeHtml, userBodyHtml } from "./format.js";
|
|
10
|
+
import { formatDuration } from "./timing.js";
|
|
11
|
+
import { emptyAnswerLine } from "./turn_events.js";
|
|
12
|
+
|
|
13
|
+
// A row's status (live, or a saved tool record's on reload): running,
|
|
14
|
+
// error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
|
|
15
|
+
// denied it), else ok ("done").
|
|
16
|
+
const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
|
|
17
|
+
|
|
18
|
+
// A row's status as its parts: the mark's class (CSS draws the glyph), its
|
|
19
|
+
// label (aria-label and title) and the word an exception shows after the
|
|
20
|
+
// title (empty for done and running).
|
|
21
|
+
export function statusParts(status) {
|
|
22
|
+
const named = NAMED_STATUSES.includes(status);
|
|
23
|
+
const cls = named ? status : "ok";
|
|
24
|
+
const label = named ? status : "done";
|
|
25
|
+
return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusMarkHtml({ cls, label }) {
|
|
29
|
+
return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusWordHtml({ cls, word }) {
|
|
33
|
+
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A tool row: its status mark, tool, title (else params; the full command,
|
|
37
|
+
// else the full params line, on hover), the word an exception shows, the
|
|
38
|
+
// call's duration, the full command (an execute/task_create's view, also
|
|
39
|
+
// while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
|
|
40
|
+
// the rest on hover), its images (+thumbs+(images) draws them) and an
|
|
41
|
+
// edit's collapsed diff. A part with nothing to say is left out.
|
|
42
|
+
export function toolRowHtml(row, { thumbs = () => "" } = {}) {
|
|
43
|
+
return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
47
|
+
const shown = row.title || row.params;
|
|
48
|
+
const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
|
|
49
|
+
const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
|
|
50
|
+
const duration = formatDuration(row.duration_ms);
|
|
51
|
+
const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
52
|
+
const output = full
|
|
53
|
+
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
54
|
+
: "";
|
|
55
|
+
const status = statusParts(row.status);
|
|
56
|
+
return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
|
|
57
|
+
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
58
|
+
`${commandBlockHtml(row.view, row.title)}${output}` +
|
|
59
|
+
`${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A prompt bubble's states and their badges: queued (waiting for its
|
|
63
|
+
// turn), steered (merged into a running turn), failed (its turn raised and
|
|
64
|
+
// gave it back). Any other state ("started", none) is a plain bubble.
|
|
65
|
+
export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
|
|
66
|
+
|
|
67
|
+
export function stateBadgeHtml(state) {
|
|
68
|
+
return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A user's line: who sent it (+label+, a delegate report's or another
|
|
72
|
+
// client's), its body (`>` lines as quotes) and +thumbs+ (its images'
|
|
73
|
+
// markup), its state's badge. +message+: { content, display } (the copy
|
|
74
|
+
// source keeps the text as written). +step+: the step that read a line
|
|
75
|
+
// merged mid-turn (a reload's; a resolved question goes before it).
|
|
76
|
+
// The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
|
|
77
|
+
// (its normalized text: an echo waiting for its id is matched by it).
|
|
78
|
+
export function userBubbleHtml(message, { label = null, state = null, step = null, thumbs = "", own = false, enqueuedId = null, key = null } = {}) {
|
|
79
|
+
const named = BADGES[state] ? state : null;
|
|
80
|
+
const attrs = [
|
|
81
|
+
`class="bubble user${named ? ` ${named}` : ""}"`,
|
|
82
|
+
named ? `data-user-state="${named}"` : "",
|
|
83
|
+
step ? `data-step="${Number(step)}"` : "",
|
|
84
|
+
].filter(Boolean).join(" ");
|
|
85
|
+
const live = [
|
|
86
|
+
key !== null ? ` data-user-content="${escapeHtml(key)}"` : "",
|
|
87
|
+
own ? ' data-own="1"' : "",
|
|
88
|
+
enqueuedId ? ` data-enqueued-id="${escapeHtml(enqueuedId)}"` : "",
|
|
89
|
+
].join("");
|
|
90
|
+
const from = label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
|
|
91
|
+
return `<div ${attrs}${copySourceAttr({ ...message, role: "user" }, false)}${live}>${from}` +
|
|
92
|
+
`<div class="user-message">${userBodyHtml(message?.content)}${thumbs}</div>${stateBadgeHtml(named)}</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The notice of a turn that ended with no answer (the server's
|
|
96
|
+
// empty_answer item, turn_summary.empty_answer live): a muted row like the
|
|
97
|
+
// loop's retry row, where the answer would be.
|
|
98
|
+
export function emptyAnswerHtml(item) {
|
|
99
|
+
return `<div class="bubble hook-notice empty-answer">${escapeHtml(emptyAnswerLine(item))}</div>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The element +html+ (one top-level element) describes.
|
|
103
|
+
export function htmlElement(html) {
|
|
104
|
+
const template = document.createElement("template");
|
|
105
|
+
template.innerHTML = html;
|
|
106
|
+
return template.content.firstElementChild;
|
|
107
|
+
}
|
|
@@ -117,12 +117,13 @@ function toolEvent(turn, event, add) {
|
|
|
117
117
|
return { ...r, row };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// +now+: when the page got the event, for the call's duration (activity.js).
|
|
121
|
+
export function toolStarted(turn, event, now = null) {
|
|
122
|
+
return toolEvent(turn, event, (model, e) => addStarted(model, e, now).row);
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
export function toolCompleted(turn, event) {
|
|
125
|
-
return toolEvent(turn, event, addCompleted);
|
|
125
|
+
export function toolCompleted(turn, event, now = null) {
|
|
126
|
+
return toolEvent(turn, event, (model, e) => addCompleted(model, e, now));
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
// A hook's line to the user, in the current step; null with no running
|