@matatbread/matbot-plugin-api 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/plugin.ts ADDED
@@ -0,0 +1,498 @@
1
+ import type {
2
+ FileStore, Vault, Message, ModelParameters,
3
+ ProviderAdapter, ProviderConfig, Tool, ToolRegistry, FrontendInfo,
4
+ Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner,
5
+ } from './types.js';
6
+ import type { HookRegistry } from './hooks.js';
7
+
8
+ export const PLUGIN_API_VERSION = '0.1';
9
+
10
+ // ── Sub-runner types ──────────────────────────────────────────────────────────
11
+
12
+ export interface CompletionRequest {
13
+ provider: string;
14
+ messages: Message[];
15
+ system?: string;
16
+ parameters?: Partial<ModelParameters>;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface CompletionResponse {
21
+ text: string;
22
+ usage: { inputTokens: number; outputTokens: number };
23
+ }
24
+
25
+ export interface SingleTurnRequest {
26
+ provider: string;
27
+ prompt: string;
28
+ system?: string;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ // ── Plugin settings ───────────────────────────────────────────────────────────
33
+
34
+ /** Scoped key-value store for a single plugin's runtime settings. */
35
+ export interface PluginSettings {
36
+ get<T>(key: string): Promise<T | undefined>;
37
+ set<T>(key: string, value: T): Promise<void>;
38
+ delete(key: string): Promise<void>;
39
+ }
40
+
41
+ // ── Plugin identity ─────────────────────────────────────────────────────────────
42
+
43
+ /** How a plugin specifier resolves to code. Loader-established, never author-declared. */
44
+ export type PluginSource = 'local' | 'npm' | 'cdn' | 'github';
45
+
46
+ /** The execution environments a plugin can run in, as declared by `matbotRuntime` in package.json. */
47
+ export type Runtime = 'node' | 'browser';
48
+
49
+ /**
50
+ * Host-provided mapping from a load specifier to a plugin's canonical name.
51
+ *
52
+ * Identity is loader-established, not author-declared: the loader calls identify() at the single
53
+ * boundary where a MatbotPluginSpec becomes a MatbotPlugin. Deriving a name walks package.json
54
+ * (node) or parses a CDN URL (browser), so the implementation is host-specific and injected via
55
+ * MatbotServices rather than living in the platform-neutral core.
56
+ */
57
+ export interface PluginResolver {
58
+ identify(specifier: string): Promise<string>;
59
+ /**
60
+ * The runtimes a plugin declares support for via its package.json `matbotRuntime`
61
+ * (e.g. `["node"]`, `["browser"]`, `["node","browser"]`), or `undefined` when the field is
62
+ * absent — meaning "don't know". The loader skips a plugin *before* importing it when this
63
+ * returns a non-empty list that excludes the current runtime; `undefined` falls back to the
64
+ * try-load / catch / rollback path. Reading the declaration is host-specific (walk package.json
65
+ * on node; consult the baked manifest in the browser), so it lives here, not in the core loader.
66
+ */
67
+ runtimes?(specifier: string): Promise<readonly Runtime[] | undefined>;
68
+ }
69
+
70
+ /** A plugin's own loader-established identity, exposed to its setup() via scoped services. */
71
+ export interface PluginSelf {
72
+ readonly name: string;
73
+ readonly specifier: string;
74
+ readonly source?: PluginSource;
75
+ }
76
+
77
+ // ── Services container ────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * The registry bucket: the swappable, registerable services, keyed by interface name. This is the
81
+ * `keyof` domain of {@link MatbotRuntime.register}/`get`, and the surface third-party plugins augment
82
+ * (`declare module '@matatbread/matbot-plugin-api' { interface MatbotServices { Foo?: Foo } }`). The
83
+ * core three carry a host boot default and revert to it when unregistered; an augmented service is
84
+ * optional and simply drops. Read each as a member (`services.KnowledgeIndex`); swap with register().
85
+ */
86
+ export interface MatbotServices {
87
+ readonly StorageBackend?: StorageBackend | undefined;
88
+ /** The live vault — also the `register('Vault', impl)` swap key. Capture-safe behind a proxy, so a
89
+ * reference held across a swap keeps resolving to the live backend. Always present (boot default). */
90
+ readonly Vault: Vault;
91
+ readonly KnowledgeIndex: KnowledgeIndex;
92
+ }
93
+
94
+ /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
95
+ export type MatbotMachine = MatbotServices & MatbotRuntime;
96
+
97
+ /**
98
+ * The fixed runtime — microcode, not a service: the agentic loop's primitives (`complete`,
99
+ * `createStore`), the registries you mutate but never swap wholesale (`hooks`, `tools`,
100
+ * `systemContext`), plugin lifecycle, and the registry API itself. Not augmentable, not registerable.
101
+ */
102
+ export interface MatbotRuntime {
103
+ complete(req: CompletionRequest): Promise<CompletionResponse>;
104
+
105
+ /**
106
+ * Thin convenience over {@link complete}: send a single `prompt` (and optional `system`) to a
107
+ * named provider and get the response, hiding the otherwise-mandatory and meaningless `Message`
108
+ * fields (id/traceId/createdAt) that an out-of-band one-shot call has no use for.
109
+ */
110
+ singleTurn(req: SingleTurnRequest): Promise<CompletionResponse>;
111
+
112
+ /** The calling plugin's own settings store. Scoped to the plugin — it cannot reach another's. */
113
+ settings(): PluginSettings;
114
+
115
+ /**
116
+ * Hot-load a plugin by specifier into the running process. Returns the loaded plugin.
117
+ * `prompt`, when supplied, resolves tool-name collisions interactively during the new
118
+ * plugin's setup(); the runner injects the triggering session's prompt automatically.
119
+ */
120
+ loadPlugin(specifier: string, prompt?: PromptFn): Promise<MatbotPlugin>;
121
+
122
+ /**
123
+ * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
124
+ * Resolves `true` if a plugin was resident and unloaded, `false` if there was nothing to unload.
125
+ */
126
+ unloadPlugin(specifier: string): Promise<boolean>;
127
+
128
+ /**
129
+ * Create (or retrieve a cached) typed store for the given namespace.
130
+ * The backing implementation is determined by the runtime (filesystem by default;
131
+ * a storage plugin may substitute a database backend).
132
+ * Namespaces are isolated: 'schedules' and 'settings' never share documents.
133
+ */
134
+ createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
135
+
136
+ /**
137
+ * Mount-table notifications: react to a registry service (re)mounting or being unloaded. A plugin
138
+ * needs this iff its setup() reads another service's *current state* to build cached/derived state —
139
+ * skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A pure
140
+ * map (no setup data; data arrives later as a tool call or hook) needs nothing — resolve the service
141
+ * per-invocation through its proxy/member instead.
142
+ *
143
+ * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
144
+ * services.mounted.consume({ key: 'StorageBackend', signal }, () => this.load());
145
+ *
146
+ * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
147
+ * services.mounted.consume({ key: 'SkillManager', replay: true, signal }, m => seed(m));
148
+ *
149
+ * Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
150
+ * says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
151
+ * never assume a register() is observed inline or at a turn boundary. A reload (unregister+register
152
+ * before the edge) collapses to a single remount; an unregister not replaced by the edge is a
153
+ * committed unload, delivered to `onUnmount` (drop your captured ref there to let the gone plugin's
154
+ * working set be collected). Handlers may re-fire on later remounts, so they must be idempotent.
155
+ */
156
+ readonly mounted: Mounted;
157
+
158
+ /**
159
+ * Register a service under a MatbotServices key (the key is the interface name it carries).
160
+ * Well-known keys have dedicated behaviour:
161
+ * 'StorageBackend' — replaces the active storage backend and re-wires all Store proxies.
162
+ * 'KnowledgeIndex' — replaces the active KnowledgeIndex, draining entries from the old one.
163
+ * 'Vault' — replaces the active vault backend and re-points the capture-safe vault proxy, so
164
+ * references to `services.Vault` / `ctx.vault` keep resolving to the live impl.
165
+ * All other keys store the value in a per-plugin service registry accessible via get().
166
+ *
167
+ * Third-party plugins advertise novel services by augmenting MatbotServices:
168
+ *
169
+ * declare module '@matatbread/matbot-plugin-api' {
170
+ * interface MatbotServices { memory?: MemoryManager; }
171
+ * }
172
+ *
173
+ * Then register and retrieve with full type safety:
174
+ * await services.register('memory', new MemoryManagerImpl(store));
175
+ * const mem = services.get('memory'); // MemoryManager | undefined
176
+ */
177
+ register<K extends keyof MatbotServices>(key: K, value: NonNullable<MatbotServices[K]>): Promise<void>;
178
+
179
+ /** Look up a service registered under a MatbotServices key via register(). */
180
+ get<K extends keyof MatbotServices>(key: K): MatbotServices[K] | undefined;
181
+
182
+ /**
183
+ * Declare the calling plugin as a frontend. Being a frontend is an action taken in setup(),
184
+ * symmetric with register() — not a static manifest flag. A frontend owns its own I/O; matbot
185
+ * only records that it exists. Multiple frontends may be active at once. Auto-unregistered when
186
+ * the plugin is unloaded.
187
+ */
188
+ registerFrontend(info: FrontendInfo): void;
189
+
190
+ /** @internal Remove a service entry — called by the runtime when the registering plugin is unloaded. */
191
+ unregister(key: string): void;
192
+
193
+ /** Host-injected name deriver, used by the loader to stamp plugin identity. */
194
+ readonly resolver?: PluginResolver;
195
+ /** The calling plugin's own loader-established identity. Bound per-plugin inside setup(). */
196
+ readonly self?: PluginSelf;
197
+
198
+ readonly providers: ReadonlyMap<string, ProviderConfig>;
199
+ readonly sessions?: Store<Session>;
200
+ /** Per-session turn serialiser. Frontends submit and observe through this rather than calling
201
+ * runSession directly, so concurrent submits queue instead of clobbering the session. */
202
+ readonly run?: SessionRunner | undefined;
203
+ readonly files?: FileStore;
204
+ readonly hooks: HookRegistry;
205
+ readonly tools: ToolRegistry;
206
+ readonly systemContext: SystemContextRegistry;
207
+ /** Default working directory for tool execution. Plugins that create servers should forward this to tool contexts. */
208
+ readonly workdir?: string;
209
+ /** Absolute path to the loaded config file. Plugins that create servers should forward this to tool contexts. */
210
+ readonly configPath?: string;
211
+
212
+ /**
213
+ * Whether this process is a background sub-agent (spawned by another matbot, not a top-level
214
+ * interactive run). The signal is platform-sourced — the node entry reads it from the environment,
215
+ * the browser realm has no sub-agent notion and returns false (a future WebWorker realm could
216
+ * return true). Plugins use it to suppress work that must be singular per bot identity: e.g. a
217
+ * frontend's long-poll loop, which would otherwise contend with the foreground process on the
218
+ * same upstream connection.
219
+ */
220
+ isSubAgent(): boolean;
221
+ }
222
+
223
+ /**
224
+ * Wrap a services object so every registered service is reachable as a member (`services.InterfaceName`),
225
+ * not only via `services.get('InterfaceName')` — one access surface for base members and plugin-registered
226
+ * services alike. A member read of a key the object doesn't carry falls back to its own `get()` (the
227
+ * registry), so the augmentation that declares `InterfaceName?: InterfaceName` is also the access path;
228
+ * the optional `?` is the single call-site signal of "may be absent, null-check it". Assignment throws,
229
+ * directing callers to `register()` (the swap-aware write path). Applied to both the host services object
230
+ * and the per-plugin scoped object, so plugins see the same surface the host does.
231
+ */
232
+ export function unifyServices(services: MatbotMachine): MatbotMachine {
233
+ return new Proxy(services, {
234
+ get(target, key, receiver) {
235
+ if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
236
+ return typeof key === 'string' ? target.get(key as keyof MatbotServices) : undefined;
237
+ },
238
+ has(target, key) {
239
+ if (Reflect.has(target, key)) return true;
240
+ return typeof key === 'string' && target.get(key as keyof MatbotServices) !== undefined;
241
+ },
242
+ set(_target, key) {
243
+ throw new Error(
244
+ `Cannot assign services.${String(key)} directly — use services.register('${String(key)}', impl) to register or replace a service.`,
245
+ );
246
+ },
247
+ });
248
+ }
249
+
250
+ // ── Capture-safe swap proxies ───────────────────────────────────────────────────
251
+
252
+ export type SwapFn<T extends object> = (next: T) => void;
253
+
254
+ /**
255
+ * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
256
+ * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
257
+ * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
258
+ * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
259
+ * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
260
+ */
261
+ export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
262
+ return new Proxy({} as T, {
263
+ get(_t, prop) {
264
+ const cur = getCurrent();
265
+ if (cur === undefined) return undefined;
266
+ const val = Reflect.get(cur, prop, cur);
267
+ return typeof val === 'function' ? (val as (...a: unknown[]) => unknown).bind(cur) : val;
268
+ },
269
+ has(_t, prop) { const cur = getCurrent(); return cur !== undefined && Reflect.has(cur, prop); },
270
+ getPrototypeOf() { const cur = getCurrent(); return cur === undefined ? null : Reflect.getPrototypeOf(cur); },
271
+ ownKeys() { const cur = getCurrent(); return cur === undefined ? [] : Reflect.ownKeys(cur); },
272
+ getOwnPropertyDescriptor(_t, prop) {
273
+ const cur = getCurrent();
274
+ if (cur === undefined) return undefined;
275
+ const d = Reflect.getOwnPropertyDescriptor(cur, prop);
276
+ if (d !== undefined) d.configurable = true; // Proxy invariant: props absent from the {} target must be configurable.
277
+ return d;
278
+ },
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Returns [proxy, swap]: the Store/FileStore handle plugins capture, plus the fn register() calls to
284
+ * repoint it at a new backend's store. Built on forwardingProxy so capture-safety is uniform.
285
+ */
286
+ export function makeSwappable<T extends object>(initial: T): [T, SwapFn<T>] {
287
+ let current = initial;
288
+ return [forwardingProxy<T>(() => current), (next: T) => { current = next; }];
289
+ }
290
+
291
+ // ── Mount table ─────────────────────────────────────────────────────────────────
292
+
293
+ /** The machine with one registry key narrowed to present — what a keyed mount handler receives. */
294
+ export type MountedMachine<K extends keyof MatbotServices> =
295
+ MatbotMachine & { readonly [P in K]-?: NonNullable<MatbotServices[P]> };
296
+
297
+ export interface MountConsumeOptions<K extends keyof MatbotServices> {
298
+ /** The registry service whose mount transitions this subscription tracks. */
299
+ readonly key: K;
300
+ /** Fire `handler` once on the next microtask against the *current* machine if `key` is present now
301
+ * (then on each later remount). Off by default — a cacher that did its initial load in setup() wants
302
+ * only future transitions; a deferred dependency wants the latch. */
303
+ readonly replay?: boolean;
304
+ /** Ends the subscription (the consumer's own teardown). */
305
+ readonly signal?: AbortSignal;
306
+ /** The *dependency's* teardown: fired when `key` is committed-unloaded (removed and not replaced by
307
+ * the quiescent edge) while this consumer lives on. The stream continues — a later remount re-fires
308
+ * `handler`. Drop any captured ref to the gone service here. */
309
+ readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
310
+ }
311
+
312
+ /** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. */
313
+ export interface Mounted {
314
+ consume<K extends keyof MatbotServices>(
315
+ options: MountConsumeOptions<K>,
316
+ handler: (machine: MountedMachine<K>) => void | Promise<void>,
317
+ ): void;
318
+ }
319
+
320
+ /** The host-side mount table: {@link Mounted} for plugins, plus the producer half the host drives from
321
+ * its register/unregister and quiescent-edge flush. */
322
+ export interface MountTable {
323
+ readonly mounted: Mounted;
324
+ /** Record that a key's presence may have changed since the last edge (called by register/unregister). */
325
+ markDirty(key: keyof MatbotServices): void;
326
+ /** At a quiescent edge, compute each dirty key's net presence transition and multicast it. */
327
+ flush(): void;
328
+ }
329
+
330
+ interface MountInterest {
331
+ readonly handler: (machine: MatbotMachine) => void | Promise<void>;
332
+ readonly onUnmount: ((machine: MatbotMachine) => void | Promise<void>) | undefined;
333
+ readonly signal: AbortSignal | undefined;
334
+ }
335
+
336
+ function reportMountHandlerError(e: unknown): void {
337
+ console.error('[matbot] mounted handler threw:', e instanceof Error ? e.message : e);
338
+ }
339
+
340
+ /**
341
+ * Build a {@link MountTable} over a lazily-read machine. Notifications batch to the quiescent edge
342
+ * ({@link flush}), where each dirty key's net presence (absent→present = mount, present→present =
343
+ * remount, present→absent = committed unload) is multicast to that key's subscribers. The clock holds
344
+ * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
345
+ * well-defined. Presence is read by member access on the unified machine, which resolves both the core
346
+ * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
347
+ */
348
+ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
349
+ const interests = new Map<string, Set<MountInterest>>();
350
+ const committed = new Map<string, boolean>(); // last-committed presence per key (the clock)
351
+ const dirty = new Set<string>();
352
+
353
+ const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
354
+
355
+ const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
356
+ try {
357
+ const r = fn(machine);
358
+ if (r instanceof Promise) r.catch(reportMountHandlerError);
359
+ } catch (e) { reportMountHandlerError(e); }
360
+ };
361
+
362
+ const mounted: Mounted = {
363
+ consume(options, handler) {
364
+ const { key, replay, signal, onUnmount } = options;
365
+ if (signal?.aborted === true) return;
366
+ const interest: MountInterest = {
367
+ handler: handler as MountInterest['handler'],
368
+ onUnmount: onUnmount as MountInterest['onUnmount'],
369
+ signal,
370
+ };
371
+ let set = interests.get(key);
372
+ if (set === undefined) { set = new Set(); interests.set(key, set); }
373
+ const subs = set;
374
+ subs.add(interest);
375
+ signal?.addEventListener('abort', () => { subs.delete(interest); }, { once: true });
376
+ // Replay on the next microtask (async-iterator parity — never inline in the consume() frame).
377
+ // Reads the live machine, not `committed`: replay is "current state", separate from the clock.
378
+ if (replay === true) queueMicrotask(() => {
379
+ if (signal?.aborted === true) return;
380
+ if (present(key as string)) run(interest.handler, getMachine());
381
+ });
382
+ },
383
+ };
384
+
385
+ return {
386
+ mounted,
387
+ markDirty(key) { dirty.add(key as string); },
388
+ flush() {
389
+ if (dirty.size === 0) return;
390
+ const keys = [...dirty];
391
+ dirty.clear();
392
+ const machine = getMachine();
393
+ for (const key of keys) {
394
+ const before = committed.get(key) ?? false;
395
+ const after = present(key);
396
+ committed.set(key, after);
397
+ const subs = interests.get(key);
398
+ if (subs === undefined) continue;
399
+ if (after) {
400
+ for (const i of subs) run(i.handler, machine); // mount / remount
401
+ } else if (before) {
402
+ for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
403
+ }
404
+ }
405
+ },
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
411
+ * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
412
+ * has no use for. Pure; the host invokes its own complete() with the result.
413
+ */
414
+ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
415
+ return {
416
+ provider: req.provider,
417
+ messages: [{
418
+ id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
419
+ content: [{ type: 'text', text: req.prompt }],
420
+ }],
421
+ ...(req.system !== undefined ? { system: req.system } : {}),
422
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
423
+ };
424
+ }
425
+
426
+ // ── Factory types ─────────────────────────────────────────────────────────────
427
+
428
+ export type ProviderAdapterFactory = (config: ProviderConfig) => ProviderAdapter;
429
+
430
+ export type StoreFactory = (
431
+ kind: string,
432
+ options: Record<string, unknown>,
433
+ ) => Store<{ id: string; version: string }>;
434
+
435
+ // ── Plugin manifest ───────────────────────────────────────────────────────────
436
+
437
+ export interface PluginManifest {
438
+ /** Human-readable description shown by `matbot install` */
439
+ description?: string;
440
+ /** matbot.yaml extensions.<pluginName> keys this plugin reads */
441
+ config?: readonly string[];
442
+ }
443
+
444
+ // ── Storage backend ───────────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * A storage backend replaces both the document store factory and the file store.
448
+ * Registered by a plugin's storageBackend.open() before the services object is built.
449
+ */
450
+ export interface StorageBackend {
451
+ createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
452
+ readonly fileStore: FileStore;
453
+ close?(): Promise<void>;
454
+ }
455
+
456
+ // ── Plugin interface ──────────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * What a plugin author writes. No identity — that is loader-established, not the author's to assign.
460
+ * The loader turns a MatbotPluginSpec into a MatbotPlugin by stamping name/specifier/source.
461
+ */
462
+ export interface MatbotPluginSpec {
463
+ readonly apiVersion: string;
464
+ readonly manifest?: PluginManifest;
465
+ readonly provider?: ProviderAdapterFactory;
466
+ readonly storage?: Record<string, StoreFactory>;
467
+ readonly tools?: readonly Tool[];
468
+ /**
469
+ * When present, the runtime calls open(dotData) before creating the services object and uses the
470
+ * returned backend for all Store and FileStore creation. The plugin must be listed before any plugin
471
+ * whose setup() calls createStore. The host treats a boot-opened backend as owned by this plugin (as
472
+ * if it had `register('StorageBackend', …)` in setup), so unloading the plugin reverts storage to the
473
+ * host's own base and closes the backend. A backend swapped in *later* via register() does not take
474
+ * effect immediately — see {@link MatbotRuntime.mounted}: it lands at the next quiescent edge so it
475
+ * never splits a turn's compare-and-swap across two backends.
476
+ */
477
+ readonly storageBackend?: {
478
+ open(dotData: string): Promise<StorageBackend>;
479
+ };
480
+ setup?(services: MatbotMachine): Promise<void>;
481
+ teardown?(): Promise<void>;
482
+ installationMessage?(): Promise<string>;
483
+ }
484
+
485
+ /**
486
+ * A loaded plugin: the author's spec plus the provenance the loader stamps at load time.
487
+ * Every consumer (registry, tools, UI) sees this type and reads `.name` / `.specifier` off it.
488
+ */
489
+ export interface MatbotPlugin extends MatbotPluginSpec {
490
+ readonly name: string; // resolver.identify(specifier)
491
+ readonly specifier: string; // how it was loaded
492
+ readonly source?: PluginSource;
493
+ // The package.json `matbotRuntime`, captured at load from the same value used for the pre-import
494
+ // gate (the host reads it once; downstream just reads this). Absent ⇒ undeclared. Stored rather
495
+ // than re-derived so `list` can report it offline and for remote plugins, whose config-entry
496
+ // specifier (a github:/https URL) can't be resolved back to a package.json after the fact.
497
+ readonly matbotRuntime?: readonly Runtime[];
498
+ }
@@ -0,0 +1,85 @@
1
+ import type { Principal } from './types.js';
2
+
3
+ /**
4
+ * Ambient carrier for the security {@link Principal} — the single mechanism by which any layer
5
+ * (runner, hook, tool, or a Store/FileStore/Vault/KnowledgeIndex backend) discovers *who*
6
+ * originated the current operation, without that identity being threaded through every signature.
7
+ *
8
+ * The host installs one platform implementation at boot via {@link installPrincipalCarrier}:
9
+ * - node (multi-user, concurrent turns): an `AsyncLocalStorage`-backed carrier — each turn /
10
+ * request runs in its own isolated scope (see the node app's `createAlsPrincipalCarrier`).
11
+ * - browser / any single-principal realm: {@link createConstantPrincipalCarrier} — there is only
12
+ * ever one principal, so no async isolation is needed and `run` is a passthrough.
13
+ *
14
+ * Carrying the principal *grants nothing*. A backend MAY call {@link currentPrincipal} (or
15
+ * {@link tryCurrentPrincipal}) to attribute, branch on, or reject an operation; the default
16
+ * implementations ignore it entirely. Policy is the service's concern — this is only the wire.
17
+ */
18
+ export interface PrincipalCarrier {
19
+ /** The principal in force for the current async flow. Throws if none is established. */
20
+ current(): Principal;
21
+ /** The principal in force, or `undefined` when called outside any established scope. */
22
+ tryCurrent(): Principal | undefined;
23
+ /**
24
+ * Establish `principal` for the dynamic extent of `fn` and return whatever `fn` returns. On node
25
+ * this is per-async-flow (concurrent turns stay isolated); nesting cleanly shadows-and-restores,
26
+ * which is how in-flow delegation works. Consume any async iterable *inside* `fn` — returning an
27
+ * unconsumed iterator drops the scope before it is pulled.
28
+ */
29
+ run<T>(principal: Principal, fn: () => T): T;
30
+ /**
31
+ * Imperatively establish `principal` for the remainder of the current flow — for a process or
32
+ * request *entry* that has no wrapping `fn` (a spawned worker reading its delegated identity, a
33
+ * boot default). Throws if a principal is already established here: re-entry is a wiring bug, and
34
+ * legitimate delegation belongs in a nested {@link run} or at a fresh process boundary.
35
+ */
36
+ enter(principal: Principal): void;
37
+ }
38
+
39
+ let carrier: PrincipalCarrier | undefined;
40
+
41
+ /** Install the host's platform carrier. Called once at boot, before any turn or request runs. */
42
+ export function installPrincipalCarrier(impl: PrincipalCarrier): void {
43
+ carrier = impl;
44
+ }
45
+
46
+ function need(): PrincipalCarrier {
47
+ if (carrier === undefined) {
48
+ throw new Error('No PrincipalCarrier installed — the host must call installPrincipalCarrier() at boot.');
49
+ }
50
+ return carrier;
51
+ }
52
+
53
+ /** The principal in force for the current flow. Throws if called with no carrier or no scope. */
54
+ export function currentPrincipal(): Principal {
55
+ return need().current();
56
+ }
57
+
58
+ /** The principal in force, or `undefined` when no carrier is installed or no scope is established.
59
+ * Backends that want to fail-open on out-of-scope access read this instead of {@link currentPrincipal}. */
60
+ export function tryCurrentPrincipal(): Principal | undefined {
61
+ return carrier?.tryCurrent();
62
+ }
63
+
64
+ /** Run `fn` with `principal` established as the ambient identity for its async extent. */
65
+ export function runAs<T>(principal: Principal, fn: () => T): T {
66
+ return need().run(principal, fn);
67
+ }
68
+
69
+ /** Imperatively establish `principal` for the current flow (entry points only). */
70
+ export function enterPrincipal(principal: Principal): void {
71
+ need().enter(principal);
72
+ }
73
+
74
+ /**
75
+ * A carrier for single-principal realms (the browser, tests): every read returns the same
76
+ * `principal` and `run` is a passthrough, since there is no second identity to isolate from.
77
+ */
78
+ export function createConstantPrincipalCarrier(principal: Principal): PrincipalCarrier {
79
+ return {
80
+ current: () => principal,
81
+ tryCurrent: () => principal,
82
+ run: (_principal, fn) => fn(),
83
+ enter: () => {},
84
+ };
85
+ }
@@ -0,0 +1,70 @@
1
+ // The matbot store query grammar: a minimal, closed filter AST designed to be translated to a
2
+ // real backend (SQL WHERE, Elasticsearch bool, Mongo find, IndexedDB cursor) — not interpreted by
3
+ // an embedded engine. The in-memory reference evaluator lives in @matatbread/matbot-core/storage-base.
4
+ //
5
+ // Design rules (see CLAUDE.md / the store-query design notes):
6
+ // - LHS is always a field, RHS is always a constant. No field-vs-field, no computed values.
7
+ // - Operators are a closed union discriminated by `op`, so every backend compiler is one total
8
+ // `switch` the type checker can prove exhaustive.
9
+ // - Comparisons are type-strict; null and absent are a single "missing" state (queried only via
10
+ // `exists`). null is therefore barred from every comparison operand.
11
+
12
+ export type Scalar = string | number | boolean | null; // a STORED value (may be null)
13
+ export type Comparable = string | number | boolean; // Scalar − null (null → use `exists`)
14
+ export type Orderable = string | number; // ordering operands only
15
+
16
+ // A field reference. A bare string is exactly ONE key (never split on '.'); use an array of
17
+ // segments for a nested path. So `"a.b"` is the key literally named `a.b`; `["a","b"]` is nested.
18
+ export type FieldPath = string | string[];
19
+
20
+ export type Filter =
21
+ | { op: 'eq' | 'neq'; field: FieldPath; value: Comparable }
22
+ | { op: 'lt' | 'lte' | 'gt' | 'gte'; field: FieldPath; value: Orderable }
23
+ | { op: 'in' | 'nin'; field: FieldPath; value: Comparable[] }
24
+ | { op: 'exists'; field: FieldPath; value: boolean }
25
+ | { op: 'stringContains'; field: FieldPath; value: string }
26
+ | { op: 'arrayContains'; field: FieldPath; value: Comparable }
27
+ | { op: 'and' | 'or'; clauses: Filter[] }
28
+ | { op: 'not'; clause: Filter };
29
+
30
+ export interface SortSpec {
31
+ field: FieldPath;
32
+ dir: 'asc' | 'desc';
33
+ }
34
+
35
+ // `cursor` is opaque and backend-issued: pass a previous result's `cursor` back to fetch the next
36
+ // page. Backends append `id` as a final sort tiebreaker so the order is total (cursor-stable).
37
+ export interface StoreQuery {
38
+ where?: Filter;
39
+ sort?: SortSpec[];
40
+ limit?: number;
41
+ cursor?: string;
42
+ }
43
+
44
+ export interface QueryResult<T> {
45
+ items: T[];
46
+ cursor?: string; // present iff more pages may follow
47
+ total?: number; // optional — omitted by backends that cannot count cheaply
48
+ }
49
+
50
+ export type StoreQueryErrorCode =
51
+ | 'UNKNOWN_OP' // an `op` outside the closed union
52
+ | 'OPERAND_TYPE' // operand is the wrong type for the op (e.g. boolean to `gt`)
53
+ | 'NULL_OPERAND' // null compared directly — use `exists` instead
54
+ | 'EMPTY_FIELD' // empty field path or empty path segment
55
+ | 'EMPTY_CLAUSES' // and/or with no clauses
56
+ | 'MALFORMED'; // structurally invalid node (or unreadable cursor)
57
+
58
+ // Thrown at the query boundary (before any backend touches data) with a JSON pointer into the
59
+ // offending node, so an LLM author can locate and fix the clause and retry. Lives in plugin-api
60
+ // (a host-shared package) so cross-plugin `instanceof` works — see the plugin hot-reload notes.
61
+ export class StoreQueryError extends Error {
62
+ readonly pointer: string;
63
+ readonly code: StoreQueryErrorCode;
64
+ constructor(message: string, pointer: string, code: StoreQueryErrorCode) {
65
+ super(message);
66
+ this.name = 'StoreQueryError';
67
+ this.pointer = pointer;
68
+ this.code = code;
69
+ }
70
+ }