@matatbread/matbot-plugin-api 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.2.0",
7
+ "version": "0.2.2",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -1,5 +1,6 @@
1
1
  import type { Principal } from './types.js';
2
2
  import { runAs } from './principal-context.js';
3
+ import { globalSlot } from './global-state.js';
3
4
 
4
5
  /**
5
6
  * A context switch is the machine analogue of an OS one: it pages in any pending machine state and
@@ -19,9 +20,19 @@ import { runAs } from './principal-context.js';
19
20
  * baseline and is never counted.
20
21
  */
21
22
 
22
- const quiescers = new Set<() => void>();
23
- let depth = 0;
24
- let flushing = false;
23
+ // State-shaped and reachable from a plugin (a storage plugin registering a deferred-swap flusher, or
24
+ // switching context), so it lives in the global slot: a duplicated plugin-api copy shares one set of
25
+ // quiescers and one depth count rather than splitting them. See ./global-state.ts.
26
+ interface ContextSwitchState {
27
+ quiescers: Set<() => void>;
28
+ depth: number;
29
+ flushing: boolean;
30
+ }
31
+ const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
32
+ quiescers: new Set<() => void>(),
33
+ depth: 0,
34
+ flushing: false,
35
+ }));
25
36
 
26
37
  /**
27
38
  * Register a machine-update flush, run at every quiescent edge (no context active). Flushers MUST be
@@ -30,8 +41,8 @@ let flushing = false;
30
41
  * into the operation that triggered the edge.
31
42
  */
32
43
  export function onContextQuiesce(flush: () => void): () => void {
33
- quiescers.add(flush);
34
- return () => { quiescers.delete(flush); };
44
+ cs.quiescers.add(flush);
45
+ return () => { cs.quiescers.delete(flush); };
35
46
  }
36
47
 
37
48
  /**
@@ -41,15 +52,15 @@ export function onContextQuiesce(flush: () => void): () => void {
41
52
  * it applies immediately, so an idle-time swap doesn't wait for the next request to take effect.
42
53
  */
43
54
  export function flushIfQuiescent(): void {
44
- if (depth !== 0 || flushing) return;
45
- flushing = true;
55
+ if (cs.depth !== 0 || cs.flushing) return;
56
+ cs.flushing = true;
46
57
  try {
47
- for (const q of quiescers) {
58
+ for (const q of cs.quiescers) {
48
59
  try { q(); }
49
60
  catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); }
50
61
  }
51
62
  } finally {
52
- flushing = false;
63
+ cs.flushing = false;
53
64
  }
54
65
  }
55
66
 
