samagotchi 0.124.0 → 0.126.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 +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +19 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// A link to a claim (docs/internals/participants.md, Deep links): one turn
|
|
2
|
+
// of a session and a quote in it, on either page.
|
|
3
|
+
//
|
|
4
|
+
// participant page: <chi web>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>
|
|
5
|
+
// owner's page: #/s/<id>?t=<turn8>&q=<quote>&p=<prefix>
|
|
6
|
+
//
|
|
7
|
+
// t is the turn's id, its first 8 characters (a turn-opening message's
|
|
8
|
+
// turn_id, data-turn-id in the DOM); q the selection's rendered text,
|
|
9
|
+
// normalized and at most QUOTE_MAX characters; p the PREFIX_MAX characters
|
|
10
|
+
// before it in the same bubble, to tell two equal quotes apart (left out
|
|
11
|
+
// when there are none). URLSearchParams encodes both, so a link is
|
|
12
|
+
// URL-safe and a space is one "+".
|
|
13
|
+
//
|
|
14
|
+
// Normalized: whitespace collapsed, and what the participant page shows
|
|
15
|
+
// instead (ParticipantView::Redact): secret shapes "[redacted]" (so a link
|
|
16
|
+
// never carries one, and matches the participant's redacted text), and on
|
|
17
|
+
// the owner's page the session's folder relative ("<cwd>/x" → "x",
|
|
18
|
+
// "<cwd>" → "."), the home folder "~". Not the owner's token (Redact's
|
|
19
|
+
// viewer secrets): the page doesn't hold it.
|
|
20
|
+
//
|
|
21
|
+
// Opening one: the quote's bubble scrolled to and marked (the quote itself
|
|
22
|
+
// by the CSS Highlight API, where the browser has it); a quote not found
|
|
23
|
+
// marks the turn; a turn not found (one the page doesn't stamp) is looked
|
|
24
|
+
// for in the whole page, and then is the caller's (top + a toast).
|
|
25
|
+
//
|
|
26
|
+
// The text work is pure; the DOM part reads only Element/Range/TreeWalker,
|
|
27
|
+
// so it runs under `node --test` with happy-dom. A module of its own:
|
|
28
|
+
// static JS is cached at unversioned URLs, and a new export on an old
|
|
29
|
+
// module would blank the page.
|
|
30
|
+
|
|
31
|
+
export const QUOTE_MAX = 200;
|
|
32
|
+
export const PREFIX_MAX = 32;
|
|
33
|
+
export const TURN_CHARS = 8;
|
|
34
|
+
// The CSS highlight's name (::highlight(chi-claim)) and the marked
|
|
35
|
+
// element's class.
|
|
36
|
+
export const HIGHLIGHT = "chi-claim";
|
|
37
|
+
export const HIT_CLASS = "claim-hit";
|
|
38
|
+
|
|
39
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40
|
+
|
|
41
|
+
export const MARK = "[redacted]";
|
|
42
|
+
// ParticipantView::Redact's PATTERNS and VALUES
|
|
43
|
+
// (lib/samagotchi/web/participant_view.rb), as JS: keep them in step.
|
|
44
|
+
// spec/fixtures/redact_cases.json holds cases both sides read the same
|
|
45
|
+
// (participant_view_spec.rb, claim_link.test.js).
|
|
46
|
+
const SECRET_PATTERNS = [
|
|
47
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
|
|
48
|
+
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g,
|
|
49
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
50
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
51
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
52
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
53
|
+
/\bchp_[A-Za-z0-9_-]{20,}/g,
|
|
54
|
+
/\bchc_[A-Za-z0-9_-]{43}/g,
|
|
55
|
+
/\bAIza[0-9A-Za-z_-]{30,}/g,
|
|
56
|
+
/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
57
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
58
|
+
/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/g,
|
|
59
|
+
];
|
|
60
|
+
// The value after group 1 is the secret.
|
|
61
|
+
const SECRET_VALUES = [
|
|
62
|
+
/(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/gi,
|
|
63
|
+
/((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/gi,
|
|
64
|
+
/(\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s/]+(?=@)/gi,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// The secrets in +raw+ as [{start, end, text: MARK}]; overlapping ones
|
|
68
|
+
// (a token= value holding a key shape) one span, as Redact's passes in a
|
|
69
|
+
// row leave one mark.
|
|
70
|
+
function secretSpans(raw) {
|
|
71
|
+
const found = [];
|
|
72
|
+
for (const re of SECRET_PATTERNS) for (const m of raw.matchAll(re)) found.push([m.index, m.index + m[0].length]);
|
|
73
|
+
for (const re of SECRET_VALUES) {
|
|
74
|
+
for (const m of raw.matchAll(re)) found.push([m.index + m[1].length, m.index + m[0].length]);
|
|
75
|
+
}
|
|
76
|
+
found.sort((a, b) => a[0] - b[0]);
|
|
77
|
+
const spans = [];
|
|
78
|
+
for (const [start, end] of found) {
|
|
79
|
+
const last = spans[spans.length - 1];
|
|
80
|
+
if (last && start < last.end) last.end = Math.max(last.end, end);
|
|
81
|
+
else spans.push({ start, end, text: MARK });
|
|
82
|
+
}
|
|
83
|
+
return spans;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The spans of +raw+ that read differently on the participant page:
|
|
87
|
+
// [{start, end, text}], in order, not overlapping. Secrets first, then
|
|
88
|
+
// the session's folder (it is usually under home), as Redact.text.
|
|
89
|
+
function replacements(raw, { home = "", cwd = "" } = {}) {
|
|
90
|
+
const spans = secretSpans(raw);
|
|
91
|
+
const taken = (s, e) => spans.some((r) => s < r.end && e > r.start);
|
|
92
|
+
if (cwd.length > 1) {
|
|
93
|
+
for (const m of raw.matchAll(new RegExp(`${escapeRegExp(cwd)}(/|(?=\\b|$))`, "g"))) {
|
|
94
|
+
const end = m.index + m[0].length;
|
|
95
|
+
if (!taken(m.index, end)) spans.push({ start: m.index, end, text: m[1] === "/" ? "" : "." });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (home.length > 1) {
|
|
99
|
+
for (const m of raw.matchAll(new RegExp(`${escapeRegExp(home)}(?=/|\\b|$)`, "g"))) {
|
|
100
|
+
const end = m.index + m[0].length;
|
|
101
|
+
if (!taken(m.index, end)) spans.push({ start: m.index, end, text: "~" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// +raw+ normalized, with where each of its characters came from: text,
|
|
108
|
+
// and starts[i]/ends[i] the raw offsets character i covers.
|
|
109
|
+
export function normalizeWithMap(raw, opts = {}) {
|
|
110
|
+
const source = String(raw ?? "");
|
|
111
|
+
const spans = replacements(source, opts);
|
|
112
|
+
let text = "";
|
|
113
|
+
const starts = [];
|
|
114
|
+
const ends = [];
|
|
115
|
+
let space = null;
|
|
116
|
+
const put = (ch, start, end) => {
|
|
117
|
+
if (/\s/.test(ch)) {
|
|
118
|
+
if (space) space.end = end;
|
|
119
|
+
else if (text) space = { start, end };
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (space) {
|
|
123
|
+
text += " ";
|
|
124
|
+
starts.push(space.start);
|
|
125
|
+
ends.push(space.end);
|
|
126
|
+
space = null;
|
|
127
|
+
}
|
|
128
|
+
text += ch;
|
|
129
|
+
starts.push(start);
|
|
130
|
+
ends.push(end);
|
|
131
|
+
};
|
|
132
|
+
let i = 0;
|
|
133
|
+
let next = 0;
|
|
134
|
+
while (i < source.length) {
|
|
135
|
+
const span = spans[next];
|
|
136
|
+
if (span && span.start === i) {
|
|
137
|
+
// A folder shown "." or "" (its "/" gone) maps to the whole span.
|
|
138
|
+
for (const ch of span.text) put(ch, span.start, span.end);
|
|
139
|
+
i = span.end;
|
|
140
|
+
next += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
put(source[i], i, i + 1);
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
return { text, starts, ends };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function normalizeClaim(raw, opts = {}) {
|
|
150
|
+
return normalizeWithMap(raw, opts).text;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The first +max+ characters of +text+ (or the last, +fromEnd+), never
|
|
154
|
+
// half a surrogate pair.
|
|
155
|
+
function clip(text, max, fromEnd = false) {
|
|
156
|
+
const chars = Array.from(text);
|
|
157
|
+
if (chars.length <= max) return text;
|
|
158
|
+
return (fromEnd ? chars.slice(-max) : chars.slice(0, max)).join("");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The claim's q and p for a selection: +selected+ its text, +before+ and
|
|
162
|
+
// +after+ the bubble's text around it. Normalized together, so a secret
|
|
163
|
+
// the selection cuts into is read whole and marked whole (never a piece
|
|
164
|
+
// of it in the link), and the prefix ends as the page reads it (one space
|
|
165
|
+
// between it and the quote, or none). q is "" when the selection holds no
|
|
166
|
+
// text.
|
|
167
|
+
export function claimQuote(selected, before = "", opts = {}, after = "") {
|
|
168
|
+
const pre = String(before ?? "");
|
|
169
|
+
const from = pre.length;
|
|
170
|
+
const to = from + String(selected ?? "").length;
|
|
171
|
+
const { text, starts, ends } = normalizeWithMap(`${pre}${selected ?? ""}${after ?? ""}`, opts);
|
|
172
|
+
let a = 0;
|
|
173
|
+
while (a < text.length && ends[a] <= from) a += 1;
|
|
174
|
+
let b = text.length;
|
|
175
|
+
while (b > a && starts[b - 1] >= to) b -= 1;
|
|
176
|
+
while (a < b && text[a] === " ") a += 1;
|
|
177
|
+
while (b > a && text[b - 1] === " ") b -= 1;
|
|
178
|
+
const q = text.slice(a, b);
|
|
179
|
+
if (!q) return { q: "", p: "" };
|
|
180
|
+
return { q: clip(q, QUOTE_MAX), p: clip(text.slice(0, a), PREFIX_MAX, true) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// "t=…&q=…&p=…" for a claim {turnId, q, p}; empty parts left out.
|
|
184
|
+
export function claimParams({ turnId = "", q = "", p = "" } = {}) {
|
|
185
|
+
const params = new URLSearchParams();
|
|
186
|
+
if (turnId) params.set("t", String(turnId).slice(0, TURN_CHARS));
|
|
187
|
+
if (q) params.set("q", q);
|
|
188
|
+
if (q && p) params.set("p", p);
|
|
189
|
+
return params.toString();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The participant page's link: +base+ the participant link's origin
|
|
193
|
+
// ("https://chi.example/", "http://192.168.1.5:4567/"), no token.
|
|
194
|
+
export function participantClaimLink(base, sessionId, claim) {
|
|
195
|
+
const params = claimParams(claim);
|
|
196
|
+
const root = String(base || "").replace(/#.*$/, "").replace(/\/?$/, "/");
|
|
197
|
+
return `${root}#s=${encodeURIComponent(sessionId)}${params ? `&${params}` : ""}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The owner's page's hash for the claim.
|
|
201
|
+
export function ownerClaimHash(sessionId, claim) {
|
|
202
|
+
const params = claimParams(claim);
|
|
203
|
+
return `#/s/${encodeURIComponent(sessionId)}${params ? `?${params}` : ""}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// {sessionId, turn, q, p} from either page's hash, or null when it names
|
|
207
|
+
// no turn (a bare session link). The owner's page reads both forms: the
|
|
208
|
+
// participant link opened in the owner's browser shows the owner's page.
|
|
209
|
+
export function claimFromHash(hash) {
|
|
210
|
+
const raw = String(hash || "");
|
|
211
|
+
let id = null;
|
|
212
|
+
let query = "";
|
|
213
|
+
const owner = /^#\/s\/([^/?#]+)\?(.*)$/.exec(raw);
|
|
214
|
+
if (owner) {
|
|
215
|
+
try { id = decodeURIComponent(owner[1]); } catch { return null; }
|
|
216
|
+
query = owner[2];
|
|
217
|
+
} else if (raw.startsWith("#") && !raw.startsWith("#/")) {
|
|
218
|
+
query = raw.slice(1);
|
|
219
|
+
} else {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
let params;
|
|
223
|
+
try { params = new URLSearchParams(query); } catch { return null; }
|
|
224
|
+
id ||= params.get("s");
|
|
225
|
+
const turn = params.get("t") || "";
|
|
226
|
+
if (!id || !/^[0-9a-f-]{1,36}$/i.test(turn)) return null;
|
|
227
|
+
return { sessionId: id, turn: turn.toLowerCase(), q: params.get("q") || "", p: params.get("p") || "" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Where the claim's text is in +texts+ (each root's raw text): {at, start,
|
|
231
|
+
// end} raw offsets in texts[at], or null. The prefix and the quote
|
|
232
|
+
// together first, then the quote alone (the bubble's text before it may
|
|
233
|
+
// read differently on the other page).
|
|
234
|
+
export function findClaim(texts, { q = "", p = "" } = {}, opts = {}) {
|
|
235
|
+
if (!q) return null;
|
|
236
|
+
const maps = texts.map((t) => normalizeWithMap(t, opts));
|
|
237
|
+
const needles = p ? [[`${p}${q}`, p.length], [q, 0]] : [[q, 0]];
|
|
238
|
+
for (const [needle, skip] of needles) {
|
|
239
|
+
for (let at = 0; at < maps.length; at += 1) {
|
|
240
|
+
const found = maps[at].text.indexOf(needle);
|
|
241
|
+
if (found < 0) continue;
|
|
242
|
+
const first = found + skip;
|
|
243
|
+
const last = found + needle.length - 1;
|
|
244
|
+
return { at, start: maps[at].starts[first], end: maps[at].ends[last] };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- The DOM ----
|
|
251
|
+
|
|
252
|
+
// The turn-opening element whose data-turn-id starts with +turn+ in
|
|
253
|
+
// +container+, and the roots (+rootSelector+) from it to the next turn's
|
|
254
|
+
// opening: {anchor, roots}, or null.
|
|
255
|
+
export function turnRoots(container, turn, rootSelector) {
|
|
256
|
+
const anchors = [...container.querySelectorAll("[data-turn-id]")];
|
|
257
|
+
const at = anchors.findIndex((a) => turn && String(a.dataset.turnId || "").toLowerCase().startsWith(turn));
|
|
258
|
+
if (at < 0) return null;
|
|
259
|
+
const anchor = anchors[at];
|
|
260
|
+
const next = anchors[at + 1] || null;
|
|
261
|
+
const FOLLOWING = 4;
|
|
262
|
+
const roots = [...container.querySelectorAll(rootSelector)].filter((el) =>
|
|
263
|
+
(anchor === el || anchor.contains(el) || (anchor.compareDocumentPosition(el) & FOLLOWING))
|
|
264
|
+
&& (!next || (!next.contains(el) && (el.compareDocumentPosition(next) & FOLLOWING))));
|
|
265
|
+
return { anchor, roots };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The turn id of the turn +node+ is in (the last turn opening at or before
|
|
269
|
+
// it in +container+), or null.
|
|
270
|
+
export function turnIdOf(container, node) {
|
|
271
|
+
let found = null;
|
|
272
|
+
const PRECEDING = 2;
|
|
273
|
+
for (const anchor of container.querySelectorAll("[data-turn-id]")) {
|
|
274
|
+
if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) found = anchor;
|
|
275
|
+
else break;
|
|
276
|
+
}
|
|
277
|
+
return found?.dataset.turnId || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// +root+'s text nodes and their text joined: {text, nodes: [{node, start}]}.
|
|
281
|
+
function textOf(root, doc) {
|
|
282
|
+
const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
|
283
|
+
const nodes = [];
|
|
284
|
+
let text = "";
|
|
285
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
286
|
+
nodes.push({ node: n, start: text.length });
|
|
287
|
+
text += n.data;
|
|
288
|
+
}
|
|
289
|
+
return { text, nodes };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The text node and offset of raw offset +at+ in a textOf result.
|
|
293
|
+
function pointAt({ nodes }, at, end = false) {
|
|
294
|
+
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
|
295
|
+
const { node, start } = nodes[i];
|
|
296
|
+
if (at > start || (at === start && !end) || i === 0) return { node, offset: Math.min(Math.max(at - start, 0), node.data.length) };
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The claim for a selection in +root+ (+range+ in it), as claimQuote's.
|
|
302
|
+
export function selectionClaim(root, range, opts = {}, doc = globalThis.document) {
|
|
303
|
+
const before = doc.createRange();
|
|
304
|
+
before.setStart(root, 0);
|
|
305
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
306
|
+
const after = doc.createRange();
|
|
307
|
+
after.setStart(range.endContainer, range.endOffset);
|
|
308
|
+
after.setEnd(root, root.childNodes.length);
|
|
309
|
+
return claimQuote(range.toString(), before.toString(), opts, after.toString());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Finds the claim +claim+ ({turn, q, p}) in +container+ and shows it: the
|
|
313
|
+
// match's root (or the turn's opening) scrolled to the middle and marked
|
|
314
|
+
// HIT_CLASS, its closed <details> opened, the quote highlighted (CSS
|
|
315
|
+
// Highlight API). Without the turn (no opening carries its id: a
|
|
316
|
+
// non-person turn), the quote in any root of the page. @return "quote",
|
|
317
|
+
// "turn" or null (neither the turn nor the quote).
|
|
318
|
+
export function showClaim(container, claim, { roots: rootSelector, opts = {}, doc = globalThis.document, win = globalThis.window } = {}) {
|
|
319
|
+
clearClaim(container, { doc, win });
|
|
320
|
+
const turn = turnRoots(container, claim.turn, rootSelector);
|
|
321
|
+
const roots = turn ? turn.roots : [...container.querySelectorAll(rootSelector)];
|
|
322
|
+
const texts = roots.map((root) => textOf(root, doc));
|
|
323
|
+
const hit = findClaim(texts.map((t) => t.text), claim, opts);
|
|
324
|
+
if (!hit && !turn) return null;
|
|
325
|
+
const el = hit ? roots[hit.at] : turn.anchor;
|
|
326
|
+
for (let d = el.parentElement?.closest("details"); d; d = d.parentElement?.closest("details")) d.open = true;
|
|
327
|
+
el.classList.add(HIT_CLASS);
|
|
328
|
+
el.scrollIntoView?.({ block: "center" });
|
|
329
|
+
if (hit) {
|
|
330
|
+
const start = pointAt(texts[hit.at], hit.start);
|
|
331
|
+
const end = pointAt(texts[hit.at], hit.end, true);
|
|
332
|
+
const highlights = win?.CSS?.highlights;
|
|
333
|
+
if (start && end && highlights && typeof win.Highlight === "function") {
|
|
334
|
+
const range = doc.createRange();
|
|
335
|
+
range.setStart(start.node, start.offset);
|
|
336
|
+
range.setEnd(end.node, end.offset);
|
|
337
|
+
highlights.set(HIGHLIGHT, new win.Highlight(range));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return hit ? "quote" : "turn";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The mark and highlight showClaim left, gone.
|
|
344
|
+
export function clearClaim(container, { win = globalThis.window } = {}) {
|
|
345
|
+
for (const el of container.querySelectorAll(`.${HIT_CLASS}`)) el.classList.remove(HIT_CLASS);
|
|
346
|
+
win?.CSS?.highlights?.delete?.(HIGHLIGHT);
|
|
347
|
+
}
|
|
@@ -140,11 +140,12 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
|
|
|
140
140
|
// image-only send, whose text is only its [image: …] placeholders).
|
|
141
141
|
// delivery: what the message does to a running turn ("cut": /cut TEXT);
|
|
142
142
|
// none = the server's default (the next step).
|
|
143
|
-
export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, ...opts } = {}) {
|
|
143
|
+
export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, verify = null, ...opts } = {}) {
|
|
144
144
|
const body = clientId ? { prompt, client_id: clientId } : { prompt };
|
|
145
145
|
if (images?.length) body.images = images;
|
|
146
146
|
if (delivery) body.delivery = delivery;
|
|
147
147
|
if (history === false) body.history = false;
|
|
148
|
+
if (verify) body.verify = verify;
|
|
148
149
|
return api(`/api/sessions/${id}/turn`, {
|
|
149
150
|
method: "POST",
|
|
150
151
|
body: JSON.stringify(body),
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
<meta name="apple-mobile-web-app-title" content="Chi">
|
|
11
11
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
12
12
|
<link rel="stylesheet" href="/assets/tokens.css">
|
|
13
|
+
<link rel="stylesheet" href="/assets/verify_card.css">
|
|
13
14
|
<link rel="stylesheet" href="/assets/annotate_bar.css">
|
|
14
15
|
<link rel="stylesheet" href="/assets/composer.css">
|
|
15
16
|
<link rel="stylesheet" href="/assets/forms.css">
|
|
@@ -20,6 +20,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
20
20
|
// (task): nobody started it, so nobody watched it.
|
|
21
21
|
const DONE_MIN_SECONDS = 10;
|
|
22
22
|
const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
|
|
23
|
+
const VERDICT_REASON_MAX = 120;
|
|
23
24
|
|
|
24
25
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
25
26
|
// and asks whether to go on) has its own reason: "out of steps".
|
|
@@ -52,7 +53,7 @@ const RELAYED_KINDS = new Set(["approval", "hook"]);
|
|
|
52
53
|
// What changed between two summaries of one session that needs the user.
|
|
53
54
|
// @param prev the summary held before (null for a session not seen yet)
|
|
54
55
|
// @param next the new summary
|
|
55
|
-
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
56
|
+
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done"|"verdict", sessionId, key}
|
|
56
57
|
// key names this one event, the same in every tab (the Notification tag);
|
|
57
58
|
// holdMs, when there, delays it (a delegated child's approval)
|
|
58
59
|
export function attentionFor(prev, next) {
|
|
@@ -75,6 +76,10 @@ export function attentionFor(prev, next) {
|
|
|
75
76
|
// A delegated child's turn is reported by its parent's.
|
|
76
77
|
if (next.parent_id) return null;
|
|
77
78
|
const key = `${next.id}:${turn.ended_at}`;
|
|
79
|
+
const verdict = next.last_verdict;
|
|
80
|
+
if (verdict?.check_turn_id && verdict.check_turn_id !== prev?.last_verdict?.check_turn_id) {
|
|
81
|
+
return { reason: "verdict", sessionId: next.id, key: `${next.id}:${verdict.check_turn_id}` };
|
|
82
|
+
}
|
|
78
83
|
if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
|
|
79
84
|
if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
|
|
80
85
|
return { reason: "done", sessionId: next.id, key };
|
|
@@ -157,13 +162,19 @@ export function attentionText(session, attention) {
|
|
|
157
162
|
const name = String(session?.title || session?.auto_title || session?.recap || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
158
163
|
const title = clip(name, 61);
|
|
159
164
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
165
|
+
const verdict = session?.last_verdict;
|
|
166
|
+
const who = verdict?.by_name || (verdict?.by ? "A participant" : "You");
|
|
167
|
+
const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
|
|
168
|
+
const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
|
|
169
|
+
const verdictBody = `${who}: ${verdictIcon} ${verdict?.verdict || ""} — ${verdictReason}`;
|
|
160
170
|
const body = {
|
|
161
171
|
question: "needs an answer",
|
|
162
172
|
approval: "needs approval",
|
|
163
173
|
continue: "hit its step limit",
|
|
164
174
|
card: "needs you",
|
|
165
175
|
failed: "turn failed",
|
|
166
|
-
done: `done in ${seconds} s
|
|
176
|
+
done: `done in ${seconds} s`,
|
|
177
|
+
verdict: verdictBody
|
|
167
178
|
}[attention.reason] || "";
|
|
168
179
|
return { title, body };
|
|
169
180
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
|
7
7
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
8
8
|
<link rel="stylesheet" href="/assets/tokens.css">
|
|
9
|
+
<link rel="stylesheet" href="/assets/verify_card.css">
|
|
9
10
|
<link rel="stylesheet" href="/assets/annotate_bar.css">
|
|
10
11
|
<link rel="stylesheet" href="/assets/composer.css">
|
|
11
12
|
<link rel="stylesheet" href="/assets/forms.css">
|
|
@@ -94,7 +95,7 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
|
94
95
|
.p-tools{font-size:12.5px;color:var(--text-muted)}
|
|
95
96
|
.p-tools summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}
|
|
96
97
|
.p-tools summary::-webkit-details-marker{display:none}
|
|
97
|
-
.p-tools summary::before{content:"▸"
|
|
98
|
+
.p-tools summary::before{content:"▸"}
|
|
98
99
|
.p-tools[open] summary::before{content:"▾"}
|
|
99
100
|
.p-tools ul{list-style:none;padding:2px 0 0 14px;display:flex;flex-direction:column;gap:1px}
|
|
100
101
|
.p-tools li{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -171,6 +172,7 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
171
172
|
<div class="p-note" id="note"></div>
|
|
172
173
|
</div>
|
|
173
174
|
<div class="p-offline" id="offline" role="status" hidden>Can't reach the session — retrying…</div>
|
|
175
|
+
<div class="p-offline" id="claimNote" role="status" hidden></div>
|
|
174
176
|
</header>
|
|
175
177
|
<div class="p-body">
|
|
176
178
|
<aside class="p-plan" id="plan" hidden aria-label="Under review">
|