@matatbread/matbot-plugin-api 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/context-switch.ts +22 -11
- package/src/errors.ts +67 -32
- package/src/global-state.ts +39 -0
package/package.json
CHANGED
package/src/context-switch.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
30
|
+
export interface MissingSecretError extends Error {
|
|
31
|
+
matbot: 'MissingSecret';
|
|
7
32
|
readonly missingKeys: readonly string[];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
readonly
|
|
26
|
-
readonly
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
75
|
+
export interface NotAPluginError extends Error {
|
|
76
|
+
matbot: 'NotAPlugin';
|
|
48
77
|
readonly specifier: string;
|
|
49
78
|
readonly reason: string;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|