misakanet 2.40.0 → 2.41.1

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 CHANGED
@@ -107,10 +107,308 @@ window.__ModuleLoader__.load({
107
107
  * could take. (`kind` is what `openTab` names; `id` is the implementation's identity.)
108
108
  */
109
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
+ }
110
374
 
111
375
  /** localStorage keys. Both scopes are this browser only — there is no account anywhere here. */
112
376
  var STATS_KEY = "misakanet.panel.stats.v1";
113
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
+ }
114
412
 
115
413
  /** How many events one session keeps in memory. The panel is a log, not an archive. */
116
414
  var LOG_LIMIT = 200;
@@ -129,8 +427,23 @@ window.__ModuleLoader__.load({
129
427
  */
130
428
  var sessions = Object.create(null);
131
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
+
132
444
  function sessionLog(sessionId) {
133
445
  if (!sessionId) return null;
446
+ touchSession(sessionId);
134
447
  if (!sessions[sessionId]) {
135
448
  sessions[sessionId] = {
136
449
  sessionId: sessionId,
@@ -199,6 +512,7 @@ window.__ModuleLoader__.load({
199
512
  } catch (error) {
200
513
  /* nothing to remember it with; the toggle still reports what it did */
201
514
  }
515
+ notifyPrefs();
202
516
  }
203
517
 
204
518
  /**
@@ -247,6 +561,76 @@ window.__ModuleLoader__.load({
247
561
  */
248
562
  var listeners = Object.create(null);
249
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
+
250
634
  function subscribe(sessionId, listener) {
251
635
  if (!sessionId) return function () {};
252
636
  if (!listeners[sessionId]) listeners[sessionId] = [];
@@ -258,6 +642,31 @@ window.__ModuleLoader__.load({
258
642
  };
259
643
  }
260
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
+
261
670
  function notify(sessionId) {
262
671
  if (!sessionId) return;
263
672
  Promise.resolve().then(function () {
@@ -326,6 +735,7 @@ window.__ModuleLoader__.load({
326
735
  log.lessons[entry.lessonId].last = entry.at;
327
736
  }
328
737
  return entry;
738
+ notifyShell();
329
739
  }
330
740
 
331
741
  /** Record a vote and retire the sighting it answered. */
@@ -343,6 +753,7 @@ window.__ModuleLoader__.load({
343
753
  }
344
754
  bumpStat("votes");
345
755
  if (log) notify(log.sessionId);
756
+ notifyShell();
346
757
  }
347
758
 
348
759
  /**
@@ -355,7 +766,7 @@ window.__ModuleLoader__.load({
355
766
  function intakeState(payload) {
356
767
  if (!payload || typeof payload !== "object") return "unknown";
357
768
  if (payload.converted) return "converted";
358
- if (payload.answered) return "answered";
769
+ if (payload.answered) return T("intake.answered");
359
770
  if (payload.already_have) return "already_have";
360
771
  if (payload.pending || payload.duplicate || payload.submitted) return "pending";
361
772
  return "unknown";
@@ -385,7 +796,7 @@ window.__ModuleLoader__.load({
385
796
  callId: String(callId || ""),
386
797
  at: Date.now(),
387
798
  text: String(text || ""),
388
- state: phase === "result" ? intakeState(payload) : "filing",
799
+ state: phase === "result" ? intakeState(payload) : T("intake.filing"),
389
800
  receipt: String((payload && (payload.receipt || payload.note)) || ""),
390
801
  issue: String((payload && (payload.intake_id || payload.previous_issue)) || ""),
391
802
  answer: String((payload && payload.answer) || ""),
@@ -395,6 +806,7 @@ window.__ModuleLoader__.load({
395
806
  if (entry.state === "converted") bumpStat("reportsConverted");
396
807
  notify(log.sessionId);
397
808
  return entry;
809
+ notifyShell();
398
810
  }
399
811
 
400
812
  /** Concatenate the text blocks of a result node; "" when there are none. */
@@ -519,15 +931,15 @@ window.__ModuleLoader__.load({
519
931
  var head = [h("strong", { key: "brand" }, "MisakaNet")];
520
932
 
521
933
  if (phase === "preparing") {
522
- head.push(h("span", { key: "state", style: DIM }, " searching…"));
934
+ head.push(h("span", { key: "state", style: DIM }, " " + T("panel.asked.searching")));
523
935
  } else if (phase === "start") {
524
- head.push(h("span", { key: "state" }, " " + (query || "searching…")));
936
+ head.push(h("span", { key: "state" }, " " + (query || T("panel.asked.searching"))));
525
937
  } else if (payload && payload.no_match) {
526
- head.push(h("span", { key: "state" }, " no lesson matched"));
938
+ head.push(h("span", { key: "state" }, " " + T("panel.asked.noMatch")));
527
939
  } else if (results.length) {
528
940
  head.push(h("span", { key: "state" }, " " + results.length + (results.length === 1 ? " lesson" : " lessons")));
529
941
  head.push(h("span", { key: "top", style: DIM }, " · top: "));
530
- head.push(h("span", { key: "title" }, top.title || top.id || "(untitled)"));
942
+ head.push(h("span", { key: "title" }, top.title || top.id || T("tool.untitled")));
531
943
  // Only fields the DEFAULT payload actually carries. `detail: "compact"` is
532
944
  // {id, title, problem, freshness, evidence_level}; `domain` arrives only at
533
945
  // `detail: "summary"` and `path` only at `"full"`. The first version of this row promised
@@ -539,7 +951,7 @@ window.__ModuleLoader__.load({
539
951
  var facts = [top.evidence_level, top.freshness].filter(Boolean).join(" · ");
540
952
  if (facts) head.push(h("span", { key: "meta", style: DIM }, " (" + facts + ")"));
541
953
  } else if (phase === "result") {
542
- head.push(h("span", { key: "state", style: DIM }, " result could not be read"));
954
+ head.push(h("span", { key: "state", style: DIM }, " " + T("tool.readFailed")));
543
955
  }
544
956
 
545
957
  var body = [h("div", { key: "body", style: BOX }, h("div", null, head))];
@@ -547,7 +959,7 @@ window.__ModuleLoader__.load({
547
959
  if (raw) {
548
960
  body.push(
549
961
  h("details", { key: "raw" },
550
- h("summary", { style: Object.assign({}, DIM, { cursor: "pointer", fontSize: "12px" }) }, "raw result"),
962
+ h("summary", { style: Object.assign({}, DIM, { cursor: "pointer", fontSize: "12px" }) }, T("tool.rawResult")),
551
963
  h("pre", { style: { fontSize: "11px", overflowX: "auto", whiteSpace: "pre-wrap" } }, raw))
552
964
  );
553
965
  }
@@ -618,24 +1030,24 @@ window.__ModuleLoader__.load({
618
1030
  return h("span", {
619
1031
  style: { fontSize: "12px", display: "inline-flex", flexWrap: "wrap", alignItems: "center", rowGap: "2px" },
620
1032
  },
621
- h("span", { style: Object.assign({}, DIM, { marginRight: "6px" }) }, "MisakaNet:"),
1033
+ h("span", { style: Object.assign({}, DIM, { marginRight: "6px" }) }, T("tool.brand")),
622
1034
  h("button", {
623
1035
  type: "button",
624
1036
  style: BUTTON,
625
- title: "Sends this lesson's id to misakanet.org as reuse evidence. Sends nothing else.",
1037
+ title: T("tool.helpfulTitle"),
626
1038
  onClick: function () { send("helpful"); },
627
- }, "👍 Helpful"),
1039
+ }, T("tool.helpful")),
628
1040
  h("button", {
629
1041
  type: "button",
630
1042
  style: BUTTON,
631
- title: "Sends this lesson's id and the search text, so the gap gets counted.",
1043
+ title: T("tool.notHelpfulTitle"),
632
1044
  onClick: function () { send("irrelevant"); },
633
- }, "👎 Not what I needed"),
1045
+ }, T("tool.notHelpful")),
634
1046
  // Words, not the emoji, carry this line: an environment without an emoji font renders the
635
1047
  // buttons as boxes, and a disclosure that turns into "□ sends the lesson id" is worse than
636
1048
  // none. The button labels survive the same way.
637
1049
  h("span", { style: Object.assign({}, DIM, { flexBasis: "100%" }) },
638
- "Helpful sends the lesson id · not-helpful also sends the search text (gap stats)")
1050
+ T("tool.helpfulHint"))
639
1051
  );
640
1052
  }
641
1053
 
@@ -662,10 +1074,10 @@ window.__ModuleLoader__.load({
662
1074
  answered: "answered",
663
1075
  already_have: "not filed — the corpus already covers this",
664
1076
  converted: "your report is cited by a lesson",
665
- unknown: "submitted",
1077
+ unknown: T("intake.submitted"),
666
1078
  };
667
1079
  var head = [
668
- h("strong", { key: "brand" }, "MisakaNet report"),
1080
+ h("strong", { key: "brand" }, T("panel.report.title")),
669
1081
  h("span", { key: "state", style: DIM }, " " + (labels[state] || state)),
670
1082
  ];
671
1083
  var body = [h("div", { key: "head" }, head)];
@@ -678,7 +1090,7 @@ window.__ModuleLoader__.load({
678
1090
  }
679
1091
  if (payload && payload.lesson && payload.lesson.id) {
680
1092
  body.push(h("div", { key: "lesson", style: DIM },
681
- "covered by " + payload.lesson.id + (payload.lesson.similarity ? " (similarity " + payload.lesson.similarity + ")" : "")));
1093
+ T("panel.report.coveredBy") + payload.lesson.id + (payload.lesson.similarity ? T("panel.report.similarity") + payload.lesson.similarity + ")" : "")));
682
1094
  }
683
1095
  return h("div", { style: BOX }, body);
684
1096
  }
@@ -708,6 +1120,8 @@ window.__ModuleLoader__.load({
708
1120
  setRevision(function (n) { return n + 1; });
709
1121
  });
710
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; }); }); }, []);
711
1125
 
712
1126
  var log = sessionId ? sessions[sessionId] : null;
713
1127
  var searches = log ? log.searches : [];
@@ -762,25 +1176,25 @@ window.__ModuleLoader__.load({
762
1176
  rows.push(h("div", { key: "title", style: { fontSize: "15px", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px" } },
763
1177
  MisakanetGlyph({ size: 18 }), h("span", null, "MisakaNet")));
764
1178
  rows.push(h("div", { key: "scope", style: DIM },
765
- "this session · counters local to this browser · no account, nothing uploaded but your votes and reports"));
1179
+ T("panel.counters")));
766
1180
 
767
1181
  rows.push(h("div", { key: "strip", style: Object.assign({}, BOX, { marginTop: "8px" }) },
768
- count(searches.length, "search", "searches") + " · " + withLesson + " with a lesson · " +
769
- noMatch + " nothing found · " + count(lessons.length, "lesson") + " surfaced · " +
770
- count(votes.length, "vote") + " (" + helpful + " helpful) · " +
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) · " +
771
1185
  count(intakes.length, "report") + " (" + converted + " became a lesson)"));
772
1186
 
773
1187
  // ① what this session asked
774
- rows.push(h("div", { key: "s1", style: { marginTop: "12px", fontWeight: 600 } }, "What this session asked"));
1188
+ rows.push(h("div", { key: "s1", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.asked")));
775
1189
  if (!searches.length) {
776
- rows.push(h("div", { key: "s1e", style: DIM }, "No MisakaNet search has run in this session yet."));
1190
+ rows.push(h("div", { key: "s1e", style: DIM }, T("panel.asked.none")));
777
1191
  } else {
778
1192
  var searchRows = searches.slice().reverse().slice(0, 20).map(function (entry) {
779
1193
  var vote = entry.voted ? (entry.voteKind === "helpful" ? "Helpful" : "not helpful") : (entry.lessonId ? "not voted" : "no lesson");
780
1194
  return h("div", { key: "q" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
781
1195
  h("span", { style: DIM }, clockOf(entry.at) + " "),
782
- h("span", {}, entry.query || "(no query text)"),
783
- h("span", { style: DIM }, " " + (entry.noMatch ? "nothing found" : count(entry.hits, "lesson"))),
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")))),
784
1198
  entry.title ? h("span", { style: DIM }, " · top: " + entry.title + (entry.evidence ? " (" + entry.evidence + ")" : "")) : null,
785
1199
  h("span", { style: DIM }, " · " + vote));
786
1200
  });
@@ -788,7 +1202,7 @@ window.__ModuleLoader__.load({
788
1202
  }
789
1203
 
790
1204
  // ② reports filed
791
- rows.push(h("div", { key: "s2", style: { marginTop: "12px", fontWeight: 600 } }, "Reports you filed"));
1205
+ rows.push(h("div", { key: "s2", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.reports")));
792
1206
  var openReports = intakes.filter(function (entry) {
793
1207
  return entry.state !== "converted" && entry.state !== "answered" && entry.state !== "already_have";
794
1208
  });
@@ -799,62 +1213,61 @@ window.__ModuleLoader__.load({
799
1213
  // is not allowed to do that, so the panel says who can re-check instead of offering it —
800
1214
  // and only for the reports that are actually still open.
801
1215
  rows.push(h("div", { key: "s2h", style: DIM },
802
- count(openReports.length, "pending report") + " is re-checked when that text is submitted again — by the agent, not by this page."));
1216
+ count(openReports.length, T("panel.report.pending")) + T("panel.report.recheck")));
803
1217
  }
804
1218
  if (!intakes.length) {
805
- rows.push(h("div", { key: "s2e", style: DIM }, "Nothing filed from this session."));
1219
+ rows.push(h("div", { key: "s2e", style: DIM }, T("panel.reports.none")));
806
1220
  } else {
807
1221
  rows.push(h("div", { key: "s2r" }, intakes.slice().reverse().map(function (entry) {
808
1222
  var labels = {
809
- filing: "filing", pending: "filed · waiting", answered: "answered",
810
- already_have: "not filed — already covered", converted: "cited by a lesson", unknown: "submitted",
1223
+ filing: "filing", pending: T("intake.waiting"), answered: "answered",
1224
+ already_have: T("intake.alreadyHave"), converted: T("intake.cited"), unknown: "submitted",
811
1225
  };
812
1226
  var issue = String(entry.issue || "").replace(/^issue-/, "#").replace(/^.*\/issues\//, "#");
813
1227
  return h("div", { key: "r" + entry.callId + entry.at, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
814
1228
  h("span", { style: DIM }, clockOf(entry.at) + " "),
815
1229
  issue ? h("span", { style: DIM }, issue + " ") : null,
816
1230
  h("span", { style: DIM }, (labels[entry.state] || entry.state) + " "),
817
- h("span", {}, entry.text ? (entry.text.length > 90 ? entry.text.slice(0, 90) + "…" : entry.text) : "(text not captured)"),
1231
+ h("span", {}, entry.text ? (entry.text.length > 90 ? entry.text.slice(0, 90) + "…" : entry.text) : T("intake.noText")),
818
1232
  entry.receipt ? h("div", { style: DIM }, entry.receipt) : null,
819
1233
  entry.answer ? h("div", {}, entry.answer) : null);
820
1234
  })));
821
1235
  }
822
1236
 
823
1237
  // ③ how much these lessons are trusted
824
- rows.push(h("div", { key: "s3", style: { marginTop: "12px", fontWeight: 600 } }, "How much these lessons are trusted"));
1238
+ rows.push(h("div", { key: "s3", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.trust")));
825
1239
  if (!lessons.length) {
826
- rows.push(h("div", { key: "s3e", style: DIM }, "No lesson surfaced yet."));
1240
+ rows.push(h("div", { key: "s3e", style: DIM }, T("panel.trust.none")));
827
1241
  } else {
828
1242
  // The rule once, not per row: it is the same sentence for every lesson, and repeating it
829
1243
  // four times buries the numbers it is there to explain.
830
1244
  rows.push(h("div", { key: "s3rule", style: DIM },
831
- "The level is the lesson's own stored evidence. The count is other people's confirmations, not yours: " +
832
- "one makes the reuse event appear, the second is what agents read as E4."));
1245
+ T("panel.trust.footnote")));
833
1246
  rows.push(h("div", { key: "s3r" }, lessons.map(function (lesson) {
834
1247
  var confirmations = trust[lesson.lessonId];
835
- var counted = confirmations === undefined ? "checking…"
836
- : (confirmations === null ? "unavailable"
837
- : count(confirmations, "human confirmation") + (confirmations >= 2 ? " → E4" : ""));
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" : ""));
838
1251
  return h("div", { key: "l" + lesson.lessonId, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
839
1252
  h("span", {}, lesson.title || lesson.lessonId),
840
- h("span", { style: DIM }, " " + (lesson.evidence || "no level") + " · " + counted +
841
- " · used " + lesson.count + "× · " + timeRange(lesson)));
1253
+ h("span", { style: DIM }, " " + (lesson.evidence || T("panel.trust.none")) + " · " + counted +
1254
+ T("panel.trust.used") + lesson.count + "× · " + timeRange(lesson)));
842
1255
  })));
843
1256
  }
844
1257
 
845
1258
  // ④ your activity, two scopes
846
- rows.push(h("div", { key: "s4", style: { marginTop: "12px", fontWeight: 600 } }, "Your activity"));
1259
+ rows.push(h("div", { key: "s4", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.activity")));
847
1260
  rows.push(h("div", { key: "s4r", style: BOX },
848
- h("div", {}, "this session: " + count(searches.length, "search", "searches") + " · " + count(lessons.length, "lesson") +
849
- " reused · " + count(votes.length, "vote") + " · " + count(intakes.length, "report") + " filed · " +
850
- count(converted, "conversion")),
851
- h("div", {}, "on this browser: " + count(stats.searches || 0, "search", "searches") + " · " +
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")) + " · " +
852
1265
  count(stats.lessonsReused || 0, "lesson") + " reused · " + count(stats.votes || 0, "vote") + " · " +
853
1266
  count(stats.reportsFiled || 0, "report") + " filed · " + count(stats.reportsConverted || 0, "conversion")),
854
- h("div", { style: DIM }, "The browser column survives a reload; the session column is rebuilt from this conversation's transcript and starts again in a new session.")));
1267
+ h("div", { style: DIM }, T("panel.activity.scope"))));
855
1268
 
856
1269
  // ⑤ voice
857
- rows.push(h("div", { key: "s5", style: { marginTop: "12px", fontWeight: 600 } }, "Voice"));
1270
+ rows.push(h("div", { key: "s5", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.voice")));
858
1271
  rows.push(h("div", { key: "s5r", style: BOX },
859
1272
  h("button", {
860
1273
  type: "button",
@@ -864,22 +1277,19 @@ window.__ModuleLoader__.load({
864
1277
  setVoiceEnabled(next);
865
1278
  setVoice(next);
866
1279
  },
867
- }, voice ? "Voice cues: on" : "Voice cues: off"),
1280
+ }, T(voice ? "panel.voice.on" : "panel.voice.off")),
868
1281
  lastCue && h("button", {
869
1282
  type: "button",
870
1283
  style: BUTTON,
871
1284
  onClick: function () { playCue(lastCue); },
872
- }, "Play “" + lastCue + "”"),
1285
+ }, T("panel.voice.play") + lastCue + "”"),
873
1286
  h("div", { style: DIM }, lastCue
874
- ? "The cue is the server's own choice for the last search (" + lastCue + "), not the UI's guess. Browser playback only, and off by default — nothing plays until you turn it on or press play."
875
- : "No cue yet: the server names one on the first search of the session."),
876
- h("div", { style: DIM }, "The local hook is a different thing: it is installed with `--voice` and mutes with MISAKANET_VOICE=0, plays through this machine's own player, and can also raise a desktop notification. A page cannot read or change it, so this panel does not report its state.")));
1287
+ ? T("panel.voice.cue") + lastCue + T("panel.voice.cueTail")
1288
+ : T("panel.voice.none")),
1289
+ h("div", { style: DIM }, T("panel.voice.hook"))));
877
1290
 
878
1291
  rows.push(h("div", { key: "foot", style: Object.assign({}, DIM, { marginTop: "12px" }) },
879
- "Every number above comes from this conversation's own transcript rows plus one public GET per lesson " +
880
- "(/api/helpful). The trust counts are other people's confirmations, not yours. Reloading the window loses " +
881
- "the session rows; the browser counters stay. Receipts shown here are what the agent's own calls returned — " +
882
- "a pending report is re-checked when the agent asks again, not by this page."));
1292
+ T("panel.footnote")));
883
1293
 
884
1294
  return h("div", { style: { fontSize: "12px", lineHeight: "1.7", padding: "4px 2px" } }, rows);
885
1295
  }
@@ -912,6 +1322,515 @@ window.__ModuleLoader__.load({
912
1322
  return h(react.Fragment, null, MisakanetGlyph({ size: 16 }), h("span", { style: { paddingLeft: "4px" } }, "MisakaNet"));
913
1323
  }
914
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
+
915
1834
  /**
916
1835
  * The left column's icon seat (`sidebar.panellist`, root scope → it does not come and go with a
917
1836
  * session). The owner supplies `{size, active}` and owns the button: the id we register here is the
@@ -938,8 +1857,7 @@ window.__ModuleLoader__.load({
938
1857
  return h("div", { style: { height: "100%", overflowY: "auto", padding: "28px 32px 48px" } },
939
1858
  h("div", { style: { maxWidth: "860px", margin: "0 auto" } },
940
1859
  h("p", { style: { margin: "0 0 14px", color: "inherit", opacity: 0.7, fontSize: "13px", lineHeight: "1.5" } },
941
- "The left column opens this page; the session-scoped counters live in the session's " +
942
- "right-sidebar MisakaNet tab. The numbers here are what this browser has contributed."),
1860
+ T("main.footnote")),
943
1861
  h(MisakanetPanel, { sessionId: props && props.sessionId })));
944
1862
  }
945
1863
 
@@ -1014,6 +1932,97 @@ window.__ModuleLoader__.load({
1014
1932
  { name: "main", key: PANEL_ID },
1015
1933
  MisakanetMainView, "main panel");
1016
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
+
1017
2026
  // The right sidebar ships from 0.1.5 on, so this rides a deferred service inject: on a line
1018
2027
  // without the registry the callback never runs, the fiber never pends, and the conversation tab
1019
2028
  // above is the whole plugin. Inside, the registry is re-proved and every disposer is collected.
@@ -1041,7 +2050,7 @@ window.__ModuleLoader__.load({
1041
2050
  id: "panel",
1042
2051
  order: 30,
1043
2052
  title: function () { return "MisakaNet"; },
1044
- description: function () { return "What this session asked, what came back, and what you filed"; },
2053
+ description: function () { return T("guide.description"); },
1045
2054
  icon: MisakanetGlyph,
1046
2055
  }],
1047
2056
  }));