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