misakanet 2.39.0 → 2.41.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,2085 @@
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
+ var ROW_ID = "misakanet-mcp";
111
+
112
+ /**
113
+ * Localization. The dictionaries live in this file rather than in `locale/*.json`: those files are the
114
+ * *package metadata* the host reads for the plugin page's title and description (`dsh-app-boot`'s
115
+ * `dictionariesOf`), while UI copy goes through `ctx.locale`. Both matter, and they are different
116
+ * mechanisms.
117
+ *
118
+ * `T` is a module-level binding rather than a prop: the host's `bind` keeps a stable identity and reads
119
+ * the active locale on every call, so a component that re-renders after a locale switch gets the new
120
+ * language from the same function.
121
+ */
122
+ var NS = PANEL_ID;
123
+ var en = {
124
+ "guide.description": "What this session asked, what came back, and what you filed",
125
+ "panel.asked": "What this session asked",
126
+ "panel.asked.none": "No MisakaNet search has run in this session yet.",
127
+ "panel.reports": "Reports you filed",
128
+ "panel.reports.none": "Nothing filed from this session.",
129
+ "panel.trust": "How much these lessons are trusted",
130
+ "panel.trust.none": "No lesson surfaced yet.",
131
+ "panel.activity": "Your activity",
132
+ "panel.voice": "Voice",
133
+ "panel.voice.on": "Voice cues: on",
134
+ "panel.voice.off": "Voice cues: off",
135
+ "overlay.searching": "Searching…",
136
+ "overlay.empty": "Type what broke after /misakanet, e.g. /misakanet pip install timeout",
137
+ "overlay.none": "No lesson covers this yet. Ask the agent to file it (misakanet_submit_intake), or file it at misakanet.org — that is how the next person finds it here.",
138
+ "overlay.failed": "Search failed: {error} — the library is at misakanet.org; nothing was sent but the query.",
139
+ "overlay.footnote": "Reads are anonymous. This overlay sends only the query to misakanet.org; your votes and reports are the only other things a page ever sends.",
140
+ "overlay.close": "Close",
141
+ "settings.voice": "Play voice cues in this browser",
142
+ "settings.voice.note": "(the same switch the panel shows)",
143
+ "settings.density": "How much MisakaNet shows",
144
+ "settings.density.compact": "compact — title and domain",
145
+ "settings.density.full": "full — plus lesson id and description",
146
+ "settings.endpoint": "MCP endpoint",
147
+ "settings.save": "Save",
148
+ "settings.saving": "Saving…",
149
+ "settings.saved": "Saved — applies on the next host start.",
150
+ "settings.refused": "The host refused the write.",
151
+ "settings.writeFailed": "Write failed: {error}",
152
+ "settings.readonly": "This host does not accept writes for the row from here.",
153
+ "settings.patchHint": "The MCP endpoint, transport and timeout live in the profile's cordis.patch.yml, entry id: {row}; this host exposes no writable form for them.",
154
+ "settings.local": "The two switches above are kept in this browser and never uploaded.",
155
+ "footer.action": "Copy session summary",
156
+ "footer.copied": "Copied",
157
+ "footer.nothing": "Nothing yet",
158
+ "footer.failed": "Copy failed",
159
+ "toast.one": "1 lesson",
160
+ "toast.many": "{n} lessons",
161
+ "unit.search": "search",
162
+ "unit.searches": "searches",
163
+ "unit.lesson": "lesson",
164
+ "unit.lessons": "lessons",
165
+ "unit.vote": "vote",
166
+ "unit.votes": "votes",
167
+ "unit.report": "report",
168
+ "unit.reports": "reports",
169
+ "panel.asked.searching": "searching…",
170
+ "panel.asked.noMatch": "no lesson matched",
171
+ "panel.asked.top": "· top: ",
172
+ "panel.asked.noQuery": "(no query text)",
173
+ "panel.asked.nothingFound": "nothing found",
174
+ "panel.asked.voteHelpful": "Helpful",
175
+ "panel.asked.voteNotHelpful": "not helpful",
176
+ "panel.asked.voteNone": "not voted",
177
+ "panel.asked.voteNoLesson": "no lesson",
178
+ "panel.counters": "this session · counters local to this browser · no account, nothing uploaded but your votes and reports",
179
+ "panel.report.title": "MisakaNet report",
180
+ "panel.report.coveredBy": "covered by ",
181
+ "panel.report.similarity": " (similarity ",
182
+ "panel.report.pending": "pending report",
183
+ "panel.report.recheck": " is re-checked when that text is submitted again — by the agent, not by this page.",
184
+ "intake.filing": "filing",
185
+ "intake.waiting": "filed · waiting",
186
+ "intake.answered": "answered",
187
+ "intake.alreadyHave": "not filed — already covered",
188
+ "intake.cited": "cited by a lesson",
189
+ "intake.submitted": "submitted",
190
+ "intake.noText": "(text not captured)",
191
+ "panel.trust.checking": "checking…",
192
+ "panel.trust.unavailable": "unavailable",
193
+ "panel.trust.human": "human confirmation",
194
+ "panel.trust.none": "no level",
195
+ "panel.trust.used": " · used ",
196
+ "panel.trust.footnote": "The level is the lesson's own stored evidence. The count is other people's confirmations, not yours: one makes the reuse event appear, the second is what agents read as E4.",
197
+ "panel.activity.session": "this session: ",
198
+ "panel.activity.browser": "on this browser: ",
199
+ "panel.activity.reused": " reused · ",
200
+ "panel.activity.filed": " filed · ",
201
+ "panel.activity.conversion": "conversion",
202
+ "panel.activity.scope": "The browser column survives a reload; the session column is rebuilt from this conversation's transcript and starts again in a new session.",
203
+ "panel.voice.cue": "The cue is the server's own choice for the last search (",
204
+ "panel.voice.cueTail": "), not the UI's guess. Browser playback only, and off by default — nothing plays until you turn it on or press play.",
205
+ "panel.voice.none": "No cue yet: the server names one on the first search of the session.",
206
+ "panel.voice.play": "Play “",
207
+ "panel.voice.hook": "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.",
208
+ "panel.footnote": "Every number above comes from this conversation's own transcript rows plus one public GET per lesson (/api/helpful). The trust counts are other people's confirmations, not yours. Reloading the window loses the session rows; the browser counters stay. Receipts shown here are what the agent's own calls returned — a pending report is re-checked when the agent asks again, not by this page.",
209
+ "main.footnote": "The left column opens this page; the session-scoped counters live in the session's right-sidebar MisakaNet tab. The numbers here are what this browser has contributed.",
210
+ "tool.rawResult": "raw result",
211
+ "tool.readFailed": "result could not be read",
212
+ "tool.helpfulTitle": "Sends this lesson's id to misakanet.org as reuse evidence. Sends nothing else.",
213
+ "tool.notHelpfulTitle": "Sends this lesson's id and the search text, so the gap gets counted.",
214
+ "tool.helpfulHint": "Helpful sends the lesson id · not-helpful also sends the search text (gap stats)",
215
+ "tool.helpful": "👍 Helpful",
216
+ "tool.notHelpful": "👎 Not what I needed",
217
+ "tool.untitled": "(untitled)",
218
+ "tool.brand": "MisakaNet:",
219
+ "settings.rowReadonly": "Shown read-only: this host builds an editable form only for fields a plugin can change without reloading, and this row's settings (endpoint, transport, timeout) apply on the next host start. Edit them in the profile's patch layer — `cordis.patch.yml`, entry id: {row} — next to the {what} the host already has.",
220
+ "settings.rowNoValues": "No values reported by this host yet; the row runs the bundle patch's defaults.",
221
+ "settings.noWrites": "This host does not accept writes here (read-only).",
222
+ "settings.invalidJson": "invalid JSON",
223
+ "settings.what.form": "form",
224
+ "settings.what.row": "row",
225
+ "panel.activity.withLesson": " with a lesson · ",
226
+ "footer.session": "session: ",
227
+ "footer.actionHint": "Copy the MisakaNet summary of the session that last used it",
228
+ "settings.copyPatch": "Copy patch snippet",
229
+ "settings.copied": "Copied",
230
+ "settings.copyFailed": "Copy failed",
231
+ };
232
+ var zh = {
233
+ "guide.description": "本会话问过什么、拿回了什么、你报了什么",
234
+ "panel.asked": "本会话问过什么",
235
+ "panel.asked.none": "本会话还没有发起过 MisakaNet 检索。",
236
+ "panel.reports": "你报过的问题",
237
+ "panel.reports.none": "本会话没有报过任何问题。",
238
+ "panel.trust": "这些课程的可信度",
239
+ "panel.trust.none": "还没有命中过课程。",
240
+ "panel.activity": "你的活动",
241
+ "panel.voice": "语音",
242
+ "panel.voice.on": "语音提示:开",
243
+ "panel.voice.off": "语音提示:关",
244
+ "overlay.searching": "检索中……",
245
+ "overlay.empty": "在 /misakanet 后面写清楚出了什么问题,例如 /misakanet pip install 超时",
246
+ "overlay.none": "还没有课程覆盖这个问题。可以让 agent 报料(misakanet_submit_intake),或直接去 misakanet.org 提交 —— 下一个人就能在这里搜到它。",
247
+ "overlay.failed": "检索失败:{error} —— 课程库在 misakanet.org;这次只发出了查询本身。",
248
+ "overlay.footnote": "读取是匿名的。这个浮层只把查询发给 misakanet.org;除此之外,页面只会发出你的投票与报料。",
249
+ "overlay.close": "关闭",
250
+ "settings.voice": "在本浏览器播放语音提示",
251
+ "settings.voice.note": "(与面板上那个开关是同一个)",
252
+ "settings.density": "MisakaNet 显示多少内容",
253
+ "settings.density.compact": "精简 —— 标题与领域",
254
+ "settings.density.full": "完整 —— 另加课程 id 与描述",
255
+ "settings.endpoint": "MCP 端点",
256
+ "settings.save": "保存",
257
+ "settings.saving": "保存中……",
258
+ "settings.saved": "已保存 —— 下次宿主启动时生效。",
259
+ "settings.refused": "宿主拒绝了这次写入。",
260
+ "settings.writeFailed": "写入失败:{error}",
261
+ "settings.readonly": "本宿主不允许从这里写入该行。",
262
+ "settings.patchHint": "MCP 端点、传输方式与超时写在 profile 的 cordis.patch.yml 里,条目 id: {row};本宿主没有为它们提供可写表单。",
263
+ "settings.local": "上面两个开关只保存在本浏览器,不会上传。",
264
+ "footer.action": "复制本会话摘要",
265
+ "footer.copied": "已复制",
266
+ "footer.nothing": "还没有内容",
267
+ "footer.failed": "复制失败",
268
+ "toast.one": "命中 1 篇课程",
269
+ "toast.many": "命中 {n} 篇课程",
270
+ "unit.search": "次检索",
271
+ "unit.searches": "次检索",
272
+ "unit.lesson": "篇",
273
+ "unit.lessons": "篇",
274
+ "unit.vote": "票",
275
+ "unit.votes": "票",
276
+ "unit.report": "条报料",
277
+ "unit.reports": "条报料",
278
+ "panel.asked.searching": "检索中……",
279
+ "panel.asked.noMatch": "没有命中任何课程",
280
+ "panel.asked.top": "· 最佳:",
281
+ "panel.asked.noQuery": "(没记下查询原文)",
282
+ "panel.asked.nothingFound": "没有命中",
283
+ "panel.asked.voteHelpful": "有用",
284
+ "panel.asked.voteNotHelpful": "没用",
285
+ "panel.asked.voteNone": "未投票",
286
+ "panel.asked.voteNoLesson": "无课程",
287
+ "panel.counters": "本会话 · 计数只存在本浏览器 · 没有账号,除了你的投票与报料,不上传任何东西",
288
+ "panel.report.title": "MisakaNet 报料",
289
+ "panel.report.coveredBy": "已被 ",
290
+ "panel.report.similarity": "(相似度 ",
291
+ "panel.report.pending": "待处理报料",
292
+ "panel.report.recheck": " 会在同一段文本被再次提交时复查 —— 由 agent 触发,不是这个页面。",
293
+ "intake.filing": "提交中",
294
+ "intake.waiting": "已提交 · 等待中",
295
+ "intake.answered": "已回答",
296
+ "intake.alreadyHave": "未提交 —— 已被覆盖",
297
+ "intake.cited": "已被某篇课程引用",
298
+ "intake.submitted": "已提交",
299
+ "intake.noText": "(没抓到原文)",
300
+ "panel.trust.checking": "核验中……",
301
+ "panel.trust.unavailable": "不可用",
302
+ "panel.trust.human": "人工确认",
303
+ "panel.trust.none": "无等级",
304
+ "panel.trust.used": " · 已复用 ",
305
+ "panel.trust.footnote": "等级来自课程自己存的证据;计数是别人的确认,不是你的:前者让复用事件出现,后者才是 agent 读到的 E4。",
306
+ "panel.activity.session": "本会话:",
307
+ "panel.activity.browser": "本浏览器:",
308
+ "panel.activity.reused": " 次复用 · ",
309
+ "panel.activity.filed": " 条报料 · ",
310
+ "panel.activity.conversion": "转化",
311
+ "panel.activity.scope": "浏览器那一列能跨刷新保留;会话那一列由本次对话的转录重建,换一个会话就从零开始。",
312
+ "panel.voice.cue": "提示音是服务端为上一次检索自己选的(",
313
+ "panel.voice.cueTail": "),不是界面猜的。只在本浏览器播放,且默认关闭 —— 你不打开或点播放,它不会响。",
314
+ "panel.voice.none": "还没有提示音:服务端会在本会话第一次检索时给出。",
315
+ "panel.voice.play": "播放“",
316
+ "panel.voice.hook": "本地钩子是另一回事:用 --voice 安装,用 MISAKANET_VOICE=0 静音,通过本机自己的播放器发声,还能弹桌面通知。页面读不到也改不了它,所以这个面板不报告它的状态。",
317
+ "panel.footnote": "上面的数字都来自本次对话自己的转录,外加每篇课程一次公开 GET(/api/helpful)。可信计数是别人的确认,不是你自己的。重载窗口会丢掉会话行,浏览器计数会留下。这里的回执是 agent 自己调用返回的东西 —— 待处理报料会在 agent 再次提问时复查,而不是这个页面。",
318
+ "main.footnote": "左栏打开的就是这一页;会话级的计数在右栏的 MisakaNet 标签里。这里的数字是本浏览器贡献的部分。",
319
+ "tool.rawResult": "原始结果",
320
+ "tool.readFailed": "结果读不出来",
321
+ "tool.helpfulTitle": "把这篇课程的 id 发给 misakanet.org 作为复用证据,不发别的。",
322
+ "tool.notHelpfulTitle": "把课程 id 和检索文本一起发出去,好把缺口统计进去。",
323
+ "tool.helpfulHint": "「有用」只发课程 id ·「没用」还会发检索文本(用于缺口统计)",
324
+ "tool.helpful": "👍 有用",
325
+ "tool.notHelpful": "👎 不是我需要的",
326
+ "tool.untitled": "(无标题)",
327
+ "tool.brand": "MisakaNet:",
328
+ "settings.rowReadonly": "只读显示:这个宿主只为「改完不必重载」的字段生成可编辑表单,而本行的设置(端点、传输方式、超时)要在宿主下次启动时生效。请到 profile 的 patch 层修改 —— cordis.patch.yml,条目 id:{row} —— 就在宿主已有的{what}旁边。",
329
+ "settings.rowNoValues": "宿主还没有报告任何值;这一行用 bundle patch 里的默认值在跑。",
330
+ "settings.noWrites": "本宿主不允许在这里写入(只读)。",
331
+ "settings.invalidJson": "JSON 不合法",
332
+ "settings.what.form": "表单",
333
+ "settings.what.row": "条目",
334
+ "panel.activity.withLesson": " 篇命中课程 · ",
335
+ "footer.session": "会话:",
336
+ "footer.actionHint": "复制最近一次使用 MisakaNet 的那个会话的摘要",
337
+ "settings.copyPatch": "复制 patch 片段",
338
+ "settings.copied": "已复制",
339
+ "settings.copyFailed": "复制失败",
340
+ };
341
+ var T = function (key, params) { return localize(activeLocale(), key, params); };
342
+ var localeSubscribers = [];
343
+ var localeFace = null;
344
+
345
+ /** Substitute `{name}` placeholders; a missing key falls back to English, then to the key itself. */
346
+ function localize(locale, key, params) {
347
+ var dict = locale === "zh" ? zh : en;
348
+ var text = dict[key] !== undefined ? dict[key] : (en[key] !== undefined ? en[key] : key);
349
+ if (!params) return text;
350
+ return text.replace(/\{(\w+)\}/g, function (match, name) {
351
+ return params[name] === undefined ? match : String(params[name]);
352
+ });
353
+ }
354
+
355
+ function activeLocale() {
356
+ try {
357
+ if (localeFace && typeof localeFace.getLocale === "function") return localeFace.getLocale().active;
358
+ } catch (error) { /* fall through to the provisional default */ }
359
+ return "en";
360
+ }
361
+
362
+ function subscribeLocale(listener) {
363
+ localeSubscribers.push(listener);
364
+ return function () {
365
+ var at = localeSubscribers.indexOf(listener);
366
+ if (at >= 0) localeSubscribers.splice(at, 1);
367
+ };
368
+ }
369
+
370
+ function notifyLocale() {
371
+ var list = localeSubscribers.slice();
372
+ for (var i = 0; i < list.length; i++) list[i]();
373
+ }
374
+
375
+ /** localStorage keys. Both scopes are this browser only — there is no account anywhere here. */
376
+ var STATS_KEY = "misakanet.panel.stats.v1";
377
+ var VOICE_KEY = "misakanet.panel.voice.v1";
378
+ var DENSITY_KEY = "misakanet.panel.density.v1";
379
+ var prefListeners = [];
380
+
381
+ /** Preferences are browser-scope, so their notification is too — not per session. */
382
+ function subscribePrefs(listener) {
383
+ prefListeners.push(listener);
384
+ return function () {
385
+ var at = prefListeners.indexOf(listener);
386
+ if (at >= 0) prefListeners.splice(at, 1);
387
+ };
388
+ }
389
+
390
+ function notifyPrefs() {
391
+ Promise.resolve().then(function () {
392
+ var list = prefListeners.slice();
393
+ for (var i = 0; i < list.length; i++) list[i]();
394
+ });
395
+ }
396
+
397
+ /** How much a MisakaNet surface shows: `compact` keeps it to a title and its domain. */
398
+ function density() {
399
+ try {
400
+ return window.localStorage.getItem(DENSITY_KEY) === "full" ? "full" : "compact";
401
+ } catch (error) {
402
+ return "compact";
403
+ }
404
+ }
405
+
406
+ function setDensity(value) {
407
+ try {
408
+ window.localStorage.setItem(DENSITY_KEY, value === "full" ? "full" : "compact");
409
+ } catch (error) { /* nothing to remember it with */ }
410
+ notifyPrefs();
411
+ }
412
+
413
+ /** How many events one session keeps in memory. The panel is a log, not an archive. */
414
+ var LOG_LIMIT = 200;
415
+
416
+ /**
417
+ * What this session's transcript has shown, per session.
418
+ *
419
+ * This is a **log**, because the panel has to answer "what did I ask, what came back, what did I
420
+ * file, when" — a question a single overwritten slot cannot answer (the first version kept only
421
+ * the latest sighting and fed the verdict; the verdict's fields still live here, see `verdict`).
422
+ *
423
+ * Why the client remembers at all: the host renders the transcript, so the rows are the only
424
+ * place these events pass through. A session projection would be the sturdier source; that is a
425
+ * bigger contract than this slice, so the limits are stated in the panel instead of hidden —
426
+ * a reload loses this log and the panel says so.
427
+ */
428
+ var sessions = Object.create(null);
429
+
430
+ /**
431
+ * The session that moved last.
432
+ *
433
+ * The panel and the verdict row are session-scoped and receive a `sessionId`; the frame-wide seats
434
+ * (`shell.overlay`, `sidebar.footer.action`) receive none — "each action receives only the column
435
+ * state" is the footer's own wording. So the store remembers which session was touched most recently
436
+ * and those two surfaces speak about it.
437
+ */
438
+ var lastSessionId = null;
439
+
440
+ function touchSession(sessionId) {
441
+ if (sessionId) lastSessionId = sessionId;
442
+ }
443
+
444
+ function sessionLog(sessionId) {
445
+ if (!sessionId) return null;
446
+ touchSession(sessionId);
447
+ if (!sessions[sessionId]) {
448
+ sessions[sessionId] = {
449
+ sessionId: sessionId,
450
+ searches: [], intakes: [], votes: [],
451
+ lessons: Object.create(null),
452
+ // The verdict's own state: which message it was offered on, and whether it is spent.
453
+ shownFor: "", voted: false,
454
+ };
455
+ }
456
+ return sessions[sessionId];
457
+ }
458
+
459
+ /** Keep a log bounded: the newest `LOG_LIMIT` entries win. */
460
+ function push(list, entry) {
461
+ list.push(entry);
462
+ if (list.length > LOG_LIMIT) list.splice(0, list.length - LOG_LIMIT);
463
+ return entry;
464
+ }
465
+
466
+ /**
467
+ * Browser-scope counters, in `localStorage`, additive and never decreasing by themselves.
468
+ *
469
+ * Deliberately not tied to any identity: there is no account, no node id and no leaderboard in
470
+ * this half, because the only credential this repository issues (`client_id`) can be exchanged
471
+ * for a token and must never live in a browser bundle. So "how much have I contributed" is
472
+ * answered for *this browser*, and the panel says exactly that.
473
+ */
474
+ function browserStats() {
475
+ try {
476
+ var raw = window.localStorage.getItem(STATS_KEY);
477
+ var parsed = raw ? JSON.parse(raw) : null;
478
+ if (parsed && typeof parsed === "object") return parsed;
479
+ } catch (error) {
480
+ /* private mode, or a hostile localStorage: counters are a nicety, never a requirement */
481
+ }
482
+ return { searches: 0, lessonsReused: 0, votes: 0, reportsFiled: 0, reportsConverted: 0 };
483
+ }
484
+
485
+ function bumpStat(name, by) {
486
+ try {
487
+ var stats = browserStats();
488
+ stats[name] = (Number(stats[name]) || 0) + (by === undefined ? 1 : by);
489
+ window.localStorage.setItem(STATS_KEY, JSON.stringify(stats));
490
+ } catch (error) {
491
+ /* counters are best-effort; the panel reads what it can */
492
+ }
493
+ }
494
+
495
+ /**
496
+ * The in-browser voice switch. Off unless someone turned it on — the repository's voice hook is
497
+ * opt-in too (`--voice`), and a plugin that starts making noise on install is a plugin people
498
+ * uninstall. This controls **browser playback only**; the local hook is a different process with
499
+ * its own switch (`MISAKANET_VOICE=0`), which a page cannot read or change.
500
+ */
501
+ function voiceEnabled() {
502
+ try {
503
+ return window.localStorage.getItem(VOICE_KEY) === "1";
504
+ } catch (error) {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ function setVoiceEnabled(on) {
510
+ try {
511
+ window.localStorage.setItem(VOICE_KEY, on ? "1" : "0");
512
+ } catch (error) {
513
+ /* nothing to remember it with; the toggle still reports what it did */
514
+ }
515
+ notifyPrefs();
516
+ }
517
+
518
+ /**
519
+ * Play the cue the **server** asked for. The server names it in every response (`voice`), so the
520
+ * UI never guesses; `MISAKANET_VOICE_*`-style env vars belong to the local hook, not to this.
521
+ */
522
+ function playCue(cue) {
523
+ if (!cue || !voiceEnabled()) return false;
524
+ try {
525
+ var audio = new window.Audio(API + "/assets/voice/" + cue + ".mp3");
526
+ var played = audio.play();
527
+ if (played && played.catch) played.catch(function () { /* autoplay policy; the click was ours */ });
528
+ return true;
529
+ } catch (error) {
530
+ return false;
531
+ }
532
+ }
533
+
534
+ var MUTED = "rgba(127,127,127,0.45)";
535
+ var DIM = { opacity: 0.72 };
536
+ var BOX = {
537
+ border: "1px solid " + MUTED,
538
+ borderRadius: "8px",
539
+ padding: "8px 10px",
540
+ margin: "4px 0",
541
+ fontSize: "12px",
542
+ lineHeight: "1.6",
543
+ };
544
+ var BUTTON = {
545
+ border: "1px solid " + MUTED,
546
+ borderRadius: "6px",
547
+ background: "transparent",
548
+ color: "inherit",
549
+ cursor: "pointer",
550
+ font: "inherit",
551
+ marginRight: "6px",
552
+ padding: "2px 8px",
553
+ };
554
+
555
+ /**
556
+ * Wake whoever is rendering a session's log after it changes.
557
+ *
558
+ * Deferred out of the render phase on purpose: the recorders run while a row renders, and calling a
559
+ * listener's `setState` synchronously from another component's render is exactly the kind of update
560
+ * React forbids. A microtask is enough for the panel to catch up in the same frame.
561
+ */
562
+ var listeners = Object.create(null);
563
+
564
+ // The slash command's result overlay. It lives in the same per-session store as the counters, so
565
+ // switching sessions switches the overlay with everything else, and one `notify` redraws both.
566
+ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
567
+
568
+ var SEARCH_ENDPOINT = "https://misakanet.org/api/lessons";
569
+ var OVERLAY_LIMIT = 5;
570
+ var OVERLAY_TIMEOUT_MS = 8000;
571
+
572
+ function isJson(text) {
573
+ try { JSON.parse(text); return true; } catch (error) { return false; }
574
+ }
575
+
576
+ function overlayOf(sessionId) {
577
+ var log = sessions[sessionId];
578
+ if (!log) {
579
+ log = sessions[sessionId] = { searches: [], intakes: [], votes: [], lessons: {} };
580
+ }
581
+ if (!log.overlay) {
582
+ log.overlay = { open: false, query: "", status: "idle", results: [], error: null };
583
+ }
584
+ return log.overlay;
585
+ }
586
+
587
+ function setOverlay(sessionId, patch) {
588
+ touchSession(sessionId);
589
+ notifyShell();
590
+ var state = overlayOf(sessionId);
591
+ for (var key in patch) {
592
+ if (Object.prototype.hasOwnProperty.call(patch, key)) state[key] = patch[key];
593
+ }
594
+ notify(sessionId);
595
+ }
596
+
597
+ /**
598
+ * Search from the composer, without the agent in the loop.
599
+ *
600
+ * `GET /api/lessons?q=` is public, CORS-open (measured 2026-10-01: `access-control-allow-origin: *`)
601
+ * and returns `{query, results:[{id, title, domain, path, tags, description, rank}]}` — the same rows
602
+ * the MCP tool returns, so the overlay needs no new backend. The request carries the query and
603
+ * nothing else; the comment says so where a reader will look for it.
604
+ */
605
+ function runOverlaySearch(sessionId, query) {
606
+ query = String(query || "").trim();
607
+ if (!query) {
608
+ setOverlay(sessionId, { open: true, query: "", status: "idle", results: [], error: null });
609
+ return;
610
+ }
611
+ setOverlay(sessionId, { open: true, query: query, status: "loading", results: [], error: null });
612
+ var timedOut = false;
613
+ var timer = setTimeout(function () { timedOut = true; }, OVERLAY_TIMEOUT_MS);
614
+ var url = SEARCH_ENDPOINT + "?q=" + encodeURIComponent(query) + "&limit=" + OVERLAY_LIMIT;
615
+ fetch(url, { headers: { accept: "application/json" } })
616
+ .then(function (response) {
617
+ clearTimeout(timer);
618
+ if (timedOut) return null;
619
+ if (!response.ok) throw new Error("HTTP " + response.status);
620
+ return response.json();
621
+ })
622
+ .then(function (data) {
623
+ if (timedOut || data === null) return;
624
+ setOverlay(sessionId, { status: "done", results: (data && data.results) || [], error: null });
625
+ })
626
+ .catch(function (error) {
627
+ clearTimeout(timer);
628
+ if (timedOut) return;
629
+ setOverlay(sessionId, { status: "error", results: [],
630
+ error: (error && error.message) || String(error) });
631
+ });
632
+ }
633
+
634
+ function subscribe(sessionId, listener) {
635
+ if (!sessionId) return function () {};
636
+ if (!listeners[sessionId]) listeners[sessionId] = [];
637
+ listeners[sessionId].push(listener);
638
+ return function () {
639
+ var list = listeners[sessionId] || [];
640
+ var at = list.indexOf(listener);
641
+ if (at >= 0) list.splice(at, 1);
642
+ };
643
+ }
644
+
645
+ var shellListeners = [];
646
+
647
+ /**
648
+ * The frame-wide seats (`shell.overlay`, `sidebar.footer.action`) receive no `sessionId`, and at mount
649
+ * there is no session to subscribe to yet — an effect that returned early on a null id would subscribe
650
+ * to nothing and never repaint. They subscribe to this instead, and the store announces here whenever
651
+ * session state moves.
652
+ */
653
+ function subscribeShell(listener) {
654
+ shellListeners.push(listener);
655
+ return function () {
656
+ var at = shellListeners.indexOf(listener);
657
+ if (at >= 0) shellListeners.splice(at, 1);
658
+ };
659
+ }
660
+
661
+ function notifyShell() {
662
+ Promise.resolve().then(function () {
663
+ var list = shellListeners.slice();
664
+ for (var i = 0; i < list.length; i++) {
665
+ try { list[i](); } catch (error) { /* one listener's failure is not the others' problem */ }
666
+ }
667
+ });
668
+ }
669
+
670
+ function notify(sessionId) {
671
+ if (!sessionId) return;
672
+ Promise.resolve().then(function () {
673
+ var list = (listeners[sessionId] || []).slice();
674
+ for (var i = 0; i < list.length; i++) {
675
+ try {
676
+ list[i]();
677
+ } catch (error) {
678
+ /* one listener's failure is not the others' problem */
679
+ }
680
+ }
681
+ });
682
+ }
683
+
684
+ /** The newest search that surfaced a lesson and has not been judged yet, or null. */
685
+ function latestPending(log) {
686
+ if (!log) return null;
687
+ for (var i = log.searches.length - 1; i >= 0; i--) {
688
+ var entry = log.searches[i];
689
+ if (entry.lessonId && !entry.voted) return entry;
690
+ }
691
+ return null;
692
+ }
693
+
694
+ /**
695
+ * Record one settled search. Called from a render, so it must be idempotent per call id — and it
696
+ * is: the log is keyed by `callId`, so a repeat render finds the entry already there.
697
+ *
698
+ * The `voice` field is the **server's** choice of cue (`lesson-found` when something matched,
699
+ * `failure-warning` when nothing did). The panel shows it and may play it; a cue guessed in the
700
+ * UI would be the UI inventing data.
701
+ */
702
+ function recordSearch(log, callId, event) {
703
+ var payload = event.payload || {};
704
+ var results = event.results || [];
705
+ var top = results[0] || null;
706
+ for (var i = 0; i < log.searches.length; i++) {
707
+ if (callId && log.searches[i].callId === callId) return log.searches[i];
708
+ }
709
+ var entry = push(log.searches, {
710
+ callId: String(callId || ""),
711
+ at: Date.now(),
712
+ query: String(event.query || payload.query || ""),
713
+ hits: results.length,
714
+ noMatch: Boolean(payload.no_match),
715
+ lessonId: top && top.id ? String(top.id) : "",
716
+ title: top ? String(top.title || top.id || "") : "",
717
+ evidence: top ? String(top.evidence_level || "") : "",
718
+ freshness: top ? String(top.freshness || "") : "",
719
+ voice: String(payload.voice || ""),
720
+ voted: false,
721
+ });
722
+ bumpStat("searches");
723
+ // Browser playback of the cue the SERVER chose — only when someone turned it on. The local
724
+ // hook does the same thing through the machine's own player; this is the browser's copy.
725
+ if (entry.voice && voiceEnabled()) playCue(entry.voice);
726
+ notify(log.sessionId);
727
+ if (entry.lessonId && !log.lessons[entry.lessonId]) {
728
+ log.lessons[entry.lessonId] = {
729
+ lessonId: entry.lessonId, title: entry.title, evidence: entry.evidence,
730
+ first: entry.at, last: entry.at, count: 1,
731
+ };
732
+ bumpStat("lessonsReused");
733
+ } else if (entry.lessonId) {
734
+ log.lessons[entry.lessonId].count += 1;
735
+ log.lessons[entry.lessonId].last = entry.at;
736
+ }
737
+ return entry;
738
+ notifyShell();
739
+ }
740
+
741
+ /** Record a vote and retire the sighting it answered. */
742
+ function recordVote(log, entry, kind, answer) {
743
+ entry.voted = true;
744
+ entry.voteKind = kind;
745
+ if (log) {
746
+ log.voted = true;
747
+ push(log.votes, {
748
+ at: Date.now(),
749
+ lessonId: entry.lessonId,
750
+ kind: kind,
751
+ count: answer && typeof answer.count === "number" ? answer.count : null,
752
+ });
753
+ }
754
+ bumpStat("votes");
755
+ if (log) notify(log.sessionId);
756
+ notifyShell();
757
+ }
758
+
759
+ /**
760
+ * The four states a filed report can be in, read straight off the tool's own answers.
761
+ *
762
+ * `already_have` is deliberately NOT "converted": it is the worker's #1526 backstop, meaning the
763
+ * corpus already covers this so **nothing was filed**. Calling that a conversion would tell a
764
+ * reporter their work landed when they never filed anything.
765
+ */
766
+ function intakeState(payload) {
767
+ if (!payload || typeof payload !== "object") return "unknown";
768
+ if (payload.converted) return "converted";
769
+ if (payload.answered) return T("intake.answered");
770
+ if (payload.already_have) return "already_have";
771
+ if (payload.pending || payload.duplicate || payload.submitted) return "pending";
772
+ return "unknown";
773
+ }
774
+
775
+ /** Record one intake for the panel: what was said, what the server answered, and when. */
776
+ function recordIntake(log, callId, phase, payload, text) {
777
+ if (!log) return null;
778
+ for (var i = 0; i < log.intakes.length; i++) {
779
+ if (callId && log.intakes[i].callId === callId) {
780
+ var known = log.intakes[i];
781
+ if (text) known.text = String(text);
782
+ if (phase === "result") {
783
+ known.state = intakeState(payload);
784
+ known.receipt = String((payload && (payload.receipt || payload.note)) || known.receipt);
785
+ known.issue = String((payload && (payload.intake_id || payload.previous_issue)) || known.issue);
786
+ known.answer = String((payload && payload.answer) || known.answer);
787
+ var lesson = payload && payload.lesson;
788
+ if (lesson && lesson.id) known.lessonId = String(lesson.id);
789
+ if (known.state === "converted") bumpStat("reportsConverted");
790
+ }
791
+ return known;
792
+ }
793
+ }
794
+ var lesson2 = payload && payload.lesson;
795
+ var entry = push(log.intakes, {
796
+ callId: String(callId || ""),
797
+ at: Date.now(),
798
+ text: String(text || ""),
799
+ state: phase === "result" ? intakeState(payload) : T("intake.filing"),
800
+ receipt: String((payload && (payload.receipt || payload.note)) || ""),
801
+ issue: String((payload && (payload.intake_id || payload.previous_issue)) || ""),
802
+ answer: String((payload && payload.answer) || ""),
803
+ lessonId: String((lesson2 && lesson2.id) || ""),
804
+ });
805
+ bumpStat("reportsFiled");
806
+ if (entry.state === "converted") bumpStat("reportsConverted");
807
+ notify(log.sessionId);
808
+ return entry;
809
+ notifyShell();
810
+ }
811
+
812
+ /** Concatenate the text blocks of a result node; "" when there are none. */
813
+ function textFrom(blocks) {
814
+ var out = [];
815
+ var list = Array.isArray(blocks) ? blocks : [];
816
+ for (var i = 0; i < list.length; i++) {
817
+ var block = list[i];
818
+ if (block && block.type === "text" && typeof block.text === "string") out.push(block.text);
819
+ }
820
+ return out.join("\n");
821
+ }
822
+
823
+ /** The tool's JSON payload: the first text block that parses. null when none does. */
824
+ function payloadOf(block) {
825
+ var list = (block && block.content) || [];
826
+ for (var i = 0; i < list.length; i++) {
827
+ var item = list[i];
828
+ if (!item || item.type !== "text" || typeof item.text !== "string") continue;
829
+ try {
830
+ return JSON.parse(item.text);
831
+ } catch (error) {
832
+ /* not this block; the raw disclosure still shows it */
833
+ }
834
+ }
835
+ return null;
836
+ }
837
+
838
+ /** The query this call searched for: from the result when settled, else from the arguments. */
839
+ function queryOf(block, payload) {
840
+ if (payload && typeof payload.query === "string") return payload.query;
841
+ var raw = block && (block.argsRaw || (block.call && block.call.argsRaw));
842
+ if (typeof raw !== "string") return "";
843
+ try {
844
+ var args = JSON.parse(raw);
845
+ if (args && typeof args.query === "string") return args.query;
846
+ } catch (error) {
847
+ /* preparing or partial arguments */
848
+ }
849
+ return "";
850
+ }
851
+
852
+ /** The call's arguments: from the settled result when there is one, else from the raw prefix. */
853
+ function argsOf(block, payload) {
854
+ if (payload && typeof payload === "object" && typeof payload.query === "string") return payload;
855
+ var raw = block && (block.argsRaw || (block.call && block.call.argsRaw));
856
+ if (typeof raw !== "string") return null;
857
+ try {
858
+ return JSON.parse(raw);
859
+ } catch (error) {
860
+ return null;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * "1 lesson" / "2 lessons" — the panel prints counts next to their noun, so it needs the noun.
866
+ *
867
+ * `many` is explicit rather than `singular + "s"` because English is not regular: the first
868
+ * version of this helper printed "2 searchs", which its own verification run showed and the
869
+ * commit message then misquoted. Passing the plural at the call site makes the irregular one
870
+ * impossible to get wrong silently.
871
+ */
872
+ function count(n, singular, many) {
873
+ return n + " " + (n === 1 ? singular : many || singular + "s");
874
+ }
875
+
876
+ /** mm:ss for today, else the date — the event's own time, never the render time. */
877
+ function clockOf(at) {
878
+ try {
879
+ var when = new Date(at);
880
+ var today = new Date();
881
+ var sameDay = when.toDateString() === today.toDateString();
882
+ return sameDay
883
+ ? when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
884
+ : when.toLocaleDateString() + " " + when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
885
+ } catch (error) {
886
+ return "";
887
+ }
888
+ }
889
+
890
+ function timeRange(lesson) {
891
+ return lesson.count > 1 ? clockOf(lesson.first) + " → " + clockOf(lesson.last) : clockOf(lesson.first);
892
+ }
893
+
894
+ /** POST JSON, resolving to the parsed body; rejects only on a transport or HTTP failure. */
895
+ function post(path, body) {
896
+ return fetch(API + path, {
897
+ method: "POST",
898
+ headers: { "Content-Type": "application/json" },
899
+ body: JSON.stringify(body),
900
+ }).then(function (response) {
901
+ if (!response.ok) throw new Error(path + " answered " + response.status);
902
+ return response.json().catch(function () {
903
+ return {};
904
+ });
905
+ });
906
+ }
907
+
908
+ /**
909
+ * The tool-call row for `misakanet_search`: what came back — and nothing else.
910
+ *
911
+ * No verdict here on purpose. At search time nobody can answer "did it help?" yet, and a button
912
+ * that cannot be answered honestly trains people to click it by reflex, which would poison the
913
+ * one signal only a human can produce. The row's other job is to hand the verdict surface
914
+ * something to be about.
915
+ */
916
+ function MisakanetSearchRow(props) {
917
+ var phase = props && props.phase;
918
+ var block = props && props.block;
919
+ var payload = phase === "result" ? payloadOf(block) : null;
920
+ var query = queryOf(block, payload);
921
+ var results = payload && Array.isArray(payload.results) ? payload.results : [];
922
+ var top = results[0] || null;
923
+
924
+ // Recorded for the panel, and handed to the verdict surface. Guarded by the call id: a React
925
+ // re-render must not turn one search into two rows or two "lessons reused".
926
+ var log = sessionLog(props && props.sessionId);
927
+ if (phase === "result" && log) {
928
+ recordSearch(log, props && props.callId, { payload: payload, query: query, results: results });
929
+ }
930
+
931
+ var head = [h("strong", { key: "brand" }, "MisakaNet")];
932
+
933
+ if (phase === "preparing") {
934
+ head.push(h("span", { key: "state", style: DIM }, " " + T("panel.asked.searching")));
935
+ } else if (phase === "start") {
936
+ head.push(h("span", { key: "state" }, " " + (query || T("panel.asked.searching"))));
937
+ } else if (payload && payload.no_match) {
938
+ head.push(h("span", { key: "state" }, " " + T("panel.asked.noMatch")));
939
+ } else if (results.length) {
940
+ head.push(h("span", { key: "state" }, " " + results.length + (results.length === 1 ? " lesson" : " lessons")));
941
+ head.push(h("span", { key: "top", style: DIM }, " · top: "));
942
+ head.push(h("span", { key: "title" }, top.title || top.id || T("tool.untitled")));
943
+ // Only fields the DEFAULT payload actually carries. `detail: "compact"` is
944
+ // {id, title, problem, freshness, evidence_level}; `domain` arrives only at
945
+ // `detail: "summary"` and `path` only at `"full"`. The first version of this row promised
946
+ // a domain and therefore printed `(E3)` for every ordinary call — and the preview missed
947
+ // it because the preview's fixture was hand-written with a domain in it. Richer facts
948
+ // (domain, path) belong to the panel, which asks for them on purpose.
949
+ // `tests/test_dsh_plugin_surface.py` parses this key set out of the worker's own tool
950
+ // description and fails if this row reaches past it.
951
+ var facts = [top.evidence_level, top.freshness].filter(Boolean).join(" · ");
952
+ if (facts) head.push(h("span", { key: "meta", style: DIM }, " (" + facts + ")"));
953
+ } else if (phase === "result") {
954
+ head.push(h("span", { key: "state", style: DIM }, " " + T("tool.readFailed")));
955
+ }
956
+
957
+ var body = [h("div", { key: "body", style: BOX }, h("div", null, head))];
958
+ var raw = textFrom(block && block.content);
959
+ if (raw) {
960
+ body.push(
961
+ h("details", { key: "raw" },
962
+ h("summary", { style: Object.assign({}, DIM, { cursor: "pointer", fontSize: "12px" }) }, T("tool.rawResult")),
963
+ h("pre", { style: { fontSize: "11px", overflowX: "auto", whiteSpace: "pre-wrap" } }, raw))
964
+ );
965
+ }
966
+ return h("div", null, body);
967
+ }
968
+
969
+ /**
970
+ * The verdict on a finalized assistant message: did the lesson the agent just used help?
971
+ *
972
+ * Returns null when this session's searches left nothing to judge, once a verdict exists, and on
973
+ * every message except the one it was offered on — an entry with nothing to say leaves the host's
974
+ * own action row exactly as it was.
975
+ */
976
+ function MisakanetVerdictAction(props) {
977
+ var sessionId = props && props.sessionId;
978
+ var messageId = String((props && props.messageId) || "");
979
+ var log = sessionId ? sessions[sessionId] : null;
980
+ var pending = latestPending(log);
981
+
982
+ var state = react.useState(null);
983
+ var verdict = state[0];
984
+ var setVerdict = state[1];
985
+
986
+ // Ask on the first finalized message after the search, and never again: the question is worth
987
+ // one appearance, and repeating it on every later message is how a prompt becomes a nuisance.
988
+ react.useEffect(function () {
989
+ var live = sessionId ? sessions[sessionId] : null;
990
+ if (live && latestPending(live) && !live.shownFor) live.shownFor = messageId;
991
+ }, [sessionId, messageId]);
992
+
993
+ if (!pending) return null;
994
+ if (log.shownFor && log.shownFor !== messageId) return null;
995
+
996
+ function send(kind) {
997
+ // 👍 carries the lesson id and nothing else. 👎 has to carry the search text: that is what
998
+ // turns one annoyed person into a counted gap, and the line below says so before the click.
999
+ var call = kind === "helpful"
1000
+ ? post("/api/helpful", { lesson_id: pending.lessonId })
1001
+ : post("/api/feedback", { query: pending.query, lesson_id: pending.lessonId, feedback: "irrelevant" });
1002
+ call.then(
1003
+ function (answer) {
1004
+ recordVote(log, pending, kind, answer);
1005
+ setVerdict({
1006
+ kind: kind,
1007
+ count: answer && typeof answer.count === "number" ? answer.count : null,
1008
+ });
1009
+ },
1010
+ function () {
1011
+ setVerdict({ error: "could not reach misakanet.org — nothing was recorded" });
1012
+ }
1013
+ );
1014
+ }
1015
+
1016
+ if (verdict && verdict.error) {
1017
+ return h("span", { style: Object.assign({}, DIM, { fontSize: "12px" }) }, "⚠ " + verdict.error);
1018
+ }
1019
+
1020
+ if (verdict) {
1021
+ var done = verdict.kind === "helpful"
1022
+ ? "recorded — this lesson now has " + (verdict.count === null ? "your" : verdict.count) + " human confirmation(s)"
1023
+ : "recorded as not helpful — it feeds the unsolved-gap map";
1024
+ return h("span", { style: Object.assign({}, DIM, { fontSize: "12px" }) }, done);
1025
+ }
1026
+
1027
+ // `flexWrap` + a full-basis disclosure keeps the action row one short line when the pane is
1028
+ // narrow: the buttons and the label stay together, and the "what this sends" line drops to its
1029
+ // own line instead of stretching the row (seen in the rendered preview).
1030
+ return h("span", {
1031
+ style: { fontSize: "12px", display: "inline-flex", flexWrap: "wrap", alignItems: "center", rowGap: "2px" },
1032
+ },
1033
+ h("span", { style: Object.assign({}, DIM, { marginRight: "6px" }) }, T("tool.brand")),
1034
+ h("button", {
1035
+ type: "button",
1036
+ style: BUTTON,
1037
+ title: T("tool.helpfulTitle"),
1038
+ onClick: function () { send("helpful"); },
1039
+ }, T("tool.helpful")),
1040
+ h("button", {
1041
+ type: "button",
1042
+ style: BUTTON,
1043
+ title: T("tool.notHelpfulTitle"),
1044
+ onClick: function () { send("irrelevant"); },
1045
+ }, T("tool.notHelpful")),
1046
+ // Words, not the emoji, carry this line: an environment without an emoji font renders the
1047
+ // buttons as boxes, and a disclosure that turns into "□ sends the lesson id" is worse than
1048
+ // none. The button labels survive the same way.
1049
+ h("span", { style: Object.assign({}, DIM, { flexBasis: "100%" }) },
1050
+ T("tool.helpfulHint"))
1051
+ );
1052
+ }
1053
+
1054
+ /**
1055
+ * The tool-call row for `misakanet_submit_intake`: what was filed, and what the server said.
1056
+ *
1057
+ * It exists for two reasons. The person should see that something *was* filed and in which of the
1058
+ * four states it sits — and the panel needs to learn about it at all, which it can only do from a
1059
+ * row like this one, because the transcript is the only place these events pass through.
1060
+ */
1061
+ function MisakanetIntakeRow(props) {
1062
+ var phase = props && props.phase;
1063
+ var block = props && props.block;
1064
+ var payload = phase === "result" ? payloadOf(block) : null;
1065
+ var args = argsOf(block, null);
1066
+ var text = args && typeof args.problem === "string" ? args.problem : "";
1067
+ var log = sessionLog(props && props.sessionId);
1068
+ if (log) recordIntake(log, props && props.callId, phase, payload, text);
1069
+
1070
+ var state = phase === "result" ? intakeState(payload) : "filing";
1071
+ var labels = {
1072
+ filing: "filing…",
1073
+ pending: "filed — waiting for a maintainer",
1074
+ answered: "answered",
1075
+ already_have: "not filed — the corpus already covers this",
1076
+ converted: "your report is cited by a lesson",
1077
+ unknown: T("intake.submitted"),
1078
+ };
1079
+ var head = [
1080
+ h("strong", { key: "brand" }, T("panel.report.title")),
1081
+ h("span", { key: "state", style: DIM }, " " + (labels[state] || state)),
1082
+ ];
1083
+ var body = [h("div", { key: "head" }, head)];
1084
+ if (text) body.push(h("div", { key: "text", style: DIM }, text.length > 160 ? text.slice(0, 160) + "…" : text));
1085
+ if (payload && payload.answer) {
1086
+ body.push(h("div", { key: "answer", style: { marginTop: "4px" } }, payload.answer));
1087
+ } else if (payload && (payload.receipt || payload.note)) {
1088
+ body.push(h("div", { key: "receipt", style: Object.assign({}, DIM, { marginTop: "4px" }) },
1089
+ String(payload.receipt || payload.note)));
1090
+ }
1091
+ if (payload && payload.lesson && payload.lesson.id) {
1092
+ body.push(h("div", { key: "lesson", style: DIM },
1093
+ T("panel.report.coveredBy") + payload.lesson.id + (payload.lesson.similarity ? T("panel.report.similarity") + payload.lesson.similarity + ")" : "")));
1094
+ }
1095
+ return h("div", { style: BOX }, body);
1096
+ }
1097
+
1098
+ /**
1099
+ * The panel: what this session asked, what came back, what was filed, what it is worth, and the
1100
+ * counters — the review surface, as opposed to the two judging/visibility rows in the transcript.
1101
+ *
1102
+ * Every number here has a named source: this session's own log (the rows above), the browser's own
1103
+ * counters, one public `GET /api/helpful` per lesson, and nothing else. The limits are printed in
1104
+ * the panel rather than hidden: a reload loses the log, and the local voice hook is not ours to
1105
+ * read.
1106
+ */
1107
+ function MisakanetPanel(props) {
1108
+ var sessionId = props && props.sessionId;
1109
+ var revisionState = react.useState(0);
1110
+ var setRevision = revisionState[1];
1111
+ var voiceState = react.useState(voiceEnabled());
1112
+ var voice = voiceState[0];
1113
+ var setVoice = voiceState[1];
1114
+ var trustState = react.useState({});
1115
+ var trust = trustState[0];
1116
+ var setTrust = trustState[1];
1117
+
1118
+ react.useEffect(function () {
1119
+ return subscribe(sessionId, function () {
1120
+ setRevision(function (n) { return n + 1; });
1121
+ });
1122
+ }, [sessionId]);
1123
+ react.useEffect(function () { return subscribePrefs(function () { setRevision(function (n) { return n + 1; }); }); }, []);
1124
+ react.useEffect(function () { return subscribeLocale(function () { setRevision(function (n) { return n + 1; }); }); }, []);
1125
+
1126
+ var log = sessionId ? sessions[sessionId] : null;
1127
+ var searches = log ? log.searches : [];
1128
+ var intakes = log ? log.intakes : [];
1129
+ var lessons = log ? Object.keys(log.lessons).map(function (id) { return log.lessons[id]; }) : [];
1130
+ var stats = browserStats();
1131
+ var withLesson = searches.filter(function (s) { return s.lessonId; }).length;
1132
+ var noMatch = searches.filter(function (s) { return s.noMatch && !s.lessonId; }).length;
1133
+ var votes = log ? log.votes : [];
1134
+ var helpful = votes.filter(function (v) { return v.kind === "helpful"; }).length;
1135
+ var converted = intakes.filter(function (r) { return r.state === "converted"; }).length;
1136
+ var lastCue = "";
1137
+ for (var i = searches.length - 1; i >= 0; i--) {
1138
+ if (searches[i].voice) { lastCue = searches[i].voice; break; }
1139
+ }
1140
+
1141
+ // One request per lesson per session, whatever the log does. The effect below re-runs on every
1142
+ // revision, and a second run used to start while the first was still in flight (`trust` is still
1143
+ // undefined then) — the live render measured 8 calls for 4 lessons because of exactly that.
1144
+ var asked = react.useRef(Object.create(null));
1145
+
1146
+ react.useEffect(function () {
1147
+ var wanted = lessons.filter(function (lesson) {
1148
+ return trust[lesson.lessonId] === undefined && !asked.current[lesson.lessonId];
1149
+ });
1150
+ for (var i = 0; i < wanted.length; i++) {
1151
+ (function (lesson) {
1152
+ asked.current[lesson.lessonId] = true;
1153
+ fetch(API + "/api/helpful?lesson_id=" + encodeURIComponent(lesson.lessonId))
1154
+ .then(function (response) { return response.ok ? response.json() : null; })
1155
+ .then(function (body) {
1156
+ setTrust(function (prev) {
1157
+ var next = Object.assign({}, prev);
1158
+ next[lesson.lessonId] = body && typeof body.count === "number" ? body.count : null;
1159
+ return next;
1160
+ });
1161
+ })
1162
+ .catch(function () {
1163
+ setTrust(function (prev) {
1164
+ var next = Object.assign({}, prev);
1165
+ next[lesson.lessonId] = null;
1166
+ return next;
1167
+ });
1168
+ });
1169
+ })(wanted[i]);
1170
+ }
1171
+ }, [revisionState[0], lessons.length]);
1172
+
1173
+ var rows = [];
1174
+ // The same mark as the plugin-list icon and the sidebar chip: the conversation ring has no icon
1175
+ // channel, so the panel body is the one place the tab can show what it belongs to.
1176
+ rows.push(h("div", { key: "title", style: { fontSize: "15px", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px" } },
1177
+ MisakanetGlyph({ size: 18 }), h("span", null, "MisakaNet")));
1178
+ rows.push(h("div", { key: "scope", style: DIM },
1179
+ T("panel.counters")));
1180
+
1181
+ rows.push(h("div", { key: "strip", style: Object.assign({}, BOX, { marginTop: "8px" }) },
1182
+ count(searches.length, T("unit.search"), T("unit.searches")) + " · " + withLesson + " with a lesson · " +
1183
+ noMatch + " " + T("panel.asked.nothingFound") + " · " + count(lessons.length, "lesson") + " surfaced · " +
1184
+ count(votes.length, T("unit.vote")) + " (" + helpful + " helpful) · " +
1185
+ count(intakes.length, "report") + " (" + converted + " became a lesson)"));
1186
+
1187
+ // ① what this session asked
1188
+ rows.push(h("div", { key: "s1", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.asked")));
1189
+ if (!searches.length) {
1190
+ rows.push(h("div", { key: "s1e", style: DIM }, T("panel.asked.none")));
1191
+ } else {
1192
+ var searchRows = searches.slice().reverse().slice(0, 20).map(function (entry) {
1193
+ var vote = entry.voted ? (entry.voteKind === "helpful" ? "Helpful" : "not helpful") : (entry.lessonId ? "not voted" : "no lesson");
1194
+ return h("div", { key: "q" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1195
+ h("span", { style: DIM }, clockOf(entry.at) + " "),
1196
+ h("span", {}, entry.query || T("panel.asked.noQuery")),
1197
+ h("span", { style: DIM }, " " + (entry.noMatch ? T("panel.asked.nothingFound") : count(entry.hits, T("unit.lesson")))),
1198
+ entry.title ? h("span", { style: DIM }, " · top: " + entry.title + (entry.evidence ? " (" + entry.evidence + ")" : "")) : null,
1199
+ h("span", { style: DIM }, " · " + vote));
1200
+ });
1201
+ rows.push(h("div", { key: "s1r" }, searchRows));
1202
+ }
1203
+
1204
+ // ② reports filed
1205
+ rows.push(h("div", { key: "s2", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.reports")));
1206
+ var openReports = intakes.filter(function (entry) {
1207
+ return entry.state !== "converted" && entry.state !== "answered" && entry.state !== "already_have";
1208
+ });
1209
+ if (openReports.length) {
1210
+ // There is deliberately no "check again" button here. The only way to pull a receipt is to
1211
+ // submit the same text again, and the server's dedup window is finite (a week): a
1212
+ // page-triggered re-submit could file a *second* GitHub issue for the same problem. A UI
1213
+ // is not allowed to do that, so the panel says who can re-check instead of offering it —
1214
+ // and only for the reports that are actually still open.
1215
+ rows.push(h("div", { key: "s2h", style: DIM },
1216
+ count(openReports.length, T("panel.report.pending")) + T("panel.report.recheck")));
1217
+ }
1218
+ if (!intakes.length) {
1219
+ rows.push(h("div", { key: "s2e", style: DIM }, T("panel.reports.none")));
1220
+ } else {
1221
+ rows.push(h("div", { key: "s2r" }, intakes.slice().reverse().map(function (entry) {
1222
+ var labels = {
1223
+ filing: "filing", pending: T("intake.waiting"), answered: "answered",
1224
+ already_have: T("intake.alreadyHave"), converted: T("intake.cited"), unknown: "submitted",
1225
+ };
1226
+ var issue = String(entry.issue || "").replace(/^issue-/, "#").replace(/^.*\/issues\//, "#");
1227
+ return h("div", { key: "r" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1228
+ h("span", { style: DIM }, clockOf(entry.at) + " "),
1229
+ issue ? h("span", { style: DIM }, issue + " ") : null,
1230
+ h("span", { style: DIM }, (labels[entry.state] || entry.state) + " "),
1231
+ h("span", {}, entry.text ? (entry.text.length > 90 ? entry.text.slice(0, 90) + "…" : entry.text) : T("intake.noText")),
1232
+ entry.receipt ? h("div", { style: DIM }, entry.receipt) : null,
1233
+ entry.answer ? h("div", {}, entry.answer) : null);
1234
+ })));
1235
+ }
1236
+
1237
+ // ③ how much these lessons are trusted
1238
+ rows.push(h("div", { key: "s3", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.trust")));
1239
+ if (!lessons.length) {
1240
+ rows.push(h("div", { key: "s3e", style: DIM }, T("panel.trust.none")));
1241
+ } else {
1242
+ // The rule once, not per row: it is the same sentence for every lesson, and repeating it
1243
+ // four times buries the numbers it is there to explain.
1244
+ rows.push(h("div", { key: "s3rule", style: DIM },
1245
+ T("panel.trust.footnote")));
1246
+ rows.push(h("div", { key: "s3r" }, lessons.map(function (lesson) {
1247
+ var confirmations = trust[lesson.lessonId];
1248
+ var counted = confirmations === undefined ? T("panel.trust.checking")
1249
+ : (confirmations === null ? T("panel.trust.unavailable")
1250
+ : count(confirmations, T("panel.trust.human")) + (confirmations >= 2 ? " → E4" : ""));
1251
+ return h("div", { key: "l" + lesson.lessonId, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1252
+ h("span", {}, lesson.title || lesson.lessonId),
1253
+ h("span", { style: DIM }, " " + (lesson.evidence || T("panel.trust.none")) + " · " + counted +
1254
+ T("panel.trust.used") + lesson.count + "× · " + timeRange(lesson)));
1255
+ })));
1256
+ }
1257
+
1258
+ // ④ your activity, two scopes
1259
+ rows.push(h("div", { key: "s4", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.activity")));
1260
+ rows.push(h("div", { key: "s4r", style: BOX },
1261
+ h("div", {}, T("panel.activity.session") + count(searches.length, "search", "searches") + " · " + count(lessons.length, "lesson") +
1262
+ T("panel.activity.reused") + count(votes.length, "vote") + " · " + count(intakes.length, "report") + T("panel.activity.filed") +
1263
+ count(converted, T("panel.activity.conversion"))),
1264
+ h("div", {}, T("panel.activity.browser") + count(stats.searches || 0, T("unit.search"), T("unit.searches")) + " · " +
1265
+ count(stats.lessonsReused || 0, "lesson") + " reused · " + count(stats.votes || 0, "vote") + " · " +
1266
+ count(stats.reportsFiled || 0, "report") + " filed · " + count(stats.reportsConverted || 0, "conversion")),
1267
+ h("div", { style: DIM }, T("panel.activity.scope"))));
1268
+
1269
+ // ⑤ voice
1270
+ rows.push(h("div", { key: "s5", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.voice")));
1271
+ rows.push(h("div", { key: "s5r", style: BOX },
1272
+ h("button", {
1273
+ type: "button",
1274
+ style: BUTTON,
1275
+ onClick: function () {
1276
+ var next = !voiceEnabled();
1277
+ setVoiceEnabled(next);
1278
+ setVoice(next);
1279
+ },
1280
+ }, T(voice ? "panel.voice.on" : "panel.voice.off")),
1281
+ lastCue && h("button", {
1282
+ type: "button",
1283
+ style: BUTTON,
1284
+ onClick: function () { playCue(lastCue); },
1285
+ }, T("panel.voice.play") + lastCue + "”"),
1286
+ h("div", { style: DIM }, lastCue
1287
+ ? T("panel.voice.cue") + lastCue + T("panel.voice.cueTail")
1288
+ : T("panel.voice.none")),
1289
+ h("div", { style: DIM }, T("panel.voice.hook"))));
1290
+
1291
+ rows.push(h("div", { key: "foot", style: Object.assign({}, DIM, { marginTop: "12px" }) },
1292
+ T("panel.footnote")));
1293
+
1294
+ return h("div", { style: { fontSize: "12px", lineHeight: "1.7", padding: "4px 2px" } }, rows);
1295
+ }
1296
+
1297
+ /**
1298
+ * The panel's glyph: the same mark as `icon.svg` and the site's fallback avatar, drawn inline.
1299
+ *
1300
+ * Inline rather than a file because the host asks a tab for a component, and because a browser half
1301
+ * that fetched its own icon would add a request and a failure mode for a shape this small. It uses
1302
+ * `currentColor`, so it follows the host's theme instead of carrying its own.
1303
+ */
1304
+ function MisakanetGlyph(props) {
1305
+ // The host calls an icon with its own `IconProps` — `{size, className}` — and draws it in a
1306
+ // 26px box, coloured through `currentColor`. The chip seat passes nothing, so 16px is the
1307
+ // fallback the first-party chips use.
1308
+ var options = props || {};
1309
+ var side = options.size || 16;
1310
+ return h("svg", {
1311
+ width: side, height: side, viewBox: "0 0 64 64", fill: "none",
1312
+ className: options.className ? "misakanet-glyph " + options.className : "misakanet-glyph",
1313
+ "aria-hidden": "true", focusable: "false", style: { display: "block", flex: "none" },
1314
+ }, h("path", {
1315
+ d: "M16 44V20h7l9 14 9-14h7v24h-7V31.5L34.5 44h-5L23 31.5V44h-7Z",
1316
+ fill: "currentColor",
1317
+ }));
1318
+ }
1319
+
1320
+ /** The right-sidebar chip: the glyph, then the name — the host inlines this into its own chip. */
1321
+ function MisakanetTabTitle() {
1322
+ return h(react.Fragment, null, MisakanetGlyph({ size: 16 }), h("span", { style: { paddingLeft: "4px" } }, "MisakaNet"));
1323
+ }
1324
+
1325
+ /** One result row: the title, where it came from, and a link to read it. */
1326
+ function MisakanetOverlayRow(result) {
1327
+ var path = String(result && result.id ? result.id : "");
1328
+ var href = path ? "https://misakanet.org/lessons/" + path : "https://misakanet.org/";
1329
+ return h("a", {
1330
+ href: href, target: "_blank", rel: "noreferrer",
1331
+ style: { display: "block", padding: "7px 8px", borderRadius: "8px", textDecoration: "none",
1332
+ color: "inherit", border: "1px solid transparent" },
1333
+ }, h("div", { style: { display: "flex", alignItems: "baseline", gap: "6px" } },
1334
+ h("span", { style: { fontWeight: 600 } }, String(result && result.title ? result.title : "(untitled)")),
1335
+ result && result.domain ? h("span", { style: { opacity: 0.6, fontSize: "11px" } }, String(result.domain)) : null),
1336
+ density() === "full"
1337
+ ? h("div", { style: { opacity: 0.65, fontSize: "11px", marginTop: "2px" } }, path) : null,
1338
+ density() === "full" && result && result.description
1339
+ ? h("div", { style: { opacity: 0.6, fontSize: "11px", marginTop: "2px" } },
1340
+ String(result.description).replace(/\s+/g, " ").slice(0, 160))
1341
+ : null);
1342
+ }
1343
+
1344
+
1345
+ /**
1346
+ * The row's configuration as a `cordis.patch.yml` entry.
1347
+ *
1348
+ * Both cards that show this row cannot write it: the plugin page builds forms only from `volatile()`
1349
+ * fields, and a settings row's namespace is not always exposed. What a reader needs at that point is the
1350
+ * text they can paste into their own patch layer, so this produces exactly that — the same document the
1351
+ * host's native editor points at.
1352
+ */
1353
+ function patchSnippet(values) {
1354
+ var lines = ["- id: " + ROW_ID, " disabled: false", " config:"];
1355
+ var keys = Object.keys(values || {});
1356
+ for (var i = 0; i < keys.length; i++) {
1357
+ var name = keys[i];
1358
+ var value = values[name];
1359
+ if (value !== null && typeof value === "object") {
1360
+ lines.push(" " + name + ":");
1361
+ var inner = Object.keys(value);
1362
+ for (var j = 0; j < inner.length; j++) {
1363
+ lines.push(" " + inner[j] + ": " + yamlScalar(value[inner[j]]));
1364
+ }
1365
+ } else {
1366
+ lines.push(" " + name + ": " + yamlScalar(value));
1367
+ }
1368
+ }
1369
+ return lines.join("\n") + "\n";
1370
+ }
1371
+
1372
+ function yamlScalar(value) {
1373
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
1374
+ if (value === null || value === undefined) return '""';
1375
+ var text = String(value);
1376
+ return /^[A-Za-z0-9._\/-]+$/.test(text) ? text : JSON.stringify(text);
1377
+ }
1378
+
1379
+ /** Copy text and report it in the button's own label; never throws at the reader. */
1380
+ function copyText(text, onDone) {
1381
+ try {
1382
+ window.navigator.clipboard.writeText(text).then(function () { onDone(T("settings.copied")); },
1383
+ function () { onDone(T("settings.copyFailed")); });
1384
+ } catch (error) {
1385
+ onDone(T("settings.copyFailed"));
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * The frame-wide toast (`shell.overlay`): the layer is click-through, so an occupant opts back into
1391
+ * pointer events itself, and this card does that on its own root.
1392
+ *
1393
+ * It announces the one thing worth interrupting for — a `/misakanet` search in this session came back
1394
+ * with lessons — and then gets out of the way: once per query, dismissible, and gone after a few
1395
+ * seconds. The title links to the lesson so the click lands somewhere useful.
1396
+ */
1397
+ var TOAST_MS = 12000;
1398
+
1399
+ function MisakanetShellToast() {
1400
+ var revisionState = react.useState(0);
1401
+ var bump = function () { setRevision(function (n) { return n + 1; }); };
1402
+ var setRevision = revisionState[1];
1403
+ var hiddenState = react.useState(null);
1404
+ var hidden = hiddenState[0];
1405
+ var setHidden = hiddenState[1];
1406
+
1407
+ react.useEffect(function () { return subscribeShell(bump); }, []);
1408
+ react.useEffect(function () { return subscribePrefs(bump); }, []);
1409
+ react.useEffect(function () { return subscribeLocale(bump); }, []);
1410
+
1411
+ var log = lastSessionId ? sessions[lastSessionId] : null;
1412
+ // The overlay's own state is nested (`log.overlay`), unlike the counters beside it.
1413
+ var overlay = log && log.overlay ? log.overlay : null;
1414
+ var query = overlay && overlay.status === "done" && overlay.results && overlay.results.length
1415
+ ? overlay.query : null;
1416
+ react.useEffect(function () {
1417
+ if (!query) return undefined;
1418
+ var timer = setTimeout(function () { setHidden(query); }, TOAST_MS);
1419
+ return function () { clearTimeout(timer); };
1420
+ }, [query]);
1421
+
1422
+ if (!query || hidden === query) return null;
1423
+ var top = overlay.results[0] || {};
1424
+ var lessonId = top.id ? String(top.id) : "";
1425
+ return h("div", {
1426
+ style: { position: "fixed", right: "16px", bottom: "16px", zIndex: 40, maxWidth: "320px",
1427
+ pointerEvents: "auto", background: "Canvas", color: "CanvasText", border: "1px solid rgba(127,127,127,.35)",
1428
+ borderRadius: "8px", boxShadow: "0 6px 20px rgba(0,0,0,.22)", padding: "10px 12px", fontSize: "13px",
1429
+ display: "grid", gap: "6px" },
1430
+ }, h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
1431
+ MisakanetGlyph({ size: 16 }),
1432
+ h("span", { style: { fontWeight: 600 } }, "MisakaNet"),
1433
+ h("span", { style: { opacity: 0.6, fontSize: "11px" } },
1434
+ overlay.results.length === 1 ? T("toast.one") : T("toast.many", { n: overlay.results.length })),
1435
+ h("span", { style: { flex: 1 } }),
1436
+ h("button", { type: "button", title: T("overlay.close"), "aria-label": T("overlay.close"),
1437
+ style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", opacity: 0.7 },
1438
+ onClick: function () { setHidden(query); } }, "×")),
1439
+ lessonId
1440
+ ? h("a", { href: "https://misakanet.org/lessons/" + encodeURIComponent(lessonId),
1441
+ target: "_blank", rel: "noreferrer", style: { color: "inherit", fontWeight: 600 } },
1442
+ String(top.title || lessonId))
1443
+ : h("span", null, String(top.title || "")),
1444
+ top.domain ? h("span", { style: { opacity: 0.65, fontSize: "11px" } }, String(top.domain)) : null);
1445
+ }
1446
+
1447
+ /**
1448
+ * The sidebar footer action (`sidebar.footer.action`): copy this session's MisakaNet activity as a
1449
+ * summary, which is exactly what a bug report or a PR body wants to quote.
1450
+ *
1451
+ * It receives only the column state, so it reads the same most-recently-touched session the toast does,
1452
+ * and reports what it did in its own label rather than throwing a dialog at the reader.
1453
+ */
1454
+ function MisakanetFooterAction() {
1455
+ var revisionState = react.useState(0);
1456
+ var bump = function () { setRevision(function (n) { return n + 1; }); };
1457
+ var setRevision = revisionState[1];
1458
+ var noteState = react.useState(null);
1459
+ var note = noteState[0];
1460
+ var setNote = noteState[1];
1461
+
1462
+ react.useEffect(function () { return subscribeShell(bump); }, []);
1463
+ react.useEffect(function () { return subscribePrefs(bump); }, []);
1464
+ react.useEffect(function () { return subscribeLocale(bump); }, []);
1465
+
1466
+ function summary() {
1467
+ var log = lastSessionId ? sessions[lastSessionId] : null;
1468
+ if (!log) return "";
1469
+ var searched = log.searches ? log.searches.length : 0;
1470
+ var withLesson = log.searches ? log.searches.filter(function (s) { return s.lessonId; }).length : 0;
1471
+ var ids = log.searches ? log.searches.map(function (s) { return s.lessonId; }).filter(Boolean) : [];
1472
+ var helpful = log.votes ? log.votes.filter(function (v) { return v && v.helpful; }).length : 0;
1473
+ var overlay = log.overlay || {};
1474
+ var lines = [
1475
+ "MisakaNet — this session",
1476
+ T("footer.session") + String(lastSessionId).replace(/^session-/, "").slice(0, 8),
1477
+ "tool searches: " + searched + " (" + withLesson + " with a lesson)",
1478
+ "lessons: " + (ids.length ? ids.join(", ") : "—"),
1479
+ "votes: " + (log.votes ? log.votes.length : 0) + " (helpful " + helpful + ")",
1480
+ "reports filed: " + (log.intakes ? log.intakes.length : 0),
1481
+ ];
1482
+ // The `/misakanet` command never goes through the MCP tools, so its search is its own line
1483
+ // rather than being folded into a count that would then describe something else.
1484
+ if (overlay.query) {
1485
+ lines.push("composer search: '" + overlay.query + "' → " +
1486
+ (overlay.status === "done" ? (overlay.results || []).length + " results" : overlay.status));
1487
+ }
1488
+ return lines.join("\n");
1489
+ }
1490
+
1491
+ function copy() {
1492
+ var text = summary();
1493
+ if (!text) { setNote(T("footer.nothing")); return; }
1494
+ try {
1495
+ window.navigator.clipboard.writeText(text).then(function () { setNote(T("footer.copied")); },
1496
+ function () { setNote(T("footer.failed")); });
1497
+ } catch (error) {
1498
+ setNote(T("footer.failed"));
1499
+ }
1500
+ }
1501
+
1502
+ var label = note || T("footer.action");
1503
+ return h("button", { type: "button", title: T("footer.actionHint"), "aria-label": label, onClick: copy,
1504
+ style: { display: "inline-flex", alignItems: "center", gap: "6px", background: "transparent",
1505
+ border: 0, color: "inherit", cursor: "pointer", fontSize: "12px", padding: "4px 6px" },
1506
+ }, MisakanetGlyph({ size: 16 }), h("span", null, label));
1507
+ }
1508
+
1509
+ /**
1510
+ * The settings row (`settings.general.item`, root scope, **no props from the owner**).
1511
+ *
1512
+ * The host's contract for this seat is explicit: "the section column only stacks rows, so a row draws
1513
+ * its own internals, including its label … copy, current value, and the write path are all yours".
1514
+ * So this renders its own header and its controls.
1515
+ *
1516
+ * Two of the three are browser preferences this page owns (voice cues, how much the surfaces show).
1517
+ * The third is the MCP endpoint, and it is the interesting one: the plugin page could not offer a form
1518
+ * for it, because the host builds those only from `volatile()` fields — but a settings row may hold the
1519
+ * namespace's `ConfigForm` itself and write through it. When the host exposes no such namespace, the row
1520
+ * says where the value lives instead of pretending to edit it.
1521
+ */
1522
+ function MisakanetSettingsRow(props) {
1523
+ var face = (props && props.hooks) || {};
1524
+ var hostForm = face.hostForm;
1525
+ var revisionState = react.useState(0);
1526
+ var setRevision = revisionState[1];
1527
+ var bump = function () { setRevision(function (n) { return n + 1; }); };
1528
+ var urlState = react.useState(null);
1529
+ var url = urlState[0];
1530
+ var setUrl = urlState[1];
1531
+ var statusState = react.useState(null);
1532
+ var status = statusState[0];
1533
+ var setStatus = statusState[1];
1534
+
1535
+ react.useEffect(function () { return subscribePrefs(bump); }, []);
1536
+ react.useEffect(function () { return subscribeLocale(bump); }, []);
1537
+ react.useEffect(function () {
1538
+ if (!hostForm || typeof hostForm.subscribe !== "function") return undefined;
1539
+ return hostForm.subscribe(bump);
1540
+ }, [hostForm]);
1541
+ var snapshot = hostForm && typeof hostForm.getSnapshot === "function" ? hostForm.getSnapshot() : null;
1542
+ react.useEffect(function () {
1543
+ setUrl(snapshot && snapshot.value && typeof snapshot.value.url === "string" ? snapshot.value.url : null);
1544
+ }, [snapshot && snapshot.revision, snapshot && snapshot.status]);
1545
+
1546
+ var writable = Boolean(snapshot && snapshot.status === "ready" && snapshot.writable !== false);
1547
+
1548
+ function saveUrl() {
1549
+ if (!hostForm || !snapshot || typeof hostForm.set !== "function") return;
1550
+ setStatus(T("settings.saving"));
1551
+ hostForm.set("url", String(url || "")).then(function (accepted) {
1552
+ setStatus(accepted ? T("settings.saved") : T("settings.refused"));
1553
+ }, function (error) {
1554
+ setStatus(T("settings.writeFailed", { error: (error && error.message) || String(error) }));
1555
+ });
1556
+ }
1557
+
1558
+ return h("div", { style: { display: "grid", gap: "8px", fontSize: "13px", padding: "2px 0" } },
1559
+ h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
1560
+ MisakanetGlyph({ size: 16 }), h("span", { style: { fontWeight: 600 } }, "MisakaNet")),
1561
+ h("label", { style: { display: "flex", alignItems: "center", gap: "8px" } },
1562
+ h("input", { type: "checkbox", checked: voiceEnabled(),
1563
+ onChange: function (event) { setVoiceEnabled(event.target.checked); } }),
1564
+ h("span", null, T("settings.voice")),
1565
+ h("span", { style: { opacity: 0.6, fontSize: "11px" } }, T("settings.voice.note"))),
1566
+ h("label", { style: { display: "flex", alignItems: "center", gap: "8px" } },
1567
+ h("span", null, T("settings.density")),
1568
+ h("select", { value: density(), onChange: function (event) { setDensity(event.target.value); } },
1569
+ h("option", { value: "compact" }, T("settings.density.compact")),
1570
+ h("option", { value: "full" }, T("settings.density.full")))),
1571
+ snapshot && snapshot.status === "ready"
1572
+ ? h("div", { style: { display: "grid", gap: "6px" } },
1573
+ h("label", { style: { display: "flex", alignItems: "center", gap: "8px" } },
1574
+ h("span", { style: { width: "9rem", opacity: 0.8 } }, T("settings.endpoint")),
1575
+ h("input", { type: "text", value: url === null ? "" : url, disabled: !writable,
1576
+ style: { width: "22rem", maxWidth: "100%" },
1577
+ onChange: function (event) { setUrl(event.target.value); setStatus(null); } }),
1578
+ h("button", { type: "button", disabled: !writable, onClick: saveUrl,
1579
+ style: { cursor: writable ? "pointer" : "not-allowed" } }, T("settings.save")),
1580
+ status ? h("span", { style: { opacity: 0.75, fontSize: "12px" } }, status) : null),
1581
+ writable ? null : h("span", { style: { opacity: 0.6, fontSize: "11px" } },
1582
+ T("settings.readonly")))
1583
+ : h("span", { style: { display: "inline-flex", alignItems: "center", gap: "8px" } },
1584
+ h("span", { style: { opacity: 0.6, fontSize: "11px" } },
1585
+ T("settings.patchHint", { row: ROW_ID })),
1586
+ h("button", { type: "button", style: { cursor: "pointer" },
1587
+ onClick: function () {
1588
+ // this row has a `snapshot`, not the card's `resolved`: naming the wrong one threw
1589
+ // `resolved is not defined` in the live render, which is how it was caught
1590
+ copyText(patchSnippet(snapshot && snapshot.value ? snapshot.value : {}), setStatus);
1591
+ } },
1592
+ T("settings.copyPatch")),
1593
+ status ? h("span", { style: { fontSize: "11px", opacity: 0.8 } }, status) : null),
1594
+ h("span", { style: { opacity: 0.55, fontSize: "11px" } },
1595
+ T("settings.local")));
1596
+ }
1597
+
1598
+ /**
1599
+ * The MCP row's configuration card (`plugins.row.config`, key `<bundle>#<row id>`).
1600
+ *
1601
+ * The host owns the values and the writes: it hands `{view, form}` where `form.state` is the resolved
1602
+ * section (schema defaults composed with whatever the user stored) and `form.mutate(ops, revision)`
1603
+ * is the only write path. The fields are derived from the values rather than hard-coded, so the schema
1604
+ * in `index.js` stays the single source of truth for what the row accepts.
1605
+ *
1606
+ * `view: 'summary'` is what the plugin page puts on the row when the row has no description of its own;
1607
+ * `view: 'page'` is the form behind its configure control.
1608
+ */
1609
+ function MisakanetRowConfig(props) {
1610
+ var view = props && props.view;
1611
+ var form = props && props.form;
1612
+ var snapshot = form && form.state ? form.state : null;
1613
+ var resolved = (snapshot && snapshot.value) || {};
1614
+ var draftState = react.useState(null);
1615
+ var draft = draftState[0];
1616
+ var setDraft = draftState[1];
1617
+ var rawState = react.useState({});
1618
+ var raw = rawState[0];
1619
+ var setRaw = rawState[1];
1620
+ var statusState = react.useState(null);
1621
+ var status = statusState[0];
1622
+ var setStatus = statusState[1];
1623
+
1624
+ react.useEffect(function () {
1625
+ try {
1626
+ setDraft(snapshot && snapshot.value ? JSON.parse(JSON.stringify(snapshot.value)) : null);
1627
+ setRaw({});
1628
+ } catch (error) { /* a non-JSON section is not editable here; the read-only note covers it */ }
1629
+ }, [snapshot && snapshot.revision, snapshot && snapshot.status]);
1630
+
1631
+ if (view === "summary") {
1632
+ return h("span", null, String(resolved.transport || "streamable-http") + " → " +
1633
+ String(resolved.url || DEFAULT_MCP_URL));
1634
+ }
1635
+
1636
+ if (!form || !snapshot || snapshot.status !== "ready" || !draft) {
1637
+ // This is the expected state for this row, not a failure. The host builds a form only from
1638
+ // fields its schema marks `volatile()` — ones it can change *without remounting* the plugin
1639
+ // (read out of `dsh-settings`: `volatileForm(schema)` filters on `schema.meta.volatile`, and a
1640
+ // schema with none returns no form at all). An MCP endpoint or transport can only take effect
1641
+ // after a reload, so claiming volatility to win a form would be a lie about this plugin.
1642
+ // What the card can do honestly is show the values in force and say where they are edited.
1643
+ return h("div", { style: { margin: "4px 0", fontSize: "13px", display: "grid", gap: "6px" } },
1644
+ h("p", { style: { margin: 0, opacity: 0.75 } },
1645
+ T("settings.rowReadonly", { row: ROW_ID, what: form ? T("settings.what.form") : T("settings.what.row") })),
1646
+ // The host serves no form here, so hand the reader the text they can paste instead of a dead end.
1647
+ h("div", { style: { display: "inline-flex", alignItems: "center", gap: "8px" } },
1648
+ h("button", { type: "button", style: { cursor: "pointer" },
1649
+ onClick: function () { copyText(patchSnippet(resolved), setStatus); } },
1650
+ T("settings.copyPatch")),
1651
+ status ? h("span", { style: { fontSize: "11px", opacity: 0.8 } }, status) : null),
1652
+ Object.keys(resolved).length
1653
+ ? h("table", { style: { borderCollapse: "collapse" } },
1654
+ Object.keys(resolved).map(function (name) {
1655
+ return h("tr", { key: name },
1656
+ h("td", { style: { padding: "2px 10px 2px 0", opacity: 0.7 } }, name),
1657
+ h("td", { style: { fontFamily: "monospace", fontSize: "12px" } }, JSON.stringify(resolved[name])));
1658
+ }))
1659
+ : h("p", { style: { margin: 0, opacity: 0.7 } },
1660
+ T("settings.rowNoValues")));
1661
+ }
1662
+
1663
+ var fields = Object.keys(draft);
1664
+ var readOnly = snapshot.writable === false;
1665
+
1666
+ function change(name, value) {
1667
+ var next = JSON.parse(JSON.stringify(draft));
1668
+ next[name] = value;
1669
+ setDraft(next);
1670
+ setStatus(null);
1671
+ }
1672
+
1673
+ function save() {
1674
+ var ops = [];
1675
+ for (var i = 0; i < fields.length; i++) {
1676
+ var name = fields[i];
1677
+ if (JSON.stringify(resolved[name]) !== JSON.stringify(draft[name])) {
1678
+ ops.push({ op: "set", path: [name], value: draft[name] });
1679
+ }
1680
+ }
1681
+ if (!ops.length) { setStatus({ kind: "ok", text: "Nothing changed." }); return; }
1682
+ setStatus({ kind: "ok", text: "Saving " + ops.length + " field(s)…" });
1683
+ form.mutate(ops, snapshot.revision).then(function (accepted) {
1684
+ setStatus(accepted
1685
+ ? { kind: "ok", text: "Saved — the row applies it on the next host start." }
1686
+ : { kind: "error", text: "The host refused the write." });
1687
+ }, function (error) {
1688
+ setStatus({ kind: "error", text: "Write failed: " + ((error && error.message) || String(error)) });
1689
+ });
1690
+ }
1691
+
1692
+ return h("div", { style: { fontSize: "13px", display: "grid", gap: "6px" } },
1693
+ fields.map(function (name) {
1694
+ var value = draft[name];
1695
+ var control;
1696
+ if (typeof value === "boolean") {
1697
+ control = h("input", { type: "checkbox", checked: value, disabled: readOnly,
1698
+ onChange: function (event) { change(name, event.target.checked); } });
1699
+ } else if (typeof value === "number") {
1700
+ control = h("input", { type: "number", value: String(value), disabled: readOnly,
1701
+ style: { width: "10rem" },
1702
+ onChange: function (event) { change(name, Number(event.target.value)); } });
1703
+ } else if (typeof value === "string") {
1704
+ control = h("input", { type: "text", value: value, disabled: readOnly,
1705
+ style: { width: "24rem", maxWidth: "100%" },
1706
+ onChange: function (event) { change(name, event.target.value); } });
1707
+ } else {
1708
+ var text = raw[name] !== undefined ? raw[name] : JSON.stringify(value);
1709
+ control = h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px" } },
1710
+ h("textarea", { value: text, disabled: readOnly, rows: 3,
1711
+ style: { width: "24rem", maxWidth: "100%", fontFamily: "monospace", fontSize: "12px" },
1712
+ onChange: function (event) {
1713
+ var next = {}; next[name] = event.target.value; setRaw(next);
1714
+ try { change(name, JSON.parse(event.target.value)); } catch (error) { /* keep the text */ }
1715
+ } }),
1716
+ raw[name] !== undefined && !isJson(raw[name])
1717
+ ? h("span", { style: { fontSize: "11px", opacity: 0.7 } }, T("settings.invalidJson")) : null);
1718
+ }
1719
+ return h("label", { key: name, style: { display: "flex", alignItems: "center", gap: "8px" } },
1720
+ h("span", { style: { width: "11rem", opacity: 0.8 } }, name), control);
1721
+ }),
1722
+ readOnly
1723
+ ? h("p", { style: { margin: 0, opacity: 0.7 } }, T("settings.noWrites"))
1724
+ : h("div", { style: { display: "flex", alignItems: "center", gap: "10px" } },
1725
+ h("button", { type: "button", onClick: save, style: { cursor: "pointer" } }, T("settings.save")),
1726
+ status ? h("span", { style: { opacity: 0.8 } }, status.text) : null));
1727
+ }
1728
+
1729
+ /**
1730
+ * The `/misakanet` result overlay, rendered inside the resident composer card
1731
+ * (`conversation.input.overlay`, session scope).
1732
+ *
1733
+ * It is the one place in this half that asks the server for something the session does not already
1734
+ * have, so it also says what it sends: the query, anonymously, to `misakanet.org`.
1735
+ */
1736
+ function MisakanetSearchOverlay(props) {
1737
+ var sessionId = props && props.sessionId;
1738
+ var revisionState = react.useState(0);
1739
+ var setRevision = revisionState[1];
1740
+ react.useEffect(function () {
1741
+ return subscribe(sessionId, function () { setRevision(function (n) { return n + 1; }); });
1742
+ }, [sessionId]);
1743
+ react.useEffect(function () { return subscribePrefs(function () { setRevision(function (n) { return n + 1; }); }); }, []);
1744
+ react.useEffect(function () { return subscribeLocale(function () { setRevision(function (n) { return n + 1; }); }); }, []);
1745
+ if (!sessionId) return null;
1746
+ var state = overlayOf(sessionId);
1747
+ if (!state.open) return null;
1748
+
1749
+ var body;
1750
+ if (state.status === "loading") {
1751
+ body = h("div", { style: { opacity: 0.7, fontSize: "12px", padding: "6px 2px" } }, T("overlay.searching"));
1752
+ } else if (state.status === "error") {
1753
+ body = h("div", { style: { fontSize: "12px", padding: "6px 2px" } },
1754
+ T("overlay.failed", { error: state.error }));
1755
+ } else if (state.status === "idle") {
1756
+ body = h("div", { style: { opacity: 0.7, fontSize: "12px", padding: "6px 2px" } },
1757
+ T("overlay.empty"));
1758
+ } else if (!state.results.length) {
1759
+ body = h("div", { style: { fontSize: "12px", padding: "6px 2px" } },
1760
+ T("overlay.none"));
1761
+ } else {
1762
+ body = h("div", null, state.results.map(MisakanetOverlayRow));
1763
+ }
1764
+
1765
+ // `Canvas`/`CanvasText` are CSS system colours: they follow the host's light/dark theme without
1766
+ // this file knowing its variables, and the opaque background is what keeps the composer's own
1767
+ // text from showing through the card (measured: a transparent card overlapped the tool row).
1768
+ return h("div", {
1769
+ style: { border: "1px solid rgba(127,127,127,0.35)", borderRadius: "12px", padding: "8px 10px",
1770
+ margin: "6px 0", maxHeight: "320px", overflowY: "auto", fontSize: "13px",
1771
+ position: "relative", zIndex: 3, background: "Canvas", color: "CanvasText",
1772
+ boxShadow: "0 8px 24px rgba(0,0,0,0.18)" },
1773
+ }, h("div", { style: { display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" } },
1774
+ MisakanetGlyph({ size: 14 }),
1775
+ h("span", { style: { fontWeight: 600 } }, "MisakaNet"),
1776
+ state.query ? h("span", { style: { opacity: 0.6, fontSize: "12px" } }, "· " + state.query) : null,
1777
+ h("span", { style: { flex: 1 } }),
1778
+ h("button", {
1779
+ type: "button", title: T("overlay.close"),
1780
+ onClick: function () { setOverlay(sessionId, { open: false }); },
1781
+ style: { background: "none", border: "none", cursor: "pointer", color: "inherit", opacity: 0.7 },
1782
+ }, "×")),
1783
+ body,
1784
+ h("div", { style: { opacity: 0.55, fontSize: "11px", marginTop: "4px" } },
1785
+ T("overlay.footnote")));
1786
+ }
1787
+
1788
+ /**
1789
+ * The `/misakanet` source for the host's slash pipeline.
1790
+ *
1791
+ * The pipeline owns the draft: picking the candidate claims the token (so the composer says what is
1792
+ * about to happen) and Enter then hands us the argument through `submit`. We answer with the
1793
+ * overlay instead of consuming the submit, which is why this is a claim and not an insert.
1794
+ */
1795
+ function misakanetTrigger(lastSession) {
1796
+ return {
1797
+ trigger: "/",
1798
+ name: "misakanet",
1799
+ order: 5,
1800
+ candidates: function (session, request) {
1801
+ if (session && session.sessionId) lastSession.id = session.sessionId;
1802
+ if (!request || request.position !== "leading") return Promise.resolve([]);
1803
+ var query = String(request.query || "").trim().toLowerCase();
1804
+ if (query !== "" && "misakanet".indexOf(query) !== 0) return Promise.resolve([]);
1805
+ return Promise.resolve([{
1806
+ name: "misakanet",
1807
+ label: "MisakaNet search",
1808
+ description: "Search the failure-lesson library without leaving the composer",
1809
+ icon: MisakanetGlyph,
1810
+ section: "MisakaNet",
1811
+ }]);
1812
+ },
1813
+ onPick: function () {
1814
+ return { claim: {
1815
+ name: "misakanet",
1816
+ token: "/misakanet ",
1817
+ hint: "what broke?",
1818
+ submit: function (args) {
1819
+ var sessionId = lastSession.id;
1820
+ var query = String(args || "").trim();
1821
+ if (!query) {
1822
+ runOverlaySearch(sessionId, "");
1823
+ return Promise.resolve({ kind: "error",
1824
+ text: "Type what broke after /misakanet — e.g. /misakanet pip install timeout" });
1825
+ }
1826
+ runOverlaySearch(sessionId, query);
1827
+ return Promise.resolve({ kind: "success" });
1828
+ },
1829
+ } };
1830
+ },
1831
+ };
1832
+ }
1833
+
1834
+ /**
1835
+ * The left column's icon seat (`sidebar.panellist`, root scope → it does not come and go with a
1836
+ * session). The owner supplies `{size, active}` and owns the button: the id we register here is the
1837
+ * key the main column is dispatched with, which is why `PANEL_ID` is used for both.
1838
+ */
1839
+ function MisakanetPanelIcon(props) {
1840
+ var options = props || {};
1841
+ return h("span", {
1842
+ style: {
1843
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
1844
+ opacity: options.active ? 1 : 0.75,
1845
+ },
1846
+ }, MisakanetGlyph({ size: options.size || 22 }));
1847
+ }
1848
+
1849
+ /**
1850
+ * The main column page the icon above opens ("Central panel selected by sidebar entry id").
1851
+ *
1852
+ * A `main` occupant gets no session binding, so this is the browser-scoped view: what this browser
1853
+ * has contributed, how trust is counted, the voice switch. The per-session story stays in the session's
1854
+ * right-sidebar tab, and the first line says so rather than letting the zeros read as a bug.
1855
+ */
1856
+ function MisakanetMainView(props) {
1857
+ return h("div", { style: { height: "100%", overflowY: "auto", padding: "28px 32px 48px" } },
1858
+ h("div", { style: { maxWidth: "860px", margin: "0 auto" } },
1859
+ h("p", { style: { margin: "0 0 14px", color: "inherit", opacity: 0.7, fontSize: "13px", lineHeight: "1.5" } },
1860
+ T("main.footnote")),
1861
+ h(MisakanetPanel, { sessionId: props && props.sessionId })));
1862
+ }
1863
+
1864
+ /** Cordis services this half needs before it may run. */
1865
+ var inject = ["slots"];
1866
+
1867
+ /**
1868
+ * Register both surfaces. Each registration is an effect, so unloading the plugin takes them with
1869
+ * it, and `dsh.client.inject` guarantees the packages that declare the slots are already there.
1870
+ */
1871
+ function apply(ctx) {
1872
+ // Every surface registers through one guarded step, because a throw here is not a local
1873
+ // failure: the client runner marks the plugin's fiber `failed`, the web boot audit reports
1874
+ // `misakanet: failed`, and the shell shows "Failed to load plugins" over the whole UI.
1875
+ // A UI plugin may lose a surface; it may not lose the page — and it must say what it lost.
1876
+ var failures = [];
1877
+
1878
+ function step(label, register) {
1879
+ try {
1880
+ return register();
1881
+ } catch (error) {
1882
+ failures.push(label + ": " + (error && error.message ? error.message : String(error)));
1883
+ return undefined;
1884
+ }
1885
+ }
1886
+
1887
+ // EVERY registration waits for its slot's declaration (`ctx.slots.inject`), because most of
1888
+ // these are **child slots**: `tool.call.toolview` and `conversation.chat.assistant-actions` are
1889
+ // declared by a *parent* entry's `children` table, and registering into one before that
1890
+ // declaration is committed throws
1891
+ // `slot "…" is not declared (a parent entry's children table must declare it)`.
1892
+ //
1893
+ // That throw is what broke the whole plugin on 2026-10-01: the first toolview registration
1894
+ // raised, `apply()` aborted, the client runner marked the fiber `failed`, the shell showed
1895
+ // "Failed to load plugins / misakanet: failed", and **nothing** registered — not even the panel
1896
+ // tab, which was never itself at fault. `slots.inject` runs the callback immediately when the
1897
+ // declaration already exists and otherwise inside the declaring call, so it is correct in both
1898
+ // orders. (First-party plugins do exactly this; registering directly does not work.)
1899
+ function registerWhenDeclared(slot, options, component, label) {
1900
+ return step(label, function () {
1901
+ return ctx.effect(function () {
1902
+ return ctx.slots.inject(slot, function () {
1903
+ return ctx.slots.register(options, component);
1904
+ });
1905
+ }, "misakanet: " + label);
1906
+ });
1907
+ }
1908
+
1909
+ for (var i = 0; i < TOOL_KEYS.length; i++) {
1910
+ registerWhenDeclared("tool.call.toolview", { name: "tool.call.toolview", key: TOOL_KEYS[i] },
1911
+ MisakanetSearchRow, "search row " + TOOL_KEYS[i]);
1912
+ }
1913
+ for (var j = 0; j < INTAKE_KEYS.length; j++) {
1914
+ registerWhenDeclared("tool.call.toolview", { name: "tool.call.toolview", key: INTAKE_KEYS[j] },
1915
+ MisakanetIntakeRow, "intake row " + INTAKE_KEYS[j]);
1916
+ }
1917
+ registerWhenDeclared("conversation.chat.assistant-actions",
1918
+ { name: "conversation.chat.assistant-actions", id: VERDICT_ID, order: 20 },
1919
+ MisakanetVerdictAction, "verdict action");
1920
+ registerWhenDeclared("conversation.view",
1921
+ { name: "conversation.view", id: PANEL_ID, order: 20, label: function () { return "MisakaNet"; } },
1922
+ MisakanetPanel, "conversation tab");
1923
+
1924
+ // The left column, where the shipped "Plugins" and "Settings" entries live. Two seats, one id:
1925
+ // `sidebar.panellist` draws the icon in the rail (a shortcut), and `main` is the page the sidebar
1926
+ // dispatches that id to — "Central panel selected by sidebar entry id" (client-ui-layout).
1927
+ // They are separate registrations on purpose: with only the first, the icon would be a dead end.
1928
+ registerWhenDeclared("sidebar.panellist",
1929
+ { name: "sidebar.panellist", id: PANEL_ID, order: 30, label: function () { return "MisakaNet"; } },
1930
+ MisakanetPanelIcon, "left sidebar icon");
1931
+ registerWhenDeclared("main",
1932
+ { name: "main", key: PANEL_ID },
1933
+ MisakanetMainView, "main panel");
1934
+
1935
+ // The composer: a result overlay above the input, and the `/misakanet` source that opens it.
1936
+ // The source is a *service* inject, not a slot — the slash pipeline owns the draft, so the
1937
+ // interesting contract is `registerSource`, and a host without that service keeps every other
1938
+ // seat instead of failing activation.
1939
+ // The plugin page's row: the key is `<bundle>#<row id>` as the patch declares it. Without the
1940
+ // host's `form` the card renders the resolved values read-only, which is also what a host that
1941
+ // serves no schema for the row gets.
1942
+ // Two keys, because the plugin page looks a form up twice with different names: the bundle's own
1943
+ // config is keyed by the **package name** (`ItemDetail` → `formFor(item.id)`), while a row's is
1944
+ // keyed by the **row id** (`RowDetail` → `formFor(row.rowId)`). Whichever namespace the host
1945
+ // actually documents, one of these finds it — and the card renders read-only values when neither
1946
+ // does, so the entry is never a dead end.
1947
+ // The dictionaries and the binding. `bind` keeps a stable identity and reads the active locale on
1948
+ // every call, so one binding serves every component; the subscription is what lets an already
1949
+ // rendered surface switch language without a reload.
1950
+ step("dictionaries", function () {
1951
+ if (typeof ctx.inject !== "function") throw new Error("ctx.inject is not available");
1952
+ return ctx.inject(["locale"], function (scoped) {
1953
+ var locale = scoped && (scoped.locale ||
1954
+ (typeof scoped.get === "function" ? scoped.get("locale") : undefined));
1955
+ if (!locale || typeof locale.register !== "function" || typeof locale.bind !== "function") {
1956
+ return undefined; // no locale service: the surfaces stay in English, the source text
1957
+ }
1958
+ localeFace = locale;
1959
+ T = locale.bind(NS);
1960
+ var stops = [];
1961
+ if (typeof locale.subscribe === "function") {
1962
+ stops.push(locale.subscribe(function () { notifyLocale(); }));
1963
+ }
1964
+ return ctx.effect(function () {
1965
+ var disposer = locale.register(NS, { en: en, zh: zh });
1966
+ return function () {
1967
+ for (var i = 0; i < stops.length; i++) {
1968
+ if (typeof stops[i] === "function") stops[i]();
1969
+ }
1970
+ if (typeof disposer === "function") disposer();
1971
+ };
1972
+ }, "misakanet: dictionaries");
1973
+ });
1974
+ });
1975
+
1976
+ // The two frame-wide seats. Both are root scope and receive no `sessionId`, which is why the store
1977
+ // tracks the most recently touched session for them.
1978
+ registerWhenDeclared("shell.overlay",
1979
+ { name: "shell.overlay", id: "misakanet-lesson-toast", order: 30, label: function () { return "MisakaNet"; } },
1980
+ MisakanetShellToast, "shell overlay");
1981
+ registerWhenDeclared("sidebar.footer.action",
1982
+ { name: "sidebar.footer.action", id: "misakanet-summary", order: 40,
1983
+ label: function () { return T("footer.action"); } },
1984
+ MisakanetFooterAction, "footer action");
1985
+ // The settings row. `configForms` is a service, so it is read defensively: a host without it keeps
1986
+ // the row's browser preferences and loses only the endpoint field.
1987
+ step("settings row", function () {
1988
+ var face = {};
1989
+ try {
1990
+ if (ctx.configForms && typeof ctx.configForms.get === "function") {
1991
+ var form = ctx.configForms.get(ROW_ID);
1992
+ if (form && typeof form.getSnapshot === "function" && typeof form.set === "function") {
1993
+ face.hostForm = form;
1994
+ }
1995
+ }
1996
+ } catch (error) { /* the row still carries its two browser preferences */ }
1997
+ return ctx.effect(function () {
1998
+ return ctx.slots.inject("settings.general.item", function () {
1999
+ return ctx.slots.register({
2000
+ name: "settings.general.item", id: PANEL_ID, order: 50,
2001
+ inject: function () { return { hooks: face }; },
2002
+ }, MisakanetSettingsRow);
2003
+ });
2004
+ }, "misakanet: settings row");
2005
+ });
2006
+ registerWhenDeclared("plugins.bundle.config",
2007
+ { name: "plugins.bundle.config", key: "misakanet" },
2008
+ MisakanetRowConfig, "bundle config");
2009
+ registerWhenDeclared("plugins.row.config",
2010
+ { name: "plugins.row.config", key: "misakanet#misakanet-mcp" },
2011
+ MisakanetRowConfig, "row config");
2012
+ registerWhenDeclared("conversation.input.overlay",
2013
+ { name: "conversation.input.overlay", id: "misakanet-search", order: 20 },
2014
+ MisakanetSearchOverlay, "search overlay");
2015
+ step("slash command", function () {
2016
+ if (typeof ctx.inject !== "function") throw new Error("ctx.inject is not available");
2017
+ var lastSession = { id: null };
2018
+ return ctx.inject(["inputTriggers"], function (scoped) {
2019
+ var triggers = scoped && (scoped.inputTriggers ||
2020
+ (typeof scoped.get === "function" ? scoped.get("inputTriggers") : undefined));
2021
+ if (!triggers || typeof triggers.registerSource !== "function") return undefined;
2022
+ return triggers.registerSource(misakanetTrigger(lastSession));
2023
+ });
2024
+ });
2025
+
2026
+ // The right sidebar ships from 0.1.5 on, so this rides a deferred service inject: on a line
2027
+ // without the registry the callback never runs, the fiber never pends, and the conversation tab
2028
+ // above is the whole plugin. Inside, the registry is re-proved and every disposer is collected.
2029
+ step("sidebar tab", function () {
2030
+ if (typeof ctx.inject !== "function") throw new Error("ctx.inject is not available");
2031
+ return ctx.inject(["sidebarRightTabs"], function (scoped) {
2032
+ var disposers = [];
2033
+ function own(result) {
2034
+ if (typeof result === "function") disposers.push(result);
2035
+ }
2036
+ function unwind() {
2037
+ for (var k = 0; k < disposers.length; k++) {
2038
+ try { disposers[k](); } catch (error) { /* already leaving */ }
2039
+ }
2040
+ disposers = [];
2041
+ }
2042
+ try {
2043
+ var tabs = scoped && scoped.sidebarRightTabs;
2044
+ if (!tabs || typeof tabs.register !== "function") return;
2045
+ own(tabs.register({
2046
+ id: PANEL_ID,
2047
+ kind: PANEL_KIND,
2048
+ title: function () { return "MisakaNet"; },
2049
+ guide: [{
2050
+ id: "panel",
2051
+ order: 30,
2052
+ title: function () { return "MisakaNet"; },
2053
+ description: function () { return T("guide.description"); },
2054
+ icon: MisakanetGlyph,
2055
+ }],
2056
+ }));
2057
+ own(scoped.slots.inject("sidebar.right.pane.tab", function () {
2058
+ return scoped.slots.register({ name: "sidebar.right.pane.tab", key: PANEL_ID }, MisakanetPanel);
2059
+ }));
2060
+ own(scoped.slots.inject("sidebar.right.pane.tab.title", function () {
2061
+ return scoped.slots.register({ name: "sidebar.right.pane.tab.title", key: PANEL_ID }, MisakanetTabTitle);
2062
+ }));
2063
+ } catch (error) {
2064
+ unwind();
2065
+ return undefined;
2066
+ }
2067
+ return unwind;
2068
+ });
2069
+ });
2070
+
2071
+ if (failures.length) {
2072
+ // Loud in the console, harmless in the UI: the surfaces that did register keep working, and
2073
+ // this line is what turns "misakanet: failed" into a name and a message next time.
2074
+ try {
2075
+ console.warn("misakanet: " + failures.length + " surface(s) did not register — " + failures.join(" | "));
2076
+ } catch (error) { /* no console: nothing else to do */ }
2077
+ }
2078
+ }
2079
+
2080
+ exports.apply = apply;
2081
+ exports.inject = inject;
2082
+ exports.name = "misakanet-client";
2083
+ return module.exports;
2084
+ },
2085
+ });