@matatbread/matbot-core 0.1.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/src/loader.ts ADDED
@@ -0,0 +1,340 @@
1
+ import type { MatbotPlugin, MatbotPluginSpec, MatbotMachine, PluginSource, Runtime } from './plugin.js';
2
+ import type { PromptFn } from './types.js';
3
+ import { IncompatibleRuntimeError, NotAPluginError } from '@matatbread/matbot-plugin-api';
4
+ import { registerPlugin, setupPlugin, unloadPlugin } from './registry.js';
5
+
6
+ /**
7
+ * The runtime this process is executing in. Detected the same way the rejected-import branch below
8
+ * already distinguishes platforms (`typeof window`). Used only for the declarative pre-import gate;
9
+ * an absent `matbotRuntime` declaration bypasses the gate entirely.
10
+ */
11
+ const CURRENT_RUNTIME: Runtime = typeof window !== 'undefined' ? 'browser' : 'node';
12
+
13
+ /**
14
+ * Query-param marker appended to a plugin entry URL when `bustCache` is set.
15
+ *
16
+ * Core only *marks* the entry; it does not propagate. Interpretation is the job
17
+ * of an optional node-side module-customization hook (see apps/cli/ts-hooks.js),
18
+ * which reads this marker off `context.parentURL` and re-stamps the plugin's
19
+ * first-party imports so the whole subtree re-evaluates — "fresh all the way
20
+ * down". Where no such hook is installed (browser builds; node without the hook)
21
+ * the marker is an inert query string: only the entry module re-evaluates, and
22
+ * a browser frontend reloads the whole realm instead.
23
+ *
24
+ * The name is namespaced so it cannot collide with a query a plugin URL might
25
+ * legitimately carry. Keep it in sync with the hook.
26
+ */
27
+ const FRESH_PARAM = 'mbfresh';
28
+
29
+ // Monotonic tie-breaker: two reloads inside the same millisecond would otherwise
30
+ // produce the same stamp and re-hit the cache.
31
+ let freshSeq = 0;
32
+
33
+ /**
34
+ * Load plugins from the given specifiers, register them, then run setup().
35
+ *
36
+ * Node: specifiers are npm package names (e.g. "matbot-anthropic") or
37
+ * file paths relative to the process working directory.
38
+ *
39
+ * Browser: specifiers must be URL paths served from the same origin
40
+ * (e.g. "/plugins/matbot-anthropic.js" or "./plugins/my-plugin.js").
41
+ * Bare package names are supported only if an import map is in scope.
42
+ * A failed import logs a warning and is skipped rather than aborting
43
+ * startup — the browser has no node_modules to fall back to.
44
+ *
45
+ * Each module must export either:
46
+ * export const plugin: MatbotPlugin (named export, preferred)
47
+ * export default { plugin: MatbotPlugin } (default export)
48
+ *
49
+ * A specifier is normally a string that is both imported and recorded as the plugin's
50
+ * `specifier`. The object form decouples those concerns for a host that has already done its
51
+ * own resolution:
52
+ * - `spec` — recorded as the plugin's `specifier` (the human/config-level identity).
53
+ * - `importSpec` — the URL actually imported, when it differs from `spec`. The browser
54
+ * fetches a remote plugin, type-strips it, and imports the resulting ephemeral `blob:`
55
+ * URL while recording the original source URL as `spec`; node imports a resolved
56
+ * `file://` path while recording the original `matbot.yaml` entry. Defaults to `spec`.
57
+ * `bustCache` still applies to a pre-set `importSpec`: a `file:` URL is stamped in place
58
+ * (the host owns resolution — re-resolving `spec` here would fail for `./…`-relative or
59
+ * remote specifiers — but the loader owns the freshness stamp). A `blob:` URL is left
60
+ * untouched (a `?${FRESH_PARAM}` query does not resolve to the blob, and the browser busts
61
+ * by reloading the whole realm anyway); any other scheme is passed through unstamped.
62
+ * - `name` — the canonical plugin name, when the host has already derived it (e.g. by
63
+ * reading the resolved package.json). Bypasses `resolver.identify(spec)` — necessary
64
+ * because `spec` (a remote URL, a versioned npm range) may not itself identify the name.
65
+ * - `runtimes` — the package.json `matbotRuntime`, when the host has already read it; used
66
+ * for the pre-import gate in place of `resolver.runtimes(spec)`.
67
+ *
68
+ * @param bustCache When true, the URL imported for each specifier gets a unique
69
+ * `${FRESH_PARAM}` query stamp appended before importing — either a host-pre-resolved
70
+ * `file:` `importSpec` stamped in place, or, absent one, the bare `spec` resolved via
71
+ * `import.meta.resolve` and then stamped. This forces the JS engine to bypass its module
72
+ * cache and re-evaluate the entry from disk. With the companion node resolve hook installed
73
+ * (apps/cli/ts-hooks.js) the stamp propagates to the plugin's first-party imports too, so the
74
+ * entire subtree re-evaluates — necessary when reloading a plugin whose code, or whose own
75
+ * modules' code, changed since startup. When resolution is needed but `import.meta.resolve`
76
+ * is unavailable (or throws), it falls back to the original specifier — re-importing the
77
+ * cached module — and logs a warning. (Because the single-funnel node host always supplies a
78
+ * pre-resolved `importSpec`, forgetting to stamp it silently neuters every node reload —
79
+ * hence the stamp is applied here, in the loader, not left to the caller.)
80
+ * @param prompt Optional host prompt used by setup() to resolve tool-name collisions
81
+ * interactively. Omitted by non-interactive hosts, in which case collisions overwrite
82
+ * silently (the historical default).
83
+ * @param onLoadError What to do when an entry cannot become a plugin — an incompatible
84
+ * `matbotRuntime`, an import that rejects, or a module that is not plugin-shaped (no `plugin`
85
+ * export, no `apiVersion`, a non-function lifecycle member). `'skip'` (default, the startup
86
+ * batch) warns and moves on — one mis-targeted or non-plugin entry in the config must not abort
87
+ * the process, or, under a supervisor that restarts on exit (systemd `Restart=always`), a single
88
+ * bad `matbot.yaml` line becomes an unbreakable crash loop fixable only by hand-editing the
89
+ * config. `'throw'` is for an explicit, single, user-initiated load (the `plugin`/`provider`
90
+ * tools via `services.loadPlugin`): the user named *this* plugin, so a silent skip would surface
91
+ * only as a confusing empty-result error downstream — fail loudly with the reason instead.
92
+ */
93
+ export async function loadPlugins(
94
+ specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] })[],
95
+ services: MatbotMachine,
96
+ bustCache = false,
97
+ prompt?: PromptFn,
98
+ onLoadError: 'skip' | 'throw' = 'skip',
99
+ ): Promise<MatbotPlugin[]> {
100
+ const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] });
101
+ if (bustCache) {
102
+ console.debug(
103
+ `[matbot] cache-bust requested for ${specifiers.length} plugin(s); the entry is re-evaluated, ` +
104
+ `and its first-party imports too IF the node resolve hook is installed (else only the entry).`,
105
+ );
106
+ }
107
+ // Declarative pre-import gate. A plugin whose package.json `matbotRuntime` lists runtimes that
108
+ // exclude this host is skipped *before* import() — its (possibly platform-specific) top-level
109
+ // code never evaluates here, which is both the quick path and the only safe one for, e.g., a
110
+ // node-only plugin reached from a browser. An *absent* declaration means "don't know": the
111
+ // resolver returns undefined and we import it, falling back to the try-load / rollback path
112
+ // below. Reading the declaration is the host resolver's job (this module stays node-free).
113
+ const declared = await Promise.all(
114
+ reqs.map(({ spec, runtimes }) =>
115
+ runtimes !== undefined ? Promise.resolve(runtimes)
116
+ : services.resolver?.runtimes !== undefined ? services.resolver.runtimes(spec)
117
+ : Promise.resolve(undefined),
118
+ ),
119
+ );
120
+
121
+ const toLoad: { spec: string; importSpec: string; name?: string; runtimes?: readonly Runtime[] }[] = [];
122
+ for (let i = 0; i < reqs.length; i++) {
123
+ const { spec, importSpec, name } = reqs[i]!;
124
+ const runtimes = declared[i];
125
+ if (runtimes !== undefined && runtimes.length > 0 && !runtimes.includes(CURRENT_RUNTIME)) {
126
+ if (onLoadError === 'throw') {
127
+ throw new IncompatibleRuntimeError(spec, runtimes, CURRENT_RUNTIME);
128
+ }
129
+ console.warn(`[matbot] Skipping plugin "${spec}": declares matbotRuntime [${runtimes.join(', ')}], host runtime is "${CURRENT_RUNTIME}".`);
130
+ continue;
131
+ }
132
+ toLoad.push({
133
+ spec,
134
+ importSpec: freshImportSpec(spec, importSpec, bustCache),
135
+ ...(name !== undefined ? { name } : {}),
136
+ ...(runtimes !== undefined ? { runtimes } : {}),
137
+ });
138
+ }
139
+
140
+ // Imports run in parallel; registration remains sequential to preserve order.
141
+ const results = await Promise.allSettled(
142
+ toLoad.map(({ importSpec }) => import(/* @vite-ignore */ importSpec) as Promise<Record<string, unknown>>),
143
+ );
144
+
145
+ const loaded: MatbotPlugin[] = [];
146
+
147
+ // A load failure is either skipped (the startup batch — a bad entry must not abort the whole
148
+ // boot) or rethrown (an explicit single load — the user named this plugin). One funnel so every
149
+ // failure below — import rejection and each shape check — obeys the same mode. See @param onLoadError.
150
+ // `kind: 'shape'` distinguishes a module that imported cleanly but is not plugin-shaped (a library)
151
+ // from a `'load'` failure (import rejection — a bad path, a syntax error). Only the former is
152
+ // permanently-not-a-plugin: it throws NotAPluginError so the `add` flow can roll back the config
153
+ // write, where an import failure may be a fixable typo and is left in config.
154
+ const failLoad = (spec: string, reason: string, kind: 'load' | 'shape' = 'load'): void => {
155
+ if (onLoadError === 'throw') throw kind === 'shape' ? new NotAPluginError(spec, reason) : new Error(reason);
156
+ console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
157
+ };
158
+
159
+ for (let i = 0; i < toLoad.length; i++) {
160
+ const spec = toLoad[i]!.spec;
161
+ const result = results[i]!;
162
+
163
+ if (result.status === 'rejected') {
164
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
165
+ if (typeof window !== 'undefined') {
166
+ // Browser: warn and skip — browser environments have no node_modules fallback.
167
+ console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
168
+ continue;
169
+ }
170
+ console.error(`[matbot] Failed to load plugin "${spec}":`, result.reason);
171
+ failLoad(spec, `Could not load plugin "${spec}": ${reason}`);
172
+ continue;
173
+ }
174
+
175
+ const mod = result.value;
176
+ const spec_obj = (mod['plugin'] ?? (mod['default'] as Record<string, unknown> | undefined)?.['plugin']) as MatbotPluginSpec | undefined;
177
+
178
+ // Shape verification: a loaded module is a matbot plugin only if it exports a `plugin` object
179
+ // carrying `apiVersion`, and any lifecycle members it declares are functions. This is the
180
+ // post-import half of the install guard (the pre-import half is the package.json matbotRuntime
181
+ // check) — it is what stops an arbitrary fetched/installed module from being treated as a plugin.
182
+ if (spec_obj === undefined || typeof spec_obj !== 'object') {
183
+ failLoad(spec,
184
+ `Plugin module "${spec}" does not export a \`plugin\` object — it is not a matbot plugin. ` +
185
+ `Expected: export const plugin: MatbotPluginSpec`,
186
+ 'shape',
187
+ );
188
+ continue;
189
+ }
190
+ if (!('apiVersion' in spec_obj)) {
191
+ failLoad(spec, `Plugin module "${spec}" exports a \`plugin\` object with no \`apiVersion\` — it is not a valid matbot plugin.`, 'shape');
192
+ continue;
193
+ }
194
+ const lifecycle = spec_obj as { setup?: unknown; teardown?: unknown; installationMessage?: unknown };
195
+ let lifecycleError: string | undefined;
196
+ for (const fn of ['setup', 'teardown', 'installationMessage'] as const) {
197
+ const v = lifecycle[fn];
198
+ if (v !== undefined && typeof v !== 'function') {
199
+ lifecycleError = `Plugin module "${spec}" exports \`plugin.${fn}\` that is not a function (got ${typeof v}).`;
200
+ break;
201
+ }
202
+ }
203
+ if (lifecycleError !== undefined) { failLoad(spec, lifecycleError, 'shape'); continue; }
204
+
205
+ // The single boundary where an author's spec becomes a loaded plugin: identity is stamped here,
206
+ // never declared by the author. The host-injected resolver derives the canonical name; absent a
207
+ // resolver (e.g. a bare host) we fall back to platform-neutral string munging.
208
+ const name = toLoad[i]!.name ?? (services.resolver !== undefined ? await services.resolver.identify(spec) : defaultIdentify(spec));
209
+ const source = sourceOf(spec);
210
+ const runtimes = toLoad[i]!.runtimes;
211
+ const plugin: MatbotPlugin = {
212
+ ...spec_obj,
213
+ name,
214
+ specifier: spec,
215
+ ...(source !== undefined ? { source } : {}),
216
+ ...(runtimes !== undefined ? { matbotRuntime: runtimes } : {}),
217
+ };
218
+
219
+ // registered gates the rollback: only an attempt that *itself* registered the
220
+ // plugin may unload it. A registerPlugin failure means the name/provider/storage
221
+ // was already owned by a different, healthy plugin — unloading by name there would
222
+ // tear down that innocent bystander.
223
+ let registered = false;
224
+ try {
225
+ registerPlugin(plugin);
226
+ registered = true;
227
+ await setupPlugin(plugin, services, prompt);
228
+ loaded.push(plugin);
229
+ } catch (err) {
230
+ console.error(`[matbot] Ignoring plugin "${plugin.name}" from "${spec}" due to an error:`, err instanceof Error ? err.message : err);
231
+ // Roll back partial registration so a failed load leaves no ghost state. setupPlugin
232
+ // can throw after the plugin is already in the registry with some of its tools / hooks /
233
+ // services / provider / storage wired (a wrong-platform plugin touching a missing
234
+ // primitive mid-setup is the common case). unloadPlugin is the exact inverse of
235
+ // register + setup, so it removes precisely those contributions. We run it defensively:
236
+ // a half-set-up plugin's own teardown() may throw, and a rollback failure must not abort
237
+ // the remaining loads.
238
+ if (registered) {
239
+ try {
240
+ await unloadPlugin(plugin.name, services);
241
+ } catch (cleanupErr) {
242
+ console.error(
243
+ `[matbot] Cleanup after failed load of "${plugin.name}" did not fully complete; some state may linger:`,
244
+ cleanupErr instanceof Error ? cleanupErr.message : cleanupErr,
245
+ );
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ return loaded;
252
+ }
253
+
254
+ /**
255
+ * Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh
256
+ * evaluation (and, with the node resolve hook, a fresh subtree — see FRESH_PARAM).
257
+ *
258
+ * Diagnostics: cache-busting silently degrades in two ways that are
259
+ * indistinguishable from "it worked" at the call site, so both are logged here.
260
+ * 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve
261
+ * relative to *this* module's URL) — we fall back to the bare spec, which
262
+ * re-imports the *cached* module. No busting happens at all.
263
+ * 2. Resolution succeeds but no resolve hook is installed: only the entry is
264
+ * re-evaluated, while everything it statically imports stays cached. We can
265
+ * detect (1) here; (2) is noted at the call site.
266
+ */
267
+ /**
268
+ * Classify how a specifier resolves to code, from its shape alone (platform-neutral; no fs).
269
+ * github shorthand is only recognised via the explicit `github:` prefix — a bare `owner/repo`
270
+ * is indistinguishable from a scoped npm package and is treated as npm.
271
+ */
272
+ function sourceOf(spec: string): PluginSource | undefined {
273
+ if (/^https?:\/\//.test(spec)) return 'cdn';
274
+ if (spec.startsWith('github:')) return 'github';
275
+ if (spec.startsWith('file://') || spec.startsWith('./') ||
276
+ spec.startsWith('../') || spec.startsWith('/')) return 'local';
277
+ return 'npm';
278
+ }
279
+
280
+ /**
281
+ * Degraded name derivation used only when no PluginResolver is injected. A bare package name is its
282
+ * own identity; anything path/URL-shaped collapses to its last segment (query and extension
283
+ * stripped). Real hosts inject a resolver that walks package.json (node) or parses the CDN URL
284
+ * (browser); this exists so a resolver-less host still loads rather than crashing.
285
+ */
286
+ function defaultIdentify(spec: string): string {
287
+ if (sourceOf(spec) === 'npm') return spec;
288
+ const noQuery = (spec.split('?')[0]) ?? spec;
289
+ const last = noQuery.replace(/\/+$/, '').split('/').pop() ?? noQuery;
290
+ return last.replace(/\.[^.]+$/, '') || noQuery;
291
+ }
292
+
293
+ /**
294
+ * Pick the URL to actually import, honouring a host-pre-resolved `importSpec` while still
295
+ * applying the cache-bust stamp when requested.
296
+ *
297
+ * The host (apps/cli's resolvePluginSpecifiers) is the *only* place that can resolve a config
298
+ * specifier (`./plugins/…`, a bare name, a remote URL) to an absolute file: URL — it reads the
299
+ * package.json `exports`, materialises remotes into `.plugins/`, and resolves through node_modules.
300
+ * So when an `importSpec` is supplied we must keep it (re-resolving the bare `spec` here against
301
+ * *this* module's URL would fail). But "keep it" used to mean "skip the stamp entirely", which
302
+ * silently neutered every node-side reload (the pre-set importSpec is never undefined under the
303
+ * single-funnel host). We now stamp the pre-resolved file: URL directly.
304
+ *
305
+ * Carve-out: a `blob:` importSpec (the browser bundle's import-map entries) is left untouched — a
306
+ * `?query` on a blob URL does not resolve to the blob, and the browser busts by reloading the whole
307
+ * realm anyway. Any non-file:, non-blob: scheme is also passed through unstamped.
308
+ */
309
+ function freshImportSpec(spec: string, importSpec: string | undefined, bustCache: boolean): string {
310
+ if (!bustCache) return importSpec ?? spec;
311
+ if (importSpec === undefined) return toFreshUrl(spec);
312
+ if (importSpec.startsWith('file:')) {
313
+ const fresh = stampFresh(importSpec);
314
+ console.debug(`[matbot] cache-bust: "${spec}" -> ${fresh}`);
315
+ return fresh;
316
+ }
317
+ return importSpec;
318
+ }
319
+
320
+ function stampFresh(url: string): string {
321
+ const u = new URL(url);
322
+ u.searchParams.set(FRESH_PARAM, `${Date.now()}.${++freshSeq}`);
323
+ return u.href;
324
+ }
325
+
326
+ function toFreshUrl(spec: string): string {
327
+ try {
328
+ const fresh = stampFresh(import.meta.resolve(spec));
329
+ console.debug(`[matbot] cache-bust: "${spec}" -> ${fresh}`);
330
+ return fresh;
331
+ } catch (err) {
332
+ const reason = err instanceof Error ? err.message : String(err);
333
+ console.warn(
334
+ `[matbot] cache-bust fallback: import.meta.resolve("${spec}") failed (${reason}); ` +
335
+ `re-importing the CACHED module — changes on disk will NOT be picked up. ` +
336
+ `(Specifiers must be resolvable relative to ${import.meta.url} or be bare package names.)`,
337
+ );
338
+ return spec;
339
+ }
340
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1 @@
1
+ export * from '@matatbread/matbot-plugin-api';
@@ -0,0 +1 @@
1
+ export { parseSSE } from './sse.js';
@@ -0,0 +1,28 @@
1
+ // Parses a Server-Sent Events stream into data payloads.
2
+ // Uses only Web APIs (ReadableStream, TextDecoder) — works in Node 24+ and browsers.
3
+ export async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncIterable<string> {
4
+ const reader = body.getReader();
5
+ const decoder = new TextDecoder();
6
+ let buffer = '';
7
+
8
+ try {
9
+ while (true) {
10
+ const { done, value } = await reader.read();
11
+ if (done) break;
12
+
13
+ buffer += decoder.decode(value, { stream: true });
14
+
15
+ let nl: number;
16
+ while ((nl = buffer.indexOf('\n')) !== -1) {
17
+ const line = buffer.slice(0, nl).trimEnd();
18
+ buffer = buffer.slice(nl + 1);
19
+ if (line.startsWith('data: ')) {
20
+ const data = line.slice(6);
21
+ if (data !== '[DONE]') yield data;
22
+ }
23
+ }
24
+ }
25
+ } finally {
26
+ reader.releaseLock();
27
+ }
28
+ }