misakanet 2.38.0 → 2.40.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.
package/lib/client.js ADDED
@@ -0,0 +1,1076 @@
1
+ /**
2
+ * misakanet — client half (the browser bundle the host serves under `/plugins`).
3
+ *
4
+ * WHAT THIS FILE IS
5
+ * -----------------
6
+ * `dsh.client` in package.json turns this file into a loadable browser bundle: the host composes it
7
+ * into `window.__DSH_BOOT__`, serves it through the module loader, and materializes the factory on
8
+ * first use. That is why the file registers its own factory — the loader contract is
9
+ * `window.__ModuleLoader__.load({id, factory})`, where `id` is the package name and the factory
10
+ * returns the module exports (`apply`, `inject`).
11
+ *
12
+ * It is deliberately plain CommonJS-factory JavaScript: no bundler, no JSX, no build step. The module
13
+ * table supplies `react` (a seeded platform module), and `React.createElement` builds the tree. A
14
+ * hand-written bundle is enough — measured on 2026-10-01: installed into a disposable `DSH_HOME` and
15
+ * booted with `dsh web`, the host composed this file into its boot graph and served the bytes verbatim.
16
+ *
17
+ * TWO SURFACES, TWO DIFFERENT JOBS
18
+ * -------------------------------
19
+ * **Visibility belongs where the value happens; judgment belongs where the outcome is visible.**
20
+ *
21
+ * * `tool.call.toolview` → a row on the `misakanet_search` call: how many lessons came back, which one
22
+ * is on top, its domain and evidence level, with the raw payload one disclosure away. Search time is
23
+ * when a person can *see* something; it is the wrong moment to ask whether it helped, because nobody
24
+ * knows yet — the fix has not run.
25
+ * * `conversation.chat.assistant-actions` → one action on the finalized assistant message, beside the
26
+ * host's own Like/Dislike: 👍 Helpful / 👎 Not what I needed. That is the moment the outcome is on
27
+ * screen, and the only surface from which a human can speak about it.
28
+ *
29
+ * Why the vote is worth this much care: `POST /api/helpful` is the **only** writer of the counter that
30
+ * `misakanet_me_events` reports as `lesson_found_helpful`, so one click is what turns a private success
31
+ * into reuse evidence every later agent can read (the event appears at one vote, `E4` at two). Three
32
+ * rules follow, and they explain several odd-looking details below:
33
+ *
34
+ * 1. **Never vote on the human's behalf.** No auto-vote on a successful build, no default selection,
35
+ * no pre-checked box: `E4` is worth something only because a person said it.
36
+ * 2. **Abstention is free and is the default.** Doing nothing is a first-class outcome — the buttons
37
+ * appear, and nothing nags if they are ignored.
38
+ * 3. **Say what each action sends.** 👍 posts the lesson id and nothing else. 👎 has to carry the search
39
+ * text to be useful (`/api/feedback` counts the unsolved gap), so the line under the buttons says so
40
+ * instead of letting the person guess.
41
+ *
42
+ * WHAT IT DELIBERATELY DOES NOT DO
43
+ * --------------------------------
44
+ * No lesson links (the site's directory slugs are truncated, so a URL cannot be built from an id), no
45
+ * gap-report button (that belongs with the intake side, not with a satisfaction vote), and no
46
+ * credentials — the endpoints are public, anonymous and rate-limited. And no auto-voting, ever.
47
+ *
48
+ * KNOWN LIMITS (v1, stated rather than hidden)
49
+ * --------------------------------------------
50
+ * The verdict learns that a search happened from this module's own per-session memory, not from a host
51
+ * projection: the search row records what it showed, and the verdict is offered on the **first**
52
+ * finalized message after it. Reloading the page clears that memory, so an unanswered verdict is not
53
+ * resurrected — the honest fix is a session projection, a bigger contract than this slice. A search
54
+ * that matched nothing records no sighting: there is no lesson to have an opinion about.
55
+ *
56
+ * CONTRACT NOTES (measured against dsh 0.2.0-rc.2, 2026-10-01)
57
+ * ----------------------------------------------------------
58
+ * * Both slots are session-scoped, so their props carry `sessionId`; `assistant-actions` additionally
59
+ * carries the durable `messageId` and is a **list** slot ("a fresh `id` adds an action and reusing one
60
+ * replaces that entry"; an entry with nothing to say returns null, leaving the host's standard action
61
+ * row unchanged).
62
+ * * `tool.call.toolview` is a **keyed** slot and the key is the wire tool name, which the host builds as
63
+ * `mcp__${serverName}__${rawName}` (`dsh-mcp-client`) — `cordis.patch.yml` sets `serverName:
64
+ * misakanet`, so the name is `mcp__misakanet__misakanet_search`. Both plausible spellings are
65
+ * registered because a key that matches nothing renders nothing at all.
66
+ * * `dsh.client.inject` orders the factories: both slots are declared and typed by
67
+ * `@deepseek-ai/dsh-client-ui-tool` / `-ui-chat`, so those rows arrive first.
68
+ * * Registering an occupied key REPLACES the default row, so the search row must never come back
69
+ * empty: every phase renders something, the raw payload stays reachable, and every handler is
70
+ * wrapped — a card that throws must not take the conversation down.
71
+ *
72
+ * @module misakanet/client
73
+ */
74
+
75
+ window.__ModuleLoader__.load({
76
+ id: "misakanet",
77
+ factory: (require) => {
78
+ "use strict";
79
+
80
+ var module = { exports: {} };
81
+ var exports = module.exports;
82
+ var react = require("react");
83
+ var h = react.createElement;
84
+
85
+ /** The public endpoint; the same one the plugin's host half mounts as MCP. */
86
+ var API = "https://misakanet.org";
87
+
88
+ /**
89
+ * Wire names the search tool can have. The first is what the host builds today
90
+ * (`mcp__<serverName>__<rawName>` with `serverName: misakanet` from cordis.patch.yml).
91
+ */
92
+ var TOOL_KEYS = ["mcp__misakanet__misakanet_search", "mcp__misakanet__search"];
93
+ /** The intake tool has its own row: it is how the panel learns a report was filed at all. */
94
+ var INTAKE_KEYS = ["mcp__misakanet__misakanet_submit_intake", "mcp__misakanet__submit_intake"];
95
+
96
+ /** The verdict's entry id in the assistant-action list (a fresh id adds an entry). */
97
+ var VERDICT_ID = "misakanet-verdict";
98
+ /**
99
+ * The panel's id in the conversation ring, and — in the right sidebar — the tab type's `id`,
100
+ * which is also the key its body and chip register under. The host's contract requires the type
101
+ * `id` to be unique across every registration and the body key to equal it.
102
+ */
103
+ var PANEL_ID = "misakanet";
104
+ /**
105
+ * The tab *kind* is namespaced on purpose: the registry throws when a kind collides with a
106
+ * registration it cannot coexist with, and a bare `misakanet` kind is a name a future plugin
107
+ * could take. (`kind` is what `openTab` names; `id` is the implementation's identity.)
108
+ */
109
+ var PANEL_KIND = "misakanet-panel";
110
+
111
+ /** localStorage keys. Both scopes are this browser only — there is no account anywhere here. */
112
+ var STATS_KEY = "misakanet.panel.stats.v1";
113
+ var VOICE_KEY = "misakanet.panel.voice.v1";
114
+
115
+ /** How many events one session keeps in memory. The panel is a log, not an archive. */
116
+ var LOG_LIMIT = 200;
117
+
118
+ /**
119
+ * What this session's transcript has shown, per session.
120
+ *
121
+ * This is a **log**, because the panel has to answer "what did I ask, what came back, what did I
122
+ * file, when" — a question a single overwritten slot cannot answer (the first version kept only
123
+ * the latest sighting and fed the verdict; the verdict's fields still live here, see `verdict`).
124
+ *
125
+ * Why the client remembers at all: the host renders the transcript, so the rows are the only
126
+ * place these events pass through. A session projection would be the sturdier source; that is a
127
+ * bigger contract than this slice, so the limits are stated in the panel instead of hidden —
128
+ * a reload loses this log and the panel says so.
129
+ */
130
+ var sessions = Object.create(null);
131
+
132
+ function sessionLog(sessionId) {
133
+ if (!sessionId) return null;
134
+ if (!sessions[sessionId]) {
135
+ sessions[sessionId] = {
136
+ sessionId: sessionId,
137
+ searches: [], intakes: [], votes: [],
138
+ lessons: Object.create(null),
139
+ // The verdict's own state: which message it was offered on, and whether it is spent.
140
+ shownFor: "", voted: false,
141
+ };
142
+ }
143
+ return sessions[sessionId];
144
+ }
145
+
146
+ /** Keep a log bounded: the newest `LOG_LIMIT` entries win. */
147
+ function push(list, entry) {
148
+ list.push(entry);
149
+ if (list.length > LOG_LIMIT) list.splice(0, list.length - LOG_LIMIT);
150
+ return entry;
151
+ }
152
+
153
+ /**
154
+ * Browser-scope counters, in `localStorage`, additive and never decreasing by themselves.
155
+ *
156
+ * Deliberately not tied to any identity: there is no account, no node id and no leaderboard in
157
+ * this half, because the only credential this repository issues (`client_id`) can be exchanged
158
+ * for a token and must never live in a browser bundle. So "how much have I contributed" is
159
+ * answered for *this browser*, and the panel says exactly that.
160
+ */
161
+ function browserStats() {
162
+ try {
163
+ var raw = window.localStorage.getItem(STATS_KEY);
164
+ var parsed = raw ? JSON.parse(raw) : null;
165
+ if (parsed && typeof parsed === "object") return parsed;
166
+ } catch (error) {
167
+ /* private mode, or a hostile localStorage: counters are a nicety, never a requirement */
168
+ }
169
+ return { searches: 0, lessonsReused: 0, votes: 0, reportsFiled: 0, reportsConverted: 0 };
170
+ }
171
+
172
+ function bumpStat(name, by) {
173
+ try {
174
+ var stats = browserStats();
175
+ stats[name] = (Number(stats[name]) || 0) + (by === undefined ? 1 : by);
176
+ window.localStorage.setItem(STATS_KEY, JSON.stringify(stats));
177
+ } catch (error) {
178
+ /* counters are best-effort; the panel reads what it can */
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The in-browser voice switch. Off unless someone turned it on — the repository's voice hook is
184
+ * opt-in too (`--voice`), and a plugin that starts making noise on install is a plugin people
185
+ * uninstall. This controls **browser playback only**; the local hook is a different process with
186
+ * its own switch (`MISAKANET_VOICE=0`), which a page cannot read or change.
187
+ */
188
+ function voiceEnabled() {
189
+ try {
190
+ return window.localStorage.getItem(VOICE_KEY) === "1";
191
+ } catch (error) {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function setVoiceEnabled(on) {
197
+ try {
198
+ window.localStorage.setItem(VOICE_KEY, on ? "1" : "0");
199
+ } catch (error) {
200
+ /* nothing to remember it with; the toggle still reports what it did */
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Play the cue the **server** asked for. The server names it in every response (`voice`), so the
206
+ * UI never guesses; `MISAKANET_VOICE_*`-style env vars belong to the local hook, not to this.
207
+ */
208
+ function playCue(cue) {
209
+ if (!cue || !voiceEnabled()) return false;
210
+ try {
211
+ var audio = new window.Audio(API + "/assets/voice/" + cue + ".mp3");
212
+ var played = audio.play();
213
+ if (played && played.catch) played.catch(function () { /* autoplay policy; the click was ours */ });
214
+ return true;
215
+ } catch (error) {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ var MUTED = "rgba(127,127,127,0.45)";
221
+ var DIM = { opacity: 0.72 };
222
+ var BOX = {
223
+ border: "1px solid " + MUTED,
224
+ borderRadius: "8px",
225
+ padding: "8px 10px",
226
+ margin: "4px 0",
227
+ fontSize: "12px",
228
+ lineHeight: "1.6",
229
+ };
230
+ var BUTTON = {
231
+ border: "1px solid " + MUTED,
232
+ borderRadius: "6px",
233
+ background: "transparent",
234
+ color: "inherit",
235
+ cursor: "pointer",
236
+ font: "inherit",
237
+ marginRight: "6px",
238
+ padding: "2px 8px",
239
+ };
240
+
241
+ /**
242
+ * Wake whoever is rendering a session's log after it changes.
243
+ *
244
+ * Deferred out of the render phase on purpose: the recorders run while a row renders, and calling a
245
+ * listener's `setState` synchronously from another component's render is exactly the kind of update
246
+ * React forbids. A microtask is enough for the panel to catch up in the same frame.
247
+ */
248
+ var listeners = Object.create(null);
249
+
250
+ function subscribe(sessionId, listener) {
251
+ if (!sessionId) return function () {};
252
+ if (!listeners[sessionId]) listeners[sessionId] = [];
253
+ listeners[sessionId].push(listener);
254
+ return function () {
255
+ var list = listeners[sessionId] || [];
256
+ var at = list.indexOf(listener);
257
+ if (at >= 0) list.splice(at, 1);
258
+ };
259
+ }
260
+
261
+ function notify(sessionId) {
262
+ if (!sessionId) return;
263
+ Promise.resolve().then(function () {
264
+ var list = (listeners[sessionId] || []).slice();
265
+ for (var i = 0; i < list.length; i++) {
266
+ try {
267
+ list[i]();
268
+ } catch (error) {
269
+ /* one listener's failure is not the others' problem */
270
+ }
271
+ }
272
+ });
273
+ }
274
+
275
+ /** The newest search that surfaced a lesson and has not been judged yet, or null. */
276
+ function latestPending(log) {
277
+ if (!log) return null;
278
+ for (var i = log.searches.length - 1; i >= 0; i--) {
279
+ var entry = log.searches[i];
280
+ if (entry.lessonId && !entry.voted) return entry;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * Record one settled search. Called from a render, so it must be idempotent per call id — and it
287
+ * is: the log is keyed by `callId`, so a repeat render finds the entry already there.
288
+ *
289
+ * The `voice` field is the **server's** choice of cue (`lesson-found` when something matched,
290
+ * `failure-warning` when nothing did). The panel shows it and may play it; a cue guessed in the
291
+ * UI would be the UI inventing data.
292
+ */
293
+ function recordSearch(log, callId, event) {
294
+ var payload = event.payload || {};
295
+ var results = event.results || [];
296
+ var top = results[0] || null;
297
+ for (var i = 0; i < log.searches.length; i++) {
298
+ if (callId && log.searches[i].callId === callId) return log.searches[i];
299
+ }
300
+ var entry = push(log.searches, {
301
+ callId: String(callId || ""),
302
+ at: Date.now(),
303
+ query: String(event.query || payload.query || ""),
304
+ hits: results.length,
305
+ noMatch: Boolean(payload.no_match),
306
+ lessonId: top && top.id ? String(top.id) : "",
307
+ title: top ? String(top.title || top.id || "") : "",
308
+ evidence: top ? String(top.evidence_level || "") : "",
309
+ freshness: top ? String(top.freshness || "") : "",
310
+ voice: String(payload.voice || ""),
311
+ voted: false,
312
+ });
313
+ bumpStat("searches");
314
+ // Browser playback of the cue the SERVER chose — only when someone turned it on. The local
315
+ // hook does the same thing through the machine's own player; this is the browser's copy.
316
+ if (entry.voice && voiceEnabled()) playCue(entry.voice);
317
+ notify(log.sessionId);
318
+ if (entry.lessonId && !log.lessons[entry.lessonId]) {
319
+ log.lessons[entry.lessonId] = {
320
+ lessonId: entry.lessonId, title: entry.title, evidence: entry.evidence,
321
+ first: entry.at, last: entry.at, count: 1,
322
+ };
323
+ bumpStat("lessonsReused");
324
+ } else if (entry.lessonId) {
325
+ log.lessons[entry.lessonId].count += 1;
326
+ log.lessons[entry.lessonId].last = entry.at;
327
+ }
328
+ return entry;
329
+ }
330
+
331
+ /** Record a vote and retire the sighting it answered. */
332
+ function recordVote(log, entry, kind, answer) {
333
+ entry.voted = true;
334
+ entry.voteKind = kind;
335
+ if (log) {
336
+ log.voted = true;
337
+ push(log.votes, {
338
+ at: Date.now(),
339
+ lessonId: entry.lessonId,
340
+ kind: kind,
341
+ count: answer && typeof answer.count === "number" ? answer.count : null,
342
+ });
343
+ }
344
+ bumpStat("votes");
345
+ if (log) notify(log.sessionId);
346
+ }
347
+
348
+ /**
349
+ * The four states a filed report can be in, read straight off the tool's own answers.
350
+ *
351
+ * `already_have` is deliberately NOT "converted": it is the worker's #1526 backstop, meaning the
352
+ * corpus already covers this so **nothing was filed**. Calling that a conversion would tell a
353
+ * reporter their work landed when they never filed anything.
354
+ */
355
+ function intakeState(payload) {
356
+ if (!payload || typeof payload !== "object") return "unknown";
357
+ if (payload.converted) return "converted";
358
+ if (payload.answered) return "answered";
359
+ if (payload.already_have) return "already_have";
360
+ if (payload.pending || payload.duplicate || payload.submitted) return "pending";
361
+ return "unknown";
362
+ }
363
+
364
+ /** Record one intake for the panel: what was said, what the server answered, and when. */
365
+ function recordIntake(log, callId, phase, payload, text) {
366
+ if (!log) return null;
367
+ for (var i = 0; i < log.intakes.length; i++) {
368
+ if (callId && log.intakes[i].callId === callId) {
369
+ var known = log.intakes[i];
370
+ if (text) known.text = String(text);
371
+ if (phase === "result") {
372
+ known.state = intakeState(payload);
373
+ known.receipt = String((payload && (payload.receipt || payload.note)) || known.receipt);
374
+ known.issue = String((payload && (payload.intake_id || payload.previous_issue)) || known.issue);
375
+ known.answer = String((payload && payload.answer) || known.answer);
376
+ var lesson = payload && payload.lesson;
377
+ if (lesson && lesson.id) known.lessonId = String(lesson.id);
378
+ if (known.state === "converted") bumpStat("reportsConverted");
379
+ }
380
+ return known;
381
+ }
382
+ }
383
+ var lesson2 = payload && payload.lesson;
384
+ var entry = push(log.intakes, {
385
+ callId: String(callId || ""),
386
+ at: Date.now(),
387
+ text: String(text || ""),
388
+ state: phase === "result" ? intakeState(payload) : "filing",
389
+ receipt: String((payload && (payload.receipt || payload.note)) || ""),
390
+ issue: String((payload && (payload.intake_id || payload.previous_issue)) || ""),
391
+ answer: String((payload && payload.answer) || ""),
392
+ lessonId: String((lesson2 && lesson2.id) || ""),
393
+ });
394
+ bumpStat("reportsFiled");
395
+ if (entry.state === "converted") bumpStat("reportsConverted");
396
+ notify(log.sessionId);
397
+ return entry;
398
+ }
399
+
400
+ /** Concatenate the text blocks of a result node; "" when there are none. */
401
+ function textFrom(blocks) {
402
+ var out = [];
403
+ var list = Array.isArray(blocks) ? blocks : [];
404
+ for (var i = 0; i < list.length; i++) {
405
+ var block = list[i];
406
+ if (block && block.type === "text" && typeof block.text === "string") out.push(block.text);
407
+ }
408
+ return out.join("\n");
409
+ }
410
+
411
+ /** The tool's JSON payload: the first text block that parses. null when none does. */
412
+ function payloadOf(block) {
413
+ var list = (block && block.content) || [];
414
+ for (var i = 0; i < list.length; i++) {
415
+ var item = list[i];
416
+ if (!item || item.type !== "text" || typeof item.text !== "string") continue;
417
+ try {
418
+ return JSON.parse(item.text);
419
+ } catch (error) {
420
+ /* not this block; the raw disclosure still shows it */
421
+ }
422
+ }
423
+ return null;
424
+ }
425
+
426
+ /** The query this call searched for: from the result when settled, else from the arguments. */
427
+ function queryOf(block, payload) {
428
+ if (payload && typeof payload.query === "string") return payload.query;
429
+ var raw = block && (block.argsRaw || (block.call && block.call.argsRaw));
430
+ if (typeof raw !== "string") return "";
431
+ try {
432
+ var args = JSON.parse(raw);
433
+ if (args && typeof args.query === "string") return args.query;
434
+ } catch (error) {
435
+ /* preparing or partial arguments */
436
+ }
437
+ return "";
438
+ }
439
+
440
+ /** The call's arguments: from the settled result when there is one, else from the raw prefix. */
441
+ function argsOf(block, payload) {
442
+ if (payload && typeof payload === "object" && typeof payload.query === "string") return payload;
443
+ var raw = block && (block.argsRaw || (block.call && block.call.argsRaw));
444
+ if (typeof raw !== "string") return null;
445
+ try {
446
+ return JSON.parse(raw);
447
+ } catch (error) {
448
+ return null;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * "1 lesson" / "2 lessons" — the panel prints counts next to their noun, so it needs the noun.
454
+ *
455
+ * `many` is explicit rather than `singular + "s"` because English is not regular: the first
456
+ * version of this helper printed "2 searchs", which its own verification run showed and the
457
+ * commit message then misquoted. Passing the plural at the call site makes the irregular one
458
+ * impossible to get wrong silently.
459
+ */
460
+ function count(n, singular, many) {
461
+ return n + " " + (n === 1 ? singular : many || singular + "s");
462
+ }
463
+
464
+ /** mm:ss for today, else the date — the event's own time, never the render time. */
465
+ function clockOf(at) {
466
+ try {
467
+ var when = new Date(at);
468
+ var today = new Date();
469
+ var sameDay = when.toDateString() === today.toDateString();
470
+ return sameDay
471
+ ? when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
472
+ : when.toLocaleDateString() + " " + when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
473
+ } catch (error) {
474
+ return "";
475
+ }
476
+ }
477
+
478
+ function timeRange(lesson) {
479
+ return lesson.count > 1 ? clockOf(lesson.first) + " → " + clockOf(lesson.last) : clockOf(lesson.first);
480
+ }
481
+
482
+ /** POST JSON, resolving to the parsed body; rejects only on a transport or HTTP failure. */
483
+ function post(path, body) {
484
+ return fetch(API + path, {
485
+ method: "POST",
486
+ headers: { "Content-Type": "application/json" },
487
+ body: JSON.stringify(body),
488
+ }).then(function (response) {
489
+ if (!response.ok) throw new Error(path + " answered " + response.status);
490
+ return response.json().catch(function () {
491
+ return {};
492
+ });
493
+ });
494
+ }
495
+
496
+ /**
497
+ * The tool-call row for `misakanet_search`: what came back — and nothing else.
498
+ *
499
+ * No verdict here on purpose. At search time nobody can answer "did it help?" yet, and a button
500
+ * that cannot be answered honestly trains people to click it by reflex, which would poison the
501
+ * one signal only a human can produce. The row's other job is to hand the verdict surface
502
+ * something to be about.
503
+ */
504
+ function MisakanetSearchRow(props) {
505
+ var phase = props && props.phase;
506
+ var block = props && props.block;
507
+ var payload = phase === "result" ? payloadOf(block) : null;
508
+ var query = queryOf(block, payload);
509
+ var results = payload && Array.isArray(payload.results) ? payload.results : [];
510
+ var top = results[0] || null;
511
+
512
+ // Recorded for the panel, and handed to the verdict surface. Guarded by the call id: a React
513
+ // re-render must not turn one search into two rows or two "lessons reused".
514
+ var log = sessionLog(props && props.sessionId);
515
+ if (phase === "result" && log) {
516
+ recordSearch(log, props && props.callId, { payload: payload, query: query, results: results });
517
+ }
518
+
519
+ var head = [h("strong", { key: "brand" }, "MisakaNet")];
520
+
521
+ if (phase === "preparing") {
522
+ head.push(h("span", { key: "state", style: DIM }, " searching…"));
523
+ } else if (phase === "start") {
524
+ head.push(h("span", { key: "state" }, " " + (query || "searching…")));
525
+ } else if (payload && payload.no_match) {
526
+ head.push(h("span", { key: "state" }, " no lesson matched"));
527
+ } else if (results.length) {
528
+ head.push(h("span", { key: "state" }, " " + results.length + (results.length === 1 ? " lesson" : " lessons")));
529
+ head.push(h("span", { key: "top", style: DIM }, " · top: "));
530
+ head.push(h("span", { key: "title" }, top.title || top.id || "(untitled)"));
531
+ // Only fields the DEFAULT payload actually carries. `detail: "compact"` is
532
+ // {id, title, problem, freshness, evidence_level}; `domain` arrives only at
533
+ // `detail: "summary"` and `path` only at `"full"`. The first version of this row promised
534
+ // a domain and therefore printed `(E3)` for every ordinary call — and the preview missed
535
+ // it because the preview's fixture was hand-written with a domain in it. Richer facts
536
+ // (domain, path) belong to the panel, which asks for them on purpose.
537
+ // `tests/test_dsh_plugin_surface.py` parses this key set out of the worker's own tool
538
+ // description and fails if this row reaches past it.
539
+ var facts = [top.evidence_level, top.freshness].filter(Boolean).join(" · ");
540
+ if (facts) head.push(h("span", { key: "meta", style: DIM }, " (" + facts + ")"));
541
+ } else if (phase === "result") {
542
+ head.push(h("span", { key: "state", style: DIM }, " result could not be read"));
543
+ }
544
+
545
+ var body = [h("div", { key: "body", style: BOX }, h("div", null, head))];
546
+ var raw = textFrom(block && block.content);
547
+ if (raw) {
548
+ body.push(
549
+ h("details", { key: "raw" },
550
+ h("summary", { style: Object.assign({}, DIM, { cursor: "pointer", fontSize: "12px" }) }, "raw result"),
551
+ h("pre", { style: { fontSize: "11px", overflowX: "auto", whiteSpace: "pre-wrap" } }, raw))
552
+ );
553
+ }
554
+ return h("div", null, body);
555
+ }
556
+
557
+ /**
558
+ * The verdict on a finalized assistant message: did the lesson the agent just used help?
559
+ *
560
+ * Returns null when this session's searches left nothing to judge, once a verdict exists, and on
561
+ * every message except the one it was offered on — an entry with nothing to say leaves the host's
562
+ * own action row exactly as it was.
563
+ */
564
+ function MisakanetVerdictAction(props) {
565
+ var sessionId = props && props.sessionId;
566
+ var messageId = String((props && props.messageId) || "");
567
+ var log = sessionId ? sessions[sessionId] : null;
568
+ var pending = latestPending(log);
569
+
570
+ var state = react.useState(null);
571
+ var verdict = state[0];
572
+ var setVerdict = state[1];
573
+
574
+ // Ask on the first finalized message after the search, and never again: the question is worth
575
+ // one appearance, and repeating it on every later message is how a prompt becomes a nuisance.
576
+ react.useEffect(function () {
577
+ var live = sessionId ? sessions[sessionId] : null;
578
+ if (live && latestPending(live) && !live.shownFor) live.shownFor = messageId;
579
+ }, [sessionId, messageId]);
580
+
581
+ if (!pending) return null;
582
+ if (log.shownFor && log.shownFor !== messageId) return null;
583
+
584
+ function send(kind) {
585
+ // 👍 carries the lesson id and nothing else. 👎 has to carry the search text: that is what
586
+ // turns one annoyed person into a counted gap, and the line below says so before the click.
587
+ var call = kind === "helpful"
588
+ ? post("/api/helpful", { lesson_id: pending.lessonId })
589
+ : post("/api/feedback", { query: pending.query, lesson_id: pending.lessonId, feedback: "irrelevant" });
590
+ call.then(
591
+ function (answer) {
592
+ recordVote(log, pending, kind, answer);
593
+ setVerdict({
594
+ kind: kind,
595
+ count: answer && typeof answer.count === "number" ? answer.count : null,
596
+ });
597
+ },
598
+ function () {
599
+ setVerdict({ error: "could not reach misakanet.org — nothing was recorded" });
600
+ }
601
+ );
602
+ }
603
+
604
+ if (verdict && verdict.error) {
605
+ return h("span", { style: Object.assign({}, DIM, { fontSize: "12px" }) }, "⚠ " + verdict.error);
606
+ }
607
+
608
+ if (verdict) {
609
+ var done = verdict.kind === "helpful"
610
+ ? "recorded — this lesson now has " + (verdict.count === null ? "your" : verdict.count) + " human confirmation(s)"
611
+ : "recorded as not helpful — it feeds the unsolved-gap map";
612
+ return h("span", { style: Object.assign({}, DIM, { fontSize: "12px" }) }, done);
613
+ }
614
+
615
+ // `flexWrap` + a full-basis disclosure keeps the action row one short line when the pane is
616
+ // narrow: the buttons and the label stay together, and the "what this sends" line drops to its
617
+ // own line instead of stretching the row (seen in the rendered preview).
618
+ return h("span", {
619
+ style: { fontSize: "12px", display: "inline-flex", flexWrap: "wrap", alignItems: "center", rowGap: "2px" },
620
+ },
621
+ h("span", { style: Object.assign({}, DIM, { marginRight: "6px" }) }, "MisakaNet:"),
622
+ h("button", {
623
+ type: "button",
624
+ style: BUTTON,
625
+ title: "Sends this lesson's id to misakanet.org as reuse evidence. Sends nothing else.",
626
+ onClick: function () { send("helpful"); },
627
+ }, "👍 Helpful"),
628
+ h("button", {
629
+ type: "button",
630
+ style: BUTTON,
631
+ title: "Sends this lesson's id and the search text, so the gap gets counted.",
632
+ onClick: function () { send("irrelevant"); },
633
+ }, "👎 Not what I needed"),
634
+ // Words, not the emoji, carry this line: an environment without an emoji font renders the
635
+ // buttons as boxes, and a disclosure that turns into "□ sends the lesson id" is worse than
636
+ // none. The button labels survive the same way.
637
+ h("span", { style: Object.assign({}, DIM, { flexBasis: "100%" }) },
638
+ "Helpful sends the lesson id · not-helpful also sends the search text (gap stats)")
639
+ );
640
+ }
641
+
642
+ /**
643
+ * The tool-call row for `misakanet_submit_intake`: what was filed, and what the server said.
644
+ *
645
+ * It exists for two reasons. The person should see that something *was* filed and in which of the
646
+ * four states it sits — and the panel needs to learn about it at all, which it can only do from a
647
+ * row like this one, because the transcript is the only place these events pass through.
648
+ */
649
+ function MisakanetIntakeRow(props) {
650
+ var phase = props && props.phase;
651
+ var block = props && props.block;
652
+ var payload = phase === "result" ? payloadOf(block) : null;
653
+ var args = argsOf(block, null);
654
+ var text = args && typeof args.problem === "string" ? args.problem : "";
655
+ var log = sessionLog(props && props.sessionId);
656
+ if (log) recordIntake(log, props && props.callId, phase, payload, text);
657
+
658
+ var state = phase === "result" ? intakeState(payload) : "filing";
659
+ var labels = {
660
+ filing: "filing…",
661
+ pending: "filed — waiting for a maintainer",
662
+ answered: "answered",
663
+ already_have: "not filed — the corpus already covers this",
664
+ converted: "your report is cited by a lesson",
665
+ unknown: "submitted",
666
+ };
667
+ var head = [
668
+ h("strong", { key: "brand" }, "MisakaNet report"),
669
+ h("span", { key: "state", style: DIM }, " " + (labels[state] || state)),
670
+ ];
671
+ var body = [h("div", { key: "head" }, head)];
672
+ if (text) body.push(h("div", { key: "text", style: DIM }, text.length > 160 ? text.slice(0, 160) + "…" : text));
673
+ if (payload && payload.answer) {
674
+ body.push(h("div", { key: "answer", style: { marginTop: "4px" } }, payload.answer));
675
+ } else if (payload && (payload.receipt || payload.note)) {
676
+ body.push(h("div", { key: "receipt", style: Object.assign({}, DIM, { marginTop: "4px" }) },
677
+ String(payload.receipt || payload.note)));
678
+ }
679
+ if (payload && payload.lesson && payload.lesson.id) {
680
+ body.push(h("div", { key: "lesson", style: DIM },
681
+ "covered by " + payload.lesson.id + (payload.lesson.similarity ? " (similarity " + payload.lesson.similarity + ")" : "")));
682
+ }
683
+ return h("div", { style: BOX }, body);
684
+ }
685
+
686
+ /**
687
+ * The panel: what this session asked, what came back, what was filed, what it is worth, and the
688
+ * counters — the review surface, as opposed to the two judging/visibility rows in the transcript.
689
+ *
690
+ * Every number here has a named source: this session's own log (the rows above), the browser's own
691
+ * counters, one public `GET /api/helpful` per lesson, and nothing else. The limits are printed in
692
+ * the panel rather than hidden: a reload loses the log, and the local voice hook is not ours to
693
+ * read.
694
+ */
695
+ function MisakanetPanel(props) {
696
+ var sessionId = props && props.sessionId;
697
+ var revisionState = react.useState(0);
698
+ var setRevision = revisionState[1];
699
+ var voiceState = react.useState(voiceEnabled());
700
+ var voice = voiceState[0];
701
+ var setVoice = voiceState[1];
702
+ var trustState = react.useState({});
703
+ var trust = trustState[0];
704
+ var setTrust = trustState[1];
705
+
706
+ react.useEffect(function () {
707
+ return subscribe(sessionId, function () {
708
+ setRevision(function (n) { return n + 1; });
709
+ });
710
+ }, [sessionId]);
711
+
712
+ var log = sessionId ? sessions[sessionId] : null;
713
+ var searches = log ? log.searches : [];
714
+ var intakes = log ? log.intakes : [];
715
+ var lessons = log ? Object.keys(log.lessons).map(function (id) { return log.lessons[id]; }) : [];
716
+ var stats = browserStats();
717
+ var withLesson = searches.filter(function (s) { return s.lessonId; }).length;
718
+ var noMatch = searches.filter(function (s) { return s.noMatch && !s.lessonId; }).length;
719
+ var votes = log ? log.votes : [];
720
+ var helpful = votes.filter(function (v) { return v.kind === "helpful"; }).length;
721
+ var converted = intakes.filter(function (r) { return r.state === "converted"; }).length;
722
+ var lastCue = "";
723
+ for (var i = searches.length - 1; i >= 0; i--) {
724
+ if (searches[i].voice) { lastCue = searches[i].voice; break; }
725
+ }
726
+
727
+ // One request per lesson per session, whatever the log does. The effect below re-runs on every
728
+ // revision, and a second run used to start while the first was still in flight (`trust` is still
729
+ // undefined then) — the live render measured 8 calls for 4 lessons because of exactly that.
730
+ var asked = react.useRef(Object.create(null));
731
+
732
+ react.useEffect(function () {
733
+ var wanted = lessons.filter(function (lesson) {
734
+ return trust[lesson.lessonId] === undefined && !asked.current[lesson.lessonId];
735
+ });
736
+ for (var i = 0; i < wanted.length; i++) {
737
+ (function (lesson) {
738
+ asked.current[lesson.lessonId] = true;
739
+ fetch(API + "/api/helpful?lesson_id=" + encodeURIComponent(lesson.lessonId))
740
+ .then(function (response) { return response.ok ? response.json() : null; })
741
+ .then(function (body) {
742
+ setTrust(function (prev) {
743
+ var next = Object.assign({}, prev);
744
+ next[lesson.lessonId] = body && typeof body.count === "number" ? body.count : null;
745
+ return next;
746
+ });
747
+ })
748
+ .catch(function () {
749
+ setTrust(function (prev) {
750
+ var next = Object.assign({}, prev);
751
+ next[lesson.lessonId] = null;
752
+ return next;
753
+ });
754
+ });
755
+ })(wanted[i]);
756
+ }
757
+ }, [revisionState[0], lessons.length]);
758
+
759
+ var rows = [];
760
+ // The same mark as the plugin-list icon and the sidebar chip: the conversation ring has no icon
761
+ // channel, so the panel body is the one place the tab can show what it belongs to.
762
+ rows.push(h("div", { key: "title", style: { fontSize: "15px", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px" } },
763
+ MisakanetGlyph({ size: 18 }), h("span", null, "MisakaNet")));
764
+ rows.push(h("div", { key: "scope", style: DIM },
765
+ "this session · counters local to this browser · no account, nothing uploaded but your votes and reports"));
766
+
767
+ rows.push(h("div", { key: "strip", style: Object.assign({}, BOX, { marginTop: "8px" }) },
768
+ count(searches.length, "search", "searches") + " · " + withLesson + " with a lesson · " +
769
+ noMatch + " nothing found · " + count(lessons.length, "lesson") + " surfaced · " +
770
+ count(votes.length, "vote") + " (" + helpful + " helpful) · " +
771
+ count(intakes.length, "report") + " (" + converted + " became a lesson)"));
772
+
773
+ // ① what this session asked
774
+ rows.push(h("div", { key: "s1", style: { marginTop: "12px", fontWeight: 600 } }, "What this session asked"));
775
+ if (!searches.length) {
776
+ rows.push(h("div", { key: "s1e", style: DIM }, "No MisakaNet search has run in this session yet."));
777
+ } else {
778
+ var searchRows = searches.slice().reverse().slice(0, 20).map(function (entry) {
779
+ var vote = entry.voted ? (entry.voteKind === "helpful" ? "Helpful" : "not helpful") : (entry.lessonId ? "not voted" : "no lesson");
780
+ return h("div", { key: "q" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
781
+ h("span", { style: DIM }, clockOf(entry.at) + " "),
782
+ h("span", {}, entry.query || "(no query text)"),
783
+ h("span", { style: DIM }, " " + (entry.noMatch ? "nothing found" : count(entry.hits, "lesson"))),
784
+ entry.title ? h("span", { style: DIM }, " · top: " + entry.title + (entry.evidence ? " (" + entry.evidence + ")" : "")) : null,
785
+ h("span", { style: DIM }, " · " + vote));
786
+ });
787
+ rows.push(h("div", { key: "s1r" }, searchRows));
788
+ }
789
+
790
+ // ② reports filed
791
+ rows.push(h("div", { key: "s2", style: { marginTop: "12px", fontWeight: 600 } }, "Reports you filed"));
792
+ var openReports = intakes.filter(function (entry) {
793
+ return entry.state !== "converted" && entry.state !== "answered" && entry.state !== "already_have";
794
+ });
795
+ if (openReports.length) {
796
+ // There is deliberately no "check again" button here. The only way to pull a receipt is to
797
+ // submit the same text again, and the server's dedup window is finite (a week): a
798
+ // page-triggered re-submit could file a *second* GitHub issue for the same problem. A UI
799
+ // is not allowed to do that, so the panel says who can re-check instead of offering it —
800
+ // and only for the reports that are actually still open.
801
+ rows.push(h("div", { key: "s2h", style: DIM },
802
+ count(openReports.length, "pending report") + " is re-checked when that text is submitted again — by the agent, not by this page."));
803
+ }
804
+ if (!intakes.length) {
805
+ rows.push(h("div", { key: "s2e", style: DIM }, "Nothing filed from this session."));
806
+ } else {
807
+ rows.push(h("div", { key: "s2r" }, intakes.slice().reverse().map(function (entry) {
808
+ var labels = {
809
+ filing: "filing", pending: "filed · waiting", answered: "answered",
810
+ already_have: "not filed — already covered", converted: "cited by a lesson", unknown: "submitted",
811
+ };
812
+ var issue = String(entry.issue || "").replace(/^issue-/, "#").replace(/^.*\/issues\//, "#");
813
+ return h("div", { key: "r" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
814
+ h("span", { style: DIM }, clockOf(entry.at) + " "),
815
+ issue ? h("span", { style: DIM }, issue + " ") : null,
816
+ h("span", { style: DIM }, (labels[entry.state] || entry.state) + " "),
817
+ h("span", {}, entry.text ? (entry.text.length > 90 ? entry.text.slice(0, 90) + "…" : entry.text) : "(text not captured)"),
818
+ entry.receipt ? h("div", { style: DIM }, entry.receipt) : null,
819
+ entry.answer ? h("div", {}, entry.answer) : null);
820
+ })));
821
+ }
822
+
823
+ // ③ how much these lessons are trusted
824
+ rows.push(h("div", { key: "s3", style: { marginTop: "12px", fontWeight: 600 } }, "How much these lessons are trusted"));
825
+ if (!lessons.length) {
826
+ rows.push(h("div", { key: "s3e", style: DIM }, "No lesson surfaced yet."));
827
+ } else {
828
+ // The rule once, not per row: it is the same sentence for every lesson, and repeating it
829
+ // four times buries the numbers it is there to explain.
830
+ rows.push(h("div", { key: "s3rule", style: DIM },
831
+ "The level is the lesson's own stored evidence. The count is other people's confirmations, not yours: " +
832
+ "one makes the reuse event appear, the second is what agents read as E4."));
833
+ rows.push(h("div", { key: "s3r" }, lessons.map(function (lesson) {
834
+ var confirmations = trust[lesson.lessonId];
835
+ var counted = confirmations === undefined ? "checking…"
836
+ : (confirmations === null ? "unavailable"
837
+ : count(confirmations, "human confirmation") + (confirmations >= 2 ? " → E4" : ""));
838
+ return h("div", { key: "l" + lesson.lessonId, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
839
+ h("span", {}, lesson.title || lesson.lessonId),
840
+ h("span", { style: DIM }, " " + (lesson.evidence || "no level") + " · " + counted +
841
+ " · used " + lesson.count + "× · " + timeRange(lesson)));
842
+ })));
843
+ }
844
+
845
+ // ④ your activity, two scopes
846
+ rows.push(h("div", { key: "s4", style: { marginTop: "12px", fontWeight: 600 } }, "Your activity"));
847
+ rows.push(h("div", { key: "s4r", style: BOX },
848
+ h("div", {}, "this session: " + count(searches.length, "search", "searches") + " · " + count(lessons.length, "lesson") +
849
+ " reused · " + count(votes.length, "vote") + " · " + count(intakes.length, "report") + " filed · " +
850
+ count(converted, "conversion")),
851
+ h("div", {}, "on this browser: " + count(stats.searches || 0, "search", "searches") + " · " +
852
+ count(stats.lessonsReused || 0, "lesson") + " reused · " + count(stats.votes || 0, "vote") + " · " +
853
+ count(stats.reportsFiled || 0, "report") + " filed · " + count(stats.reportsConverted || 0, "conversion")),
854
+ h("div", { style: DIM }, "The browser column survives a reload; the session column is rebuilt from this conversation's transcript and starts again in a new session.")));
855
+
856
+ // ⑤ voice
857
+ rows.push(h("div", { key: "s5", style: { marginTop: "12px", fontWeight: 600 } }, "Voice"));
858
+ rows.push(h("div", { key: "s5r", style: BOX },
859
+ h("button", {
860
+ type: "button",
861
+ style: BUTTON,
862
+ onClick: function () {
863
+ var next = !voiceEnabled();
864
+ setVoiceEnabled(next);
865
+ setVoice(next);
866
+ },
867
+ }, voice ? "Voice cues: on" : "Voice cues: off"),
868
+ lastCue && h("button", {
869
+ type: "button",
870
+ style: BUTTON,
871
+ onClick: function () { playCue(lastCue); },
872
+ }, "Play “" + lastCue + "”"),
873
+ h("div", { style: DIM }, lastCue
874
+ ? "The cue is the server's own choice for the last search (" + lastCue + "), not the UI's guess. Browser playback only, and off by default — nothing plays until you turn it on or press play."
875
+ : "No cue yet: the server names one on the first search of the session."),
876
+ h("div", { style: DIM }, "The local hook is a different thing: it is installed with `--voice` and mutes with MISAKANET_VOICE=0, plays through this machine's own player, and can also raise a desktop notification. A page cannot read or change it, so this panel does not report its state.")));
877
+
878
+ rows.push(h("div", { key: "foot", style: Object.assign({}, DIM, { marginTop: "12px" }) },
879
+ "Every number above comes from this conversation's own transcript rows plus one public GET per lesson " +
880
+ "(/api/helpful). The trust counts are other people's confirmations, not yours. Reloading the window loses " +
881
+ "the session rows; the browser counters stay. Receipts shown here are what the agent's own calls returned — " +
882
+ "a pending report is re-checked when the agent asks again, not by this page."));
883
+
884
+ return h("div", { style: { fontSize: "12px", lineHeight: "1.7", padding: "4px 2px" } }, rows);
885
+ }
886
+
887
+ /**
888
+ * The panel's glyph: the same mark as `icon.svg` and the site's fallback avatar, drawn inline.
889
+ *
890
+ * Inline rather than a file because the host asks a tab for a component, and because a browser half
891
+ * that fetched its own icon would add a request and a failure mode for a shape this small. It uses
892
+ * `currentColor`, so it follows the host's theme instead of carrying its own.
893
+ */
894
+ function MisakanetGlyph(props) {
895
+ // The host calls an icon with its own `IconProps` — `{size, className}` — and draws it in a
896
+ // 26px box, coloured through `currentColor`. The chip seat passes nothing, so 16px is the
897
+ // fallback the first-party chips use.
898
+ var options = props || {};
899
+ var side = options.size || 16;
900
+ return h("svg", {
901
+ width: side, height: side, viewBox: "0 0 64 64", fill: "none",
902
+ className: options.className ? "misakanet-glyph " + options.className : "misakanet-glyph",
903
+ "aria-hidden": "true", focusable: "false", style: { display: "block", flex: "none" },
904
+ }, h("path", {
905
+ d: "M16 44V20h7l9 14 9-14h7v24h-7V31.5L34.5 44h-5L23 31.5V44h-7Z",
906
+ fill: "currentColor",
907
+ }));
908
+ }
909
+
910
+ /** The right-sidebar chip: the glyph, then the name — the host inlines this into its own chip. */
911
+ function MisakanetTabTitle() {
912
+ return h(react.Fragment, null, MisakanetGlyph({ size: 16 }), h("span", { style: { paddingLeft: "4px" } }, "MisakaNet"));
913
+ }
914
+
915
+ /**
916
+ * The left column's icon seat (`sidebar.panellist`, root scope → it does not come and go with a
917
+ * session). The owner supplies `{size, active}` and owns the button: the id we register here is the
918
+ * key the main column is dispatched with, which is why `PANEL_ID` is used for both.
919
+ */
920
+ function MisakanetPanelIcon(props) {
921
+ var options = props || {};
922
+ return h("span", {
923
+ style: {
924
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
925
+ opacity: options.active ? 1 : 0.75,
926
+ },
927
+ }, MisakanetGlyph({ size: options.size || 22 }));
928
+ }
929
+
930
+ /**
931
+ * The main column page the icon above opens ("Central panel selected by sidebar entry id").
932
+ *
933
+ * A `main` occupant gets no session binding, so this is the browser-scoped view: what this browser
934
+ * has contributed, how trust is counted, the voice switch. The per-session story stays in the session's
935
+ * right-sidebar tab, and the first line says so rather than letting the zeros read as a bug.
936
+ */
937
+ function MisakanetMainView(props) {
938
+ return h("div", { style: { height: "100%", overflowY: "auto", padding: "28px 32px 48px" } },
939
+ h("div", { style: { maxWidth: "860px", margin: "0 auto" } },
940
+ h("p", { style: { margin: "0 0 14px", color: "inherit", opacity: 0.7, fontSize: "13px", lineHeight: "1.5" } },
941
+ "The left column opens this page; the session-scoped counters live in the session's " +
942
+ "right-sidebar MisakaNet tab. The numbers here are what this browser has contributed."),
943
+ h(MisakanetPanel, { sessionId: props && props.sessionId })));
944
+ }
945
+
946
+ /** Cordis services this half needs before it may run. */
947
+ var inject = ["slots"];
948
+
949
+ /**
950
+ * Register both surfaces. Each registration is an effect, so unloading the plugin takes them with
951
+ * it, and `dsh.client.inject` guarantees the packages that declare the slots are already there.
952
+ */
953
+ function apply(ctx) {
954
+ // Every surface registers through one guarded step, because a throw here is not a local
955
+ // failure: the client runner marks the plugin's fiber `failed`, the web boot audit reports
956
+ // `misakanet: failed`, and the shell shows "Failed to load plugins" over the whole UI.
957
+ // A UI plugin may lose a surface; it may not lose the page — and it must say what it lost.
958
+ var failures = [];
959
+
960
+ function step(label, register) {
961
+ try {
962
+ return register();
963
+ } catch (error) {
964
+ failures.push(label + ": " + (error && error.message ? error.message : String(error)));
965
+ return undefined;
966
+ }
967
+ }
968
+
969
+ // EVERY registration waits for its slot's declaration (`ctx.slots.inject`), because most of
970
+ // these are **child slots**: `tool.call.toolview` and `conversation.chat.assistant-actions` are
971
+ // declared by a *parent* entry's `children` table, and registering into one before that
972
+ // declaration is committed throws
973
+ // `slot "…" is not declared (a parent entry's children table must declare it)`.
974
+ //
975
+ // That throw is what broke the whole plugin on 2026-10-01: the first toolview registration
976
+ // raised, `apply()` aborted, the client runner marked the fiber `failed`, the shell showed
977
+ // "Failed to load plugins / misakanet: failed", and **nothing** registered — not even the panel
978
+ // tab, which was never itself at fault. `slots.inject` runs the callback immediately when the
979
+ // declaration already exists and otherwise inside the declaring call, so it is correct in both
980
+ // orders. (First-party plugins do exactly this; registering directly does not work.)
981
+ function registerWhenDeclared(slot, options, component, label) {
982
+ return step(label, function () {
983
+ return ctx.effect(function () {
984
+ return ctx.slots.inject(slot, function () {
985
+ return ctx.slots.register(options, component);
986
+ });
987
+ }, "misakanet: " + label);
988
+ });
989
+ }
990
+
991
+ for (var i = 0; i < TOOL_KEYS.length; i++) {
992
+ registerWhenDeclared("tool.call.toolview", { name: "tool.call.toolview", key: TOOL_KEYS[i] },
993
+ MisakanetSearchRow, "search row " + TOOL_KEYS[i]);
994
+ }
995
+ for (var j = 0; j < INTAKE_KEYS.length; j++) {
996
+ registerWhenDeclared("tool.call.toolview", { name: "tool.call.toolview", key: INTAKE_KEYS[j] },
997
+ MisakanetIntakeRow, "intake row " + INTAKE_KEYS[j]);
998
+ }
999
+ registerWhenDeclared("conversation.chat.assistant-actions",
1000
+ { name: "conversation.chat.assistant-actions", id: VERDICT_ID, order: 20 },
1001
+ MisakanetVerdictAction, "verdict action");
1002
+ registerWhenDeclared("conversation.view",
1003
+ { name: "conversation.view", id: PANEL_ID, order: 20, label: function () { return "MisakaNet"; } },
1004
+ MisakanetPanel, "conversation tab");
1005
+
1006
+ // The left column, where the shipped "Plugins" and "Settings" entries live. Two seats, one id:
1007
+ // `sidebar.panellist` draws the icon in the rail (a shortcut), and `main` is the page the sidebar
1008
+ // dispatches that id to — "Central panel selected by sidebar entry id" (client-ui-layout).
1009
+ // They are separate registrations on purpose: with only the first, the icon would be a dead end.
1010
+ registerWhenDeclared("sidebar.panellist",
1011
+ { name: "sidebar.panellist", id: PANEL_ID, order: 30, label: function () { return "MisakaNet"; } },
1012
+ MisakanetPanelIcon, "left sidebar icon");
1013
+ registerWhenDeclared("main",
1014
+ { name: "main", key: PANEL_ID },
1015
+ MisakanetMainView, "main panel");
1016
+
1017
+ // The right sidebar ships from 0.1.5 on, so this rides a deferred service inject: on a line
1018
+ // without the registry the callback never runs, the fiber never pends, and the conversation tab
1019
+ // above is the whole plugin. Inside, the registry is re-proved and every disposer is collected.
1020
+ step("sidebar tab", function () {
1021
+ if (typeof ctx.inject !== "function") throw new Error("ctx.inject is not available");
1022
+ return ctx.inject(["sidebarRightTabs"], function (scoped) {
1023
+ var disposers = [];
1024
+ function own(result) {
1025
+ if (typeof result === "function") disposers.push(result);
1026
+ }
1027
+ function unwind() {
1028
+ for (var k = 0; k < disposers.length; k++) {
1029
+ try { disposers[k](); } catch (error) { /* already leaving */ }
1030
+ }
1031
+ disposers = [];
1032
+ }
1033
+ try {
1034
+ var tabs = scoped && scoped.sidebarRightTabs;
1035
+ if (!tabs || typeof tabs.register !== "function") return;
1036
+ own(tabs.register({
1037
+ id: PANEL_ID,
1038
+ kind: PANEL_KIND,
1039
+ title: function () { return "MisakaNet"; },
1040
+ guide: [{
1041
+ id: "panel",
1042
+ order: 30,
1043
+ title: function () { return "MisakaNet"; },
1044
+ description: function () { return "What this session asked, what came back, and what you filed"; },
1045
+ icon: MisakanetGlyph,
1046
+ }],
1047
+ }));
1048
+ own(scoped.slots.inject("sidebar.right.pane.tab", function () {
1049
+ return scoped.slots.register({ name: "sidebar.right.pane.tab", key: PANEL_ID }, MisakanetPanel);
1050
+ }));
1051
+ own(scoped.slots.inject("sidebar.right.pane.tab.title", function () {
1052
+ return scoped.slots.register({ name: "sidebar.right.pane.tab.title", key: PANEL_ID }, MisakanetTabTitle);
1053
+ }));
1054
+ } catch (error) {
1055
+ unwind();
1056
+ return undefined;
1057
+ }
1058
+ return unwind;
1059
+ });
1060
+ });
1061
+
1062
+ if (failures.length) {
1063
+ // Loud in the console, harmless in the UI: the surfaces that did register keep working, and
1064
+ // this line is what turns "misakanet: failed" into a name and a message next time.
1065
+ try {
1066
+ console.warn("misakanet: " + failures.length + " surface(s) did not register — " + failures.join(" | "));
1067
+ } catch (error) { /* no console: nothing else to do */ }
1068
+ }
1069
+ }
1070
+
1071
+ exports.apply = apply;
1072
+ exports.inject = inject;
1073
+ exports.name = "misakanet-client";
1074
+ return module.exports;
1075
+ },
1076
+ });