@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/LICENSE +201 -0
- package/README.md +23 -0
- package/package.json +39 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.ts +123 -0
- package/src/config/yaml.ts +127 -0
- package/src/hooks.ts +1 -0
- package/src/index.ts +32 -0
- package/src/knowledge/index.ts +1 -0
- package/src/knowledge/lookup-knowledge-index.ts +58 -0
- package/src/loader.ts +340 -0
- package/src/plugin.ts +1 -0
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/sse.ts +28 -0
- package/src/registry.ts +348 -0
- package/src/runner.ts +324 -0
- package/src/security/grants.ts +6 -0
- package/src/security/index.ts +2 -0
- package/src/security/vault.ts +76 -0
- package/src/session-runner.ts +455 -0
- package/src/session.ts +59 -0
- package/src/settings.ts +66 -0
- package/src/single-turn.ts +64 -0
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/query/access.ts +16 -0
- package/src/storage-base/query/compile.ts +42 -0
- package/src/storage-base/query/execute.ts +49 -0
- package/src/storage-base/query/index.ts +6 -0
- package/src/storage-base/query/paginate.ts +26 -0
- package/src/storage-base/query/sort.ts +29 -0
- package/src/storage-base/query/validate.ts +113 -0
- package/src/system-context.ts +26 -0
- package/src/tool-registry.ts +38 -0
- package/src/types.ts +13 -0
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
|
+
}
|