@chatpanel/events 0.2.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/kernel.js ADDED
@@ -0,0 +1,255 @@
1
+ // The plugin kernel — the part that cannot itself be a plugin.
2
+ //
3
+ // Everything in ChatPanel is a plugin: the loop, tools, skills, storage, renderers, UI
4
+ // slots, sources, model providers, presets, redaction POLICY. This file is what makes
5
+ // that safe to say.
6
+ //
7
+ // "Security is a mandatory plugin" is only meaningful if something that is NOT a plugin
8
+ // enforces the mandate — otherwise mandatory is a config value, and a config value is
9
+ // disableable. So the kernel SHRINKS rather than grows. It does three things, none of
10
+ // them extensible, and deliberately nothing else:
11
+ //
12
+ // 1. load plugins (delegated to the registry — revertible effects, reactive availability);
13
+ // 2. refuse to run when a required plugin is absent or failed;
14
+ // 3. enforce guard monotonicity — a guard may reduce permission, never widen it.
15
+ //
16
+ // POLICY VS MECHANISM. Redaction policy (what counts as sensitive, which dial the user
17
+ // picked, whether an org gets visibility) is a plugin. The redaction mechanism and the
18
+ // egress guard are not. That is what lets privacy be a real on/off dial without ever
19
+ // creating a path where bytes leave unguarded: security is not a dial, privacy is.
20
+ //
21
+ // NO DEPENDENCY ON THE EVENT LOG, matching registry.js — `onEvent` is a plain hook the
22
+ // host maps to schema events if it wants them. That keeps the kernel runnable in the
23
+ // extension, the gateway and the bridge unchanged.
24
+
25
+ import { createRegistry } from './registry.js';
26
+
27
+ export class KernelError extends Error {
28
+ constructor(code, message, detail = null) {
29
+ super(message);
30
+ this.name = 'KernelError';
31
+ this.code = code;
32
+ this.detail = detail;
33
+ }
34
+ }
35
+
36
+ /** The default non-negotiables. A host may require more; it may not require fewer. */
37
+ export const REQUIRED_PLUGINS = Object.freeze(['security']);
38
+
39
+ /**
40
+ * A decision is a permission, not a boolean: `allow` plus the scopes it covers.
41
+ * Kept deliberately small — anything richer invites a lattice we cannot check.
42
+ */
43
+ export const ALLOW_ALL = Object.freeze({ allow: true, scopes: null, reasons: [] });
44
+
45
+ const asScopeSet = (s) => (s == null ? null : new Set(s));
46
+
47
+ /**
48
+ * The MEET of two decisions — the heart of the monotonicity guarantee.
49
+ *
50
+ * A guard cannot widen because widening is never constructed, not because we inspect the
51
+ * result and complain. Detect-and-reject would leave a window in which the widened value
52
+ * existed and could be read by whatever ran next; a meet has no such window.
53
+ *
54
+ * `scopes: null` means "unscoped / all", so it is the top of the lattice and intersecting
55
+ * with it is identity — that keeps a guard that does not care about scopes from
56
+ * accidentally narrowing to nothing.
57
+ */
58
+ export function meetDecisions(a, b) {
59
+ const as = asScopeSet(a.scopes);
60
+ const bs = asScopeSet(b.scopes);
61
+ let scopes = null;
62
+ if (as && bs) scopes = [...as].filter((s) => bs.has(s));
63
+ else if (as) scopes = [...as];
64
+ else if (bs) scopes = [...bs];
65
+ return {
66
+ allow: !!a.allow && !!b.allow,
67
+ scopes,
68
+ reasons: [...(a.reasons || []), ...(b.reasons || [])],
69
+ };
70
+ }
71
+
72
+ /** True when `out` asked for more than `input` allowed — a misbehaving or hostile plugin. */
73
+ function widened(input, out) {
74
+ if (!input.allow && out.allow) return true;
75
+ const is = asScopeSet(input.scopes);
76
+ const os = asScopeSet(out.scopes);
77
+ if (!is) return false; // input was unscoped: nothing to widen past
78
+ if (!os) return true; // guard tried to drop scoping entirely
79
+ return [...os].some((s) => !is.has(s));
80
+ }
81
+
82
+ /**
83
+ * @param required plugin ids that must be active before `start()` resolves. Callers may
84
+ * add to REQUIRED_PLUGINS; they cannot remove from it.
85
+ * @param onEvent `{ event, ...detail }` — 'plugin:activated' | 'plugin:failed' |
86
+ * 'guard:widened' | 'started' | 'stopped'.
87
+ */
88
+ export function createKernel({ required = [], onEvent = null } = {}) {
89
+ const requiredIds = [...new Set([...REQUIRED_PLUGINS, ...required])];
90
+ const registry = createRegistry({
91
+ onEvent: (d) => emit(`registry:${d.event}`, d),
92
+ });
93
+
94
+ const declared = new Map(); // id -> declaration
95
+ const handles = new Map(); // id -> registry handle
96
+ const guards = new Map(); // name -> [{ pluginId, fn }]
97
+ let started = false;
98
+
99
+ const emit = (event, detail = {}) => { if (onEvent) onEvent({ event, ...detail }); };
100
+
101
+ /**
102
+ * Declare a plugin. Declaration is NOT activation: manifests are static so the kernel
103
+ * can answer "what is installed" without executing anything, and `activate` is where a
104
+ * host does its `await import()`. A kernel that had to load every plugin to know what
105
+ * it had would put the whole graph on first paint, and first paint is a release gate.
106
+ */
107
+ function define(plugin) {
108
+ const { id, requires = [], activate, load } = plugin || {};
109
+ if (!id || typeof id !== 'string') throw new KernelError('BAD_PLUGIN', 'plugin.id required');
110
+ if (typeof activate !== 'function' && typeof load !== 'function') {
111
+ throw new KernelError('BAD_PLUGIN', `plugin '${id}': activate or load required`);
112
+ }
113
+ if (declared.has(id)) throw new KernelError('DUPLICATE', `plugin '${id}' is already defined`);
114
+ declared.set(id, { ...plugin, requires: [...requires] });
115
+ return () => remove(id);
116
+ }
117
+
118
+ function remove(id) {
119
+ if (requiredIds.includes(id)) {
120
+ // Not a permission check that could be satisfied — there is no argument that makes
121
+ // removing the security plugin acceptable, so it is not expressible.
122
+ throw new KernelError('REQUIRED', `plugin '${id}' is required and cannot be removed`);
123
+ }
124
+ declared.delete(id);
125
+ const h = handles.get(id);
126
+ handles.delete(id);
127
+ for (const [name, list] of guards) {
128
+ const kept = list.filter((g) => g.pluginId !== id);
129
+ if (kept.length) guards.set(name, kept); else guards.delete(name);
130
+ }
131
+ return h ? h.dispose() : Promise.resolve();
132
+ }
133
+
134
+ /** The scope a plugin's `activate` receives — the registry's, plus guard registration. */
135
+ function scopeFor(id, inner) {
136
+ return {
137
+ ...inner,
138
+ /**
139
+ * Register a guard. It receives `(request, decision)` and returns a decision; the
140
+ * kernel meets its answer with what came in, so it can only ever narrow.
141
+ */
142
+ guard(name, fn) {
143
+ if (typeof fn !== 'function') throw new KernelError('BAD_GUARD', `guard '${name}' must be a function`);
144
+ const list = guards.get(name) || [];
145
+ list.push({ pluginId: id, fn });
146
+ guards.set(name, list);
147
+ return () => {
148
+ const cur = (guards.get(name) || []).filter((g) => g.fn !== fn);
149
+ if (cur.length) guards.set(name, cur); else guards.delete(name);
150
+ };
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Run every guard for `name` and return the narrowest decision any of them permits.
157
+ * Order-independent by construction: meet is associative and commutative, so guards
158
+ * cannot race each other into a different answer — which is what lets them be plugins.
159
+ */
160
+ function decide(name, request, initial = ALLOW_ALL) {
161
+ let decision = { allow: !!initial.allow, scopes: initial.scopes ?? null, reasons: [...(initial.reasons || [])] };
162
+ for (const { pluginId, fn } of guards.get(name) || []) {
163
+ let out;
164
+ try {
165
+ out = fn(request, decision);
166
+ } catch (err) {
167
+ // A guard that throws is a guard that did not permit. Fail closed: the
168
+ // alternative is that crashing a guard becomes a way to bypass it.
169
+ decision = meetDecisions(decision, { allow: false, scopes: null, reasons: [`${pluginId}: guard threw (${err.message})`] });
170
+ continue;
171
+ }
172
+ if (out == null) continue; // abstained
173
+ if (out === true) continue; // permitted, unchanged
174
+ if (out === false) out = { allow: false, scopes: null, reasons: [`${pluginId}: denied`] };
175
+ const norm = { allow: out.allow !== false, scopes: out.scopes ?? null, reasons: out.reasons || [] };
176
+ if (widened(decision, norm)) emit('guard:widened', { guard: name, pluginId });
177
+ decision = meetDecisions(decision, norm);
178
+ }
179
+ return decision;
180
+ }
181
+
182
+ /**
183
+ * Activate everything declared, then verify the required set is genuinely ACTIVE —
184
+ * not merely declared. A required plugin that failed to activate is the same problem
185
+ * as one that was never installed, and the kernel must not be the component that
186
+ * papers over the difference.
187
+ */
188
+ async function start() {
189
+ if (started) return kernel;
190
+ const missing = requiredIds.filter((id) => !declared.has(id));
191
+ if (missing.length) {
192
+ throw new KernelError('MISSING_REQUIRED', `kernel will not start: required plugin(s) not defined: ${missing.join(', ')}`, missing);
193
+ }
194
+ // Required plugins first, so a dependent that reaches for security during its own
195
+ // activation finds it — the same ordering rule the registry applies to teardown.
196
+ const ordered = [...declared.values()].sort(
197
+ (a, b) => (requiredIds.includes(b.id) ? 1 : 0) - (requiredIds.includes(a.id) ? 1 : 0),
198
+ );
199
+ // Resolve `load` BEFORE registering. Registry activation is synchronous — an effect
200
+ // registered after activation returned would not be in the disposer stack, so the
201
+ // inverse would not run. Awaiting the import here is what keeps a manifest static
202
+ // (the kernel knows what is installed without executing it) while the module itself
203
+ // still only costs anything when it activates.
204
+ for (const p of ordered) {
205
+ if (handles.has(p.id)) continue;
206
+ let activate = p.activate;
207
+ if (!activate) {
208
+ try {
209
+ const mod = await p.load();
210
+ activate = mod?.activate || mod?.default;
211
+ } catch (err) {
212
+ throw new KernelError('LOAD_FAILED', `plugin '${p.id}' failed to load: ${err.message}`, p.id);
213
+ }
214
+ if (typeof activate !== 'function') {
215
+ throw new KernelError('BAD_PLUGIN', `plugin '${p.id}': loaded module exports no activate`, p.id);
216
+ }
217
+ }
218
+ handles.set(p.id, registry.register({
219
+ name: p.id,
220
+ requires: p.requires,
221
+ apply: (inner) => activate(scopeFor(p.id, inner)),
222
+ }));
223
+ }
224
+ const notActive = requiredIds.filter((id) => handles.get(id)?.state !== 'active');
225
+ if (notActive.length) {
226
+ const detail = notActive.map((id) => ({ id, error: handles.get(id)?.error?.message || 'inactive', waitingFor: registry.pending().find((p) => p.name === id)?.waitingFor || [] }));
227
+ await registry.dispose();
228
+ throw new KernelError('REQUIRED_INACTIVE', `kernel will not start: required plugin(s) not active: ${notActive.join(', ')}`, detail);
229
+ }
230
+ started = true;
231
+ emit('started', { plugins: registry.active() });
232
+ return kernel;
233
+ }
234
+
235
+ const kernel = {
236
+ define,
237
+ remove,
238
+ decide,
239
+ start,
240
+ get started() { return started; },
241
+ /** What is installed, whether or not it activated — a manifest question. */
242
+ list: () => [...declared.keys()].sort(),
243
+ active: () => registry.active(),
244
+ pending: () => registry.pending(),
245
+ required: () => [...requiredIds],
246
+ guards: () => [...guards.keys()].sort(),
247
+ registry,
248
+ async stop() {
249
+ started = false;
250
+ await registry.dispose();
251
+ emit('stopped', {});
252
+ },
253
+ };
254
+ return kernel;
255
+ }
package/loop.js ADDED
@@ -0,0 +1,132 @@
1
+ // The loop contract — turn lifetime belongs to the kernel, not to the loop.
2
+ //
3
+ // We already run four loops: chat, meeting scribe, watch, and the notes swarm. They were
4
+ // never expressed once; each surface grew its own, and the shared parts were copied. On
5
+ // 2026-08-16 that produced a real bug — `streamChat` opened a turn, one exit path
6
+ // returned above the close, and every finished note reported itself as still running.
7
+ //
8
+ // The fix that day was to route both exits through one helper. The fix here is that
9
+ // there is nothing to route: a loop's `run` receives a context with NO way to open or
10
+ // close a turn. The runner opens before calling it and closes in `finally`, so "forgot to
11
+ // close on one path" is not a mistake a loop author is able to make. That is the
12
+ // difference between a bug fixed and a bug class removed.
13
+ //
14
+ // The runner takes `now`/`newId` rather than reading a clock, matching event.js: this
15
+ // package stays pure so the identical code runs in the extension, gateway and bridge, and
16
+ // so replay is reproducible (order.js never consults wall time; I6 asserts it).
17
+
18
+ export const LOOP_KINDS = Object.freeze(['chat', 'note', 'meeting', 'watch', 'assist', 'suggestion', 'topics', 'other']);
19
+
20
+ export class LoopError extends Error {
21
+ constructor(code, message) { super(message); this.name = 'LoopError'; this.code = code; }
22
+ }
23
+
24
+ /**
25
+ * Declare a loop. A declaration is inert — it names a kind and supplies `run`; the runner
26
+ * supplies everything about when a turn exists.
27
+ */
28
+ export function defineLoop({ id, kind = 'other', run, background = false }) {
29
+ if (!id) throw new LoopError('BAD_LOOP', 'loop.id required');
30
+ if (typeof run !== 'function') throw new LoopError('BAD_LOOP', `loop '${id}': run required`);
31
+ return Object.freeze({ id, kind, run, background: !!background });
32
+ }
33
+
34
+ /**
35
+ * @param emit `(type, payload)` — the host maps these to the event schema. Kept as a
36
+ * plain hook so this module has no dependency on the log, matching registry.js.
37
+ * @param decide optional `(guard, request) => decision` — normally `kernel.decide`. A
38
+ * security plugin can refuse a turn before any model call happens, which is
39
+ * the whole reason guards exist below the loop rather than inside it.
40
+ */
41
+ export function createTurnRunner({ now = () => 0, newId, emit = () => {}, decide = null } = {}) {
42
+ if (typeof newId !== 'function') throw new LoopError('BAD_RUNNER', 'newId required — the runner must not invent identity');
43
+
44
+ /**
45
+ * Run one turn of `loop`. Returns whatever `run` returned.
46
+ *
47
+ * `request.turnId` lets a caller supply identity it already has (the side panel's
48
+ * assistant-message id), so the turn record and the tool events that reference it group
49
+ * into ONE run rather than two — the mismatch that split runs in Activity.
50
+ */
51
+ async function run(loop, request = {}) {
52
+ if (!loop || typeof loop.run !== 'function') throw new LoopError('BAD_LOOP', 'runner.run needs a loop with run()');
53
+
54
+ const turnId = request.turnId || newId();
55
+ const kind = request.kind || loop.kind || 'other';
56
+
57
+ // Ask BEFORE opening a turn: a denied turn should leave no trace of having started,
58
+ // and a guard that only ran after the model call would be documentation, not a guard.
59
+ if (decide) {
60
+ const d = decide('turn.start', { turnId, kind, loopId: loop.id, ...request });
61
+ if (d && d.allow === false) {
62
+ emit('policy.denied', { turnId, kind, loopId: loop.id, reasons: d.reasons || [] });
63
+ throw new LoopError('DENIED', `turn denied: ${(d.reasons || []).join('; ') || 'policy'}`);
64
+ }
65
+ }
66
+
67
+ // The turn began when the USER acted, not when the model call did. Everything between
68
+ // — assembling tools, connecting to MCP servers — is time they waited, and a duration
69
+ // that excludes it says 2.6s about a message that took 48. Callers that know the real
70
+ // moment pass it; the rest fall back to now.
71
+ const startedAt = Number.isFinite(request.startedAt) ? request.startedAt : now();
72
+ let closed = false;
73
+ // Facts the loop learns while running — token usage, the model that actually served
74
+ // it — belong on the turn record. The loop may CONTRIBUTE them; it still cannot
75
+ // decide when the turn ends. Handing over the payload is not the same as handing over
76
+ // the lifetime, and only the second one was ever the problem.
77
+ let reported = {};
78
+ // The ONE place a turn closes. Idempotent because a retry path or a double-catch must
79
+ // not be able to write two endings for one turn — a log that can say a turn ended
80
+ // twice cannot be replayed.
81
+ const close = (reason, produced) => {
82
+ if (closed) return;
83
+ closed = true;
84
+ emit('turn.ended', { ...reported, turnId, reason, stepped: !!produced, ms: now() - startedAt, kind });
85
+ };
86
+
87
+ emit('turn.started', {
88
+ turnId, kind, loopId: loop.id,
89
+ agentId: request.agentId || null,
90
+ // WHICH THREAD THIS BELONGS TO. A run on its own is not the unit anyone reasons about —
91
+ // a conversation is, and a meeting or a note can hold many runs (live monitors,
92
+ // summaries, a swarm of agents). Without both halves the log is 1,205 unrelated rows.
93
+ surface: request.surface || null,
94
+ sourceId: request.sourceId || null,
95
+ background: request.background ?? loop.background,
96
+ });
97
+
98
+ let produced = false;
99
+ try {
100
+ // NOTE what this context does NOT contain: no close, no end, no turn handle. A loop
101
+ // cannot leave its own turn open because it was never given the ability to close it.
102
+ const result = await loop.run({
103
+ turnId,
104
+ kind,
105
+ signal: request.signal,
106
+ request,
107
+ /** Report that something reached the user — this is a fact about the turn, not control over it. */
108
+ produced: () => { produced = true; },
109
+ /**
110
+ * Contribute facts to the turn record (tokens, model, cost). Merged into
111
+ * `turn.ended`, and never able to overwrite the fields the runner owns — a loop
112
+ * that could rewrite its own turnId or reason would be holding lifetime again by
113
+ * another name.
114
+ */
115
+ report: (fields) => { if (fields && typeof fields === 'object') reported = { ...reported, ...fields }; },
116
+ /** Loops emit their own domain events; lifetime is still not theirs. */
117
+ emit: (type, payload) => emit(type, { turnId, ...payload }),
118
+ });
119
+ if (result !== undefined && result !== null && result !== '') produced = true;
120
+ close(request.signal?.aborted ? 'aborted' : 'ok', produced);
121
+ return result;
122
+ } catch (err) {
123
+ // An abort is not a failure: the user asking it to stop and the loop breaking are
124
+ // different facts, and collapsing them makes the log useless for the question people
125
+ // actually ask ("did it fail, or did I stop it?").
126
+ close(request.signal?.aborted ? 'aborted' : 'error', produced);
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ return { run };
132
+ }
package/manifest.js ADDED
@@ -0,0 +1,107 @@
1
+ // What is installed, and what the user has switched off.
2
+ //
3
+ // Four registries now exist — adapters, tool groups, search engines, sources — and each is
4
+ // small and correct on its own. What none of them can answer is the question a user asks:
5
+ // "what is running, and can I turn that off?" Answering it in four places would be the
6
+ // duplication that already bit us with the engine list, one level up.
7
+ //
8
+ // So the KERNEL owns the manifest and the registries consult it. This is admission control,
9
+ // not a rewrite: a registry keeps its own shape and simply refuses to offer something the
10
+ // user has disabled. It is also the seam that makes user-contributed plugins possible —
11
+ // once a plugin can arrive from outside, "is this allowed to run" must be asked somewhere
12
+ // that is not the plugin itself.
13
+ //
14
+ // Persistence is INJECTED. Where the toggles live is a platform question (chrome.storage,
15
+ // a file, a database) and the manifest has no business knowing.
16
+
17
+ export class ManifestError extends Error {
18
+ constructor(code, message) { super(message); this.name = 'ManifestError'; this.code = code; }
19
+ }
20
+
21
+ /** Where a plugin came from. `user` is the one that must never skip a guard. */
22
+ export const SOURCES = Object.freeze(['built-in', 'user']);
23
+
24
+ /**
25
+ * @param required ids that cannot be disabled, whatever the stored state says. Security is
26
+ * the canonical member: a mandatory plugin the user can switch off is not mandatory,
27
+ * it is a default.
28
+ * @param disabled the user's stored choices — ids they have turned OFF. Stored as the
29
+ * exception rather than the full state on purpose: a plugin added in a later release
30
+ * is then enabled by default without a migration, because absence means "not
31
+ * disabled" rather than "unknown".
32
+ */
33
+ export function createManifest({ required = ['security'], disabled = [], onChange = null } = {}) {
34
+ const entries = new Map();
35
+ const off = new Set(disabled);
36
+ const req = new Set(required);
37
+
38
+ const notify = () => { if (onChange) onChange([...off].sort()); };
39
+
40
+ return {
41
+ /**
42
+ * Declare something installed. Idempotent, so a registry can register on every build
43
+ * without accumulating duplicates.
44
+ */
45
+ register({ id, kind, label, source = 'built-in', description = '' }) {
46
+ if (!id) throw new ManifestError('BAD_ENTRY', 'plugin id required');
47
+ if (!SOURCES.includes(source)) throw new ManifestError('BAD_ENTRY', `plugin '${id}': unknown source '${source}'`);
48
+ entries.set(id, { id, kind: kind || 'plugin', label: label || id, source, description });
49
+ return () => entries.delete(id);
50
+ },
51
+
52
+ /**
53
+ * The question every registry asks. Unknown ids are ENABLED: a registry may consult the
54
+ * manifest before anything has registered, and defaulting to off would make a plugin
55
+ * silently vanish because of a load-order accident.
56
+ */
57
+ isEnabled(id) {
58
+ if (req.has(id)) return true;
59
+ return !off.has(id);
60
+ },
61
+
62
+ /** Turn something on or off. Required plugins refuse rather than reporting success. */
63
+ setEnabled(id, enabled) {
64
+ if (req.has(id) && !enabled) {
65
+ throw new ManifestError('REQUIRED', `'${id}' is required and cannot be disabled`);
66
+ }
67
+ const was = !off.has(id);
68
+ if (enabled) off.delete(id); else off.add(id);
69
+ if (was !== !!enabled) notify();
70
+ return this.isEnabled(id);
71
+ },
72
+
73
+ /** Everything installed, with its current state — what a settings page renders. */
74
+ list() {
75
+ return [...entries.values()]
76
+ .map((e) => ({ ...e, enabled: this.isEnabled(e.id), required: req.has(e.id) }))
77
+ .sort((a, b) => (a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind.localeCompare(b.kind)));
78
+ },
79
+
80
+ /** Only the ids that are OFF — the shape that persists (see the note above). */
81
+ disabledIds: () => [...off].sort(),
82
+
83
+ /**
84
+ * Adopt state changed elsewhere.
85
+ *
86
+ * A manifest is not a fact read once at startup: the user toggles a plugin in one place
87
+ * (a settings page, another window, another device) and every OTHER place holding a
88
+ * manifest is now wrong. Without this, a toggle appears to do nothing until reload —
89
+ * which reads as the switch being broken.
90
+ *
91
+ * Deliberately does NOT notify: this is the arrival of someone else's change, and
92
+ * echoing it back is how two contexts write over each other forever.
93
+ */
94
+ sync(ids) {
95
+ const next = new Set(Array.isArray(ids) ? ids : []);
96
+ if (next.size === off.size && [...next].every((id) => off.has(id))) return false;
97
+ off.clear();
98
+ for (const id of next) off.add(id);
99
+ return true;
100
+ },
101
+
102
+ /** Filter a registry's candidates. The one call a registry needs to make. */
103
+ filter(items, idOf = (x) => x?.id) {
104
+ return (items || []).filter((x) => this.isEnabled(idOf(x)));
105
+ },
106
+ };
107
+ }
package/mcp-errors.js ADDED
@@ -0,0 +1,87 @@
1
+ // Turn an MCP launch failure into something a person can act on.
2
+ //
3
+ // A local MCP server that will not start reports whatever its process printed, and that is
4
+ // usually a wall of shell noise. A real example: a published package whose executable has
5
+ // no `#!/usr/bin/env node` line, so the SHELL ran a JavaScript file and produced twelve
6
+ // lines of "import: command not found". Nothing in that says what is wrong, whose fault it
7
+ // is, or what to do — and the natural reading is "ChatPanel is broken", which is the one
8
+ // interpretation that is definitely false.
9
+ //
10
+ // The signatures are recognisable, so recognising them is cheap. Where we cannot recognise
11
+ // one, the raw output is still shown: a wrong explanation is worse than none.
12
+ //
13
+ // Shared because the gateway and the bridge launch the same servers and will hit the same
14
+ // failures — diagnosing them in three places would produce three different diagnoses.
15
+
16
+ const RULES = [
17
+ {
18
+ id: 'missing-shebang',
19
+ // `import:`/`const:` "command not found" means a shell executed JavaScript.
20
+ test: (t) => /(import|const|export):\s*(command not found|not found)/i.test(t)
21
+ || /syntax error near unexpected token/i.test(t) && /command not found/i.test(t),
22
+ explain: (pkg) => ({
23
+ summary: `${pkg || 'This MCP server'} cannot start: its executable is missing a shebang.`,
24
+ detail:
25
+ 'The package\'s entry file is JavaScript but has no `#!/usr/bin/env node` first line, so the '
26
+ + 'shell tries to run it as a shell script. That is a bug in the published package, not in your '
27
+ + 'setup — nothing you configure here can fix it.',
28
+ fix: 'Report it to the package author, pin an earlier version that worked, or use a different server.',
29
+ blame: 'package',
30
+ }),
31
+ },
32
+ {
33
+ id: 'not-found',
34
+ test: (t) => /npm ERR!.*(404|E404)|could not determine executable|command not found: npx/i.test(t),
35
+ explain: (pkg) => ({
36
+ summary: `${pkg || 'The package'} could not be found or has no runnable command.`,
37
+ detail: 'npm resolved nothing to run for this package name and version.',
38
+ fix: 'Check the package name and version, and that the registry in use publishes it.',
39
+ blame: 'config',
40
+ }),
41
+ },
42
+ {
43
+ id: 'no-bridge',
44
+ test: (t) => /can'?t reach the chatpanel bridge|ECONNREFUSED.*4319/i.test(t),
45
+ explain: () => ({
46
+ summary: 'The ChatPanel Bridge is not running.',
47
+ detail: 'Local MCP servers are launched by the bridge, so nothing can start without it.',
48
+ fix: 'Start it with `npx @chatpanel/bridge`, then try again.',
49
+ blame: 'setup',
50
+ }),
51
+ },
52
+ {
53
+ id: 'node-version',
54
+ test: (t) => /requires node|unsupported engine|SyntaxError: Unexpected token '\?\?'/i.test(t),
55
+ explain: (pkg) => ({
56
+ summary: `${pkg || 'This server'} needs a newer Node than the one launching it.`,
57
+ detail: 'The process started but failed on syntax its Node version does not support.',
58
+ fix: 'Upgrade Node, or run the server with a version manager that selects a newer one.',
59
+ blame: 'setup',
60
+ }),
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * @returns { id, summary, detail, fix, blame, raw } — or null when nothing is recognised,
66
+ * because a confident wrong explanation costs more than showing the output as it came.
67
+ */
68
+ export function explainMcpError(text, { packageName = '' } = {}) {
69
+ const t = String(text || '');
70
+ if (!t.trim()) return null;
71
+ for (const rule of RULES) {
72
+ if (!rule.test(t)) continue;
73
+ return { id: rule.id, ...rule.explain(packageName), raw: t };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** The package a command was trying to run, for naming it in the explanation. */
79
+ export function packageFromArgs(args = []) {
80
+ for (const a of args) {
81
+ const s = String(a);
82
+ if (s.startsWith('-')) continue;
83
+ if (s === 'npx' || s === 'node') continue;
84
+ return s;
85
+ }
86
+ return '';
87
+ }
@@ -0,0 +1,83 @@
1
+ // What a meeting produces, as declarations.
2
+ //
3
+ // A meeting already generates several kinds of derived thing: a running summary, insight
4
+ // sections (decisions, action items, risks), and live monitors that watch for an answer to
5
+ // a standing question. Each is written separately — its own prompt shape, its own cadence,
6
+ // its own storage — so adding a fifth means touching several files and nothing outside the
7
+ // extension can offer one.
8
+ //
9
+ // They are the same shape underneath: run over the transcript so far, on some trigger,
10
+ // producing a typed result that is stored and shown. Declaring that shape gives three
11
+ // things at once — a Plugins entry the user can switch off, a cadence the runtime can honour
12
+ // without each analyzer implementing its own timer, and a contract the gateway could later
13
+ // run server-side without rewriting callers.
14
+ //
15
+ // WHAT THIS IS NOT: a scheduler. Declaring "every 90 seconds" does not start a timer here;
16
+ // the host decides when to run and this says what running means. Putting the clock in the
17
+ // contract would make it untestable and unrunnable off a browser.
18
+
19
+ export class AnalyzerError extends Error {
20
+ constructor(code, message) { super(message); this.name = 'AnalyzerError'; this.code = code; }
21
+ }
22
+
23
+ /** When an analyzer wants to run. The host maps these to its own timers and events. */
24
+ export const CADENCES = Object.freeze([
25
+ 'periodic', // every `everyMs` while the meeting is live
26
+ 'on-demand', // only when the user asks
27
+ 'on-end', // once, when the meeting finishes
28
+ ]);
29
+
30
+ /**
31
+ * @param produces what the result IS — 'summary' | 'sections' | 'answer' | 'text'. The host
32
+ * uses it to decide where the output goes, so an analyzer never has to know about
33
+ * storage.
34
+ * @param run async ({ transcript, summary, previous, meeting, ask }) => result. `ask` is
35
+ * the model call, injected: an analyzer that imported one could not run in the
36
+ * gateway, and could not be tested without a network.
37
+ */
38
+ export function defineMeetingAnalyzer({
39
+ id, label, produces = 'text', cadence = 'on-demand', everyMs = 0,
40
+ minTranscriptChars = 0, description = '', run,
41
+ }) {
42
+ if (!id) throw new AnalyzerError('BAD_ANALYZER', 'analyzer.id required');
43
+ if (typeof run !== 'function') throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': run required`);
44
+ if (!CADENCES.includes(cadence)) throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': unknown cadence '${cadence}'`);
45
+ if (cadence === 'periodic' && !(everyMs > 0)) {
46
+ // A periodic analyzer with no interval would either never run or run every tick, and
47
+ // both look like a bug in the analyzer rather than in its declaration.
48
+ throw new AnalyzerError('BAD_ANALYZER', `analyzer '${id}': periodic cadence needs everyMs`);
49
+ }
50
+ return Object.freeze({ id, label: label || id, produces, cadence, everyMs, minTranscriptChars, description, run });
51
+ }
52
+
53
+ export function createAnalyzerRegistry() {
54
+ const analyzers = [];
55
+ return {
56
+ add(a) {
57
+ analyzers.push(a);
58
+ return () => { const i = analyzers.indexOf(a); if (i >= 0) analyzers.splice(i, 1); };
59
+ },
60
+ list: () => [...analyzers],
61
+ get: (id) => analyzers.find((a) => a.id === id) || null,
62
+
63
+ /**
64
+ * Which analyzers are due right now.
65
+ *
66
+ * `lastRunAt` is passed in rather than held here: the registry is a declaration, and a
67
+ * registry that remembered when things ran would be a second place for that truth to
68
+ * live — beside the meeting record that already has to store it.
69
+ */
70
+ due({ now, cadence = 'periodic', lastRunAt = {}, transcriptChars = 0, admit = null } = {}) {
71
+ return analyzers.filter((a) => {
72
+ if (a.cadence !== cadence) return false;
73
+ if (admit && !admit(a)) return false;
74
+ // Below the threshold there is nothing worth spending a model call on — an empty
75
+ // transcript summarised is a paragraph of apology.
76
+ if (transcriptChars < a.minTranscriptChars) return false;
77
+ if (a.cadence !== 'periodic') return true;
78
+ const last = lastRunAt[a.id] || 0;
79
+ return !last || (now - last) >= a.everyMs;
80
+ });
81
+ },
82
+ };
83
+ }