@@ -60,8 +71,8 @@ export function flushIfQuiescent(): void {
60
71
  */
61
72
  export function contextSwitch<T>(principal: Principal, fn: () => T): T {
62
73
  flushIfQuiescent();
63
- depth++;
64
- const settle = (): void => { depth--; flushIfQuiescent(); };
74
+ cs.depth++;
75
+ const settle = (): void => { cs.depth--; flushIfQuiescent(); };
65
76
  let result: T;
66
77
  try {
67
78
  result = runAs(principal, fn);
package/src/errors.ts CHANGED
@@ -1,16 +1,43 @@
1
+ /**
2
+ * matbot's typed errors are **duck-typed, not class-based**: each is a plain `Error` carrying a
3
+ * `matbot` brand string (the discriminant) plus its data fields. Callers test the brand through the
4
+ * exported `isX` guards — never `instanceof`.
5
+ *
6
+ * Why not classes: plugin-api can be physically duplicated in a skewed install (two copies at
7
+ * different versions). A class is a per-copy object, so `instanceof MissingSecretError` returns
8
+ * `false` when the host throws from one copy and a plugin tests with another — silently breaking
9
+ * error handling across the boundary. A brand string is identity-independent, so the guard holds no
10
+ * matter how many copies are loaded. See docs/duplicate-singletons.md.
11
+ *
12
+ * The interfaces are kept named `XError` so existing `import type { XError }` annotations and field
13
+ * access (`e.missingKeys`) survive; only construction (`new XError()` → `xError()`) and detection
14
+ * (`instanceof XError` → `isXError()`) change.
15
+ */
16
+
17
+ const BRAND = 'matbot';
18
+
19
+ export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled';
20
+
21
+ function isKind(e: unknown, kind: MatbotErrorKind): boolean {
22
+ return typeof e === 'object' && e !== null && (e as Record<string, unknown>)[BRAND] === kind;
23
+ }
24
+
1
25
  /**
2
26
  * Thrown by Vault.resolve() when one or more ${NAME} placeholders cannot be resolved.
3
27
  * `missingKeys` carries the unresolved key names (e.g. `ANTHROPIC_API_KEY`) so callers
4
28
  * can prompt for and store them.
5
29
  */
6
- export class MissingSecretError extends Error {
30
+ export interface MissingSecretError extends Error {
31
+ matbot: 'MissingSecret';
7
32
  readonly missingKeys: readonly string[];
8
-
9
- constructor(missingKeys: readonly string[]) {
10
- super(`Vault: secret(s) not found: ${missingKeys.join(', ')}`);
11
- this.name = 'MissingSecretError';
12
- this.missingKeys = missingKeys;
13
- }
33
+ }
34
+ export function missingSecretError(missingKeys: readonly string[]): MissingSecretError {
35
+ const e = new Error(`Vault: secret(s) not found: ${missingKeys.join(', ')}`);
36
+ e.name = 'MissingSecretError';
37
+ return Object.assign(e, { matbot: 'MissingSecret' as const, missingKeys });
38
+ }
39
+ export function isMissingSecretError(e: unknown): e is MissingSecretError {
40
+ return isKind(e, 'MissingSecret');
14
41
  }
15
42
 
16
43
  /**
@@ -20,18 +47,19 @@ export class MissingSecretError extends Error {
20
47
  * fix — this is permanent for the host, so the `add` flow catches it specifically and rolls the
21
48
  * specifier back out of matbot.yaml rather than persisting a plugin that can never activate here.
22
49
  */
23
- export class IncompatibleRuntimeError extends Error {
24
- readonly specifier: string;
25
- readonly declared: readonly string[];
26
- readonly hostRuntime: string;
27
-
28
- constructor(specifier: string, declared: readonly string[], hostRuntime: string) {
29
- super(`Cannot load plugin "${specifier}": declares matbotRuntime [${declared.join(', ')}], host runtime is "${hostRuntime}".`);
30
- this.name = 'IncompatibleRuntimeError';
31
- this.specifier = specifier;
32
- this.declared = declared;
33
- this.hostRuntime = hostRuntime;
34
- }
50
+ export interface IncompatibleRuntimeError extends Error {
51
+ matbot: 'IncompatibleRuntime';
52
+ readonly specifier: string;
53
+ readonly declared: readonly string[];
54
+ readonly hostRuntime: string;
55
+ }
56
+ export function incompatibleRuntimeError(specifier: string, declared: readonly string[], hostRuntime: string): IncompatibleRuntimeError {
57
+ const e = new Error(`Cannot load plugin "${specifier}": declares matbotRuntime [${declared.join(', ')}], host runtime is "${hostRuntime}".`);
58
+ e.name = 'IncompatibleRuntimeError';
59
+ return Object.assign(e, { matbot: 'IncompatibleRuntime' as const, specifier, declared, hostRuntime });
60
+ }
61
+ export function isIncompatibleRuntimeError(e: unknown): e is IncompatibleRuntimeError {
62
+ return isKind(e, 'IncompatibleRuntime');
35
63
  }
36
64
 
37
65
  /**
@@ -44,16 +72,18 @@ export class IncompatibleRuntimeError extends Error {
44
72
  * surfacing this to an LLM should prefer a terminal "not a plugin" message over echoing `reason`,
45
73
  * which is phrased as a code-fix instruction the model may try to act on.
46
74
  */
47
- export class NotAPluginError extends Error {
75
+ export interface NotAPluginError extends Error {
76
+ matbot: 'NotAPlugin';
48
77
  readonly specifier: string;
49
78
  readonly reason: string;
50
-
51
- constructor(specifier: string, reason: string) {
52
- super(reason);
53
- this.name = 'NotAPluginError';
54
- this.specifier = specifier;
55
- this.reason = reason;
56
- }
79
+ }
80
+ export function notAPluginError(specifier: string, reason: string): NotAPluginError {
81
+ const e = new Error(reason);
82
+ e.name = 'NotAPluginError';
83
+ return Object.assign(e, { matbot: 'NotAPlugin' as const, specifier, reason });
84
+ }
85
+ export function isNotAPluginError(e: unknown): e is NotAPluginError {
86
+ return isKind(e, 'NotAPlugin');
57
87
  }
58
88
 
59
89
  /**
@@ -61,9 +91,14 @@ export class NotAPluginError extends Error {
61
91
  * decline. Callers awaiting `ctx.prompt()` need not branch on it: the surrounding try/catch turns
62
92
  * it into a tool error that closes the tool call, while the host separately abandons the turn.
63
93
  */
64
- export class PromptCancelledError extends Error {
65
- constructor(message = 'User cancelled — cannot proceed.') {
66
- super(message);
67
- this.name = 'PromptCancelledError';
68
- }
94
+ export interface PromptCancelledError extends Error {
95
+ matbot: 'PromptCancelled';
96
+ }
97
+ export function promptCancelledError(message = 'User cancelled — cannot proceed.'): PromptCancelledError {
98
+ const e = new Error(message);
99
+ e.name = 'PromptCancelledError';
100
+ return Object.assign(e, { matbot: 'PromptCancelled' as const });
101
+ }
102
+ export function isPromptCancelledError(e: unknown): e is PromptCancelledError {
103
+ return isKind(e, 'PromptCancelled');
69
104
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Process-global home for matbot's **state-shaped** singletons.
3
+ *
4
+ * A skewed install can load two physical copies of plugin-api (a plugin bundling its own copy at a
5
+ * version that doesn't dedupe with the host's). Module-level state then splits: the host mutates one
6
+ * copy's state, a plugin reads the other's, and the two never agree. Anchoring that state on
7
+ * `globalThis` under one well-known key collapses every copy onto a single shared object, so
8
+ * duplication becomes *benign* rather than corrupting.
9
+ *
10
+ * This handles state — Sets, Maps, counters-in-a-container. It deliberately does NOT cover
11
+ * identity-shaped things (class `instanceof`): a class object is per-copy and can't be shared this
12
+ * way, which is why plugin-api's typed errors are brand-based instead (see ./errors.ts). And the
13
+ * principal carrier predates this module and stays on its own established `Symbol.for` key for
14
+ * backward compatibility with already-published copies — moving it would re-split it in mixed
15
+ * version trees, the exact bug it fixes. See docs/duplicate-singletons.md.
16
+ *
17
+ * Keyed by a registered `Symbol.for` so the lookup is identical across every module copy and
18
+ * resistant to accidental string-key clashes on `globalThis`.
19
+ */
20
+
21
+ const KEY = Symbol.for('@matatbread/matbot.globalState');
22
+
23
+ type GlobalState = Record<string, unknown>;
24
+
25
+ function bag(): GlobalState {
26
+ const g = globalThis as { [KEY]?: GlobalState };
27
+ return (g[KEY] ??= {});
28
+ }
29
+
30
+ /**
31
+ * Return the shared slot named `name`, creating it from `init` on first access. Every copy of every
32
+ * module that asks for the same `name` gets the same object back, so the state is singular however
33
+ * many physical copies are loaded. `init` must produce a *container* (object/Map/Set) — primitives
34
+ * can't be shared by reference, so counters live as fields inside a container.
35
+ */
36
+ export function globalSlot<T extends object>(name: string, init: () => T): T {
37
+ const b = bag();
38
+ return (b[name] ??= init()) as T;
39
+ }