@matatbread/matbot-plugin-api 0.3.8 → 0.4.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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.8",
7
+ "version": "0.4.0",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -22,7 +22,8 @@
22
22
  "license": "Apache-2.0",
23
23
  "type": "module",
24
24
  "exports": {
25
- ".": "./src/index.ts"
25
+ ".": "./src/index.ts",
26
+ "./host": "./src/host.ts"
26
27
  },
27
28
  "repository": {
28
29
  "type": "git",
package/src/hooks.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type {
2
- Hook, HookPoint, Message, MessageContent, Session, DeferredScreen,
2
+ Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
+ import { foldOntoUserTurn } from './session.js';
5
6
 
6
7
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
7
8
 
8
- export class HookRegistry {
9
+ export class HookRegistry implements HookRegistrar {
9
10
  private readonly hooks = new Map<HookPoint, Hook[]>();
10
11
 
11
12
  // A hook handler that *throws* is a failure, not an intentional stop (which is a return value:
@@ -111,11 +112,10 @@ export class HookRegistry {
111
112
  // so a user message is present in practice; this is defensive).
112
113
  const durable: MessageContent[] = [];
113
114
  const foldDurable = (blocks: MessageContent[]): void => {
114
- const idx = session.messages.findLastIndex(m => m.role === 'user');
115
- if (idx < 0) return;
115
+ const folded = foldOntoUserTurn(session, blocks);
116
+ if (folded === session) return; // no user message to attach to — dropped, not orphaned
116
117
  durable.push(...blocks);
117
- session = { ...session, messages: session.messages.map((m, i) =>
118
- i === idx ? { ...m, content: [...m.content, ...blocks] } : m) };
118
+ session = folded;
119
119
  };
120
120
  for (const hook of this.hooks.get('screen') ?? []) {
121
121
  if (hook.on !== 'screen') continue;
@@ -0,0 +1,191 @@
1
+ import type {
2
+ CompletionRequest, MatbotMachine, MatbotServices, Mounted, MountConsumeOptions, SingleTurnRequest,
3
+ } from './plugin.js';
4
+
5
+ /*
6
+ * Host-side machine assembly — the wiring an *embedder* does once at boot, not API a plugin author
7
+ * writes against. It lives behind `@matatbread/matbot-plugin-api/host` so the package root can answer
8
+ * exactly one question: what does a plugin need in order to be a plugin? Publishing these at the root
9
+ * made boot mechanics look like stable author-facing surface, and no plugin in this repo imports one.
10
+ *
11
+ * Split out of plugin.ts as a *file* boundary rather than an export list, so the distinction cannot
12
+ * quietly erode the next time something is added. The plugin-facing halves of the same subsystems stay
13
+ * where they are: `Mounted`/`MountConsumeOptions` (the contract of `services.mounted`) in plugin.ts,
14
+ * `runAs`/`currentPrincipal` in principal-context.ts.
15
+ */
16
+
17
+ /**
18
+ * Wrap a services object so every registered service is reachable as a member (`services.InterfaceName`),
19
+ * not only via `services.get('InterfaceName')` — one access surface for base members and plugin-registered
20
+ * services alike. A member read of a key the object doesn't carry falls back to its own `get()` (the
21
+ * registry), so the augmentation that declares `InterfaceName?: InterfaceName` is also the access path;
22
+ * the optional `?` is the single call-site signal of "may be absent, null-check it". Assignment throws,
23
+ * directing callers to `register()` (the swap-aware write path). Applied to both the host services object
24
+ * and the per-plugin scoped object, so plugins see the same surface the host does.
25
+ *
26
+ * Only `get` and `set` are trapped. `has` used to fall through to the registry too, which made the plain
27
+ * language construct `'Foo' in services` perform a registry lookup — no side effect, but a surprise, and
28
+ * inconsistent with `ownKeys`, which never listed registered services. `in` is therefore structural, and
29
+ * the presence check remains the documented one: `services.Foo !== undefined`, or `?.`.
30
+ */
31
+ export function unifyServices(services: MatbotMachine): MatbotMachine {
32
+ return new Proxy(services, {
33
+ get(target, key, receiver) {
34
+ if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
35
+ return typeof key === 'string' ? target.get(key as keyof MatbotServices) : undefined;
36
+ },
37
+ set(_target, key) {
38
+ throw new Error(
39
+ `Cannot assign services.${String(key)} directly — use services.register('${String(key)}', impl) to register or replace a service.`,
40
+ );
41
+ },
42
+ });
43
+ }
44
+ // ── Capture-safe swap proxies ───────────────────────────────────────────────────
45
+
46
+ export type SwapFn<T extends object> = (next: T) => void;
47
+
48
+ /**
49
+ * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
50
+ * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
51
+ * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
52
+ * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
53
+ * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
54
+ */
55
+ export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
56
+ return new Proxy({} as T, {
57
+ get(_t, prop) {
58
+ const cur = getCurrent();
59
+ if (cur === undefined) return undefined;
60
+ const val = Reflect.get(cur, prop, cur);
61
+ return typeof val === 'function' ? (val as (...a: unknown[]) => unknown).bind(cur) : val;
62
+ },
63
+ has(_t, prop) { const cur = getCurrent(); return cur !== undefined && Reflect.has(cur, prop); },
64
+ getPrototypeOf() { const cur = getCurrent(); return cur === undefined ? null : Reflect.getPrototypeOf(cur); },
65
+ ownKeys() { const cur = getCurrent(); return cur === undefined ? [] : Reflect.ownKeys(cur); },
66
+ getOwnPropertyDescriptor(_t, prop) {
67
+ const cur = getCurrent();
68
+ if (cur === undefined) return undefined;
69
+ const d = Reflect.getOwnPropertyDescriptor(cur, prop);
70
+ if (d !== undefined) d.configurable = true; // Proxy invariant: props absent from the {} target must be configurable.
71
+ return d;
72
+ },
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Returns [proxy, swap]: the Store/FileStore handle plugins capture, plus the fn register() calls to
78
+ * repoint it at a new backend's store. Built on forwardingProxy so capture-safety is uniform.
79
+ */
80
+ export function makeSwappable<T extends object>(initial: T): [T, SwapFn<T>] {
81
+ let current = initial;
82
+ return [forwardingProxy<T>(() => current), (next: T) => { current = next; }];
83
+ }
84
+
85
+ // ── Mount table (host half) ──────────────────────────────────
86
+
87
+ /** The host-side mount table: {@link Mounted} for plugins, plus the producer half the host drives from
88
+ * its register/unregister and quiescent-edge flush. */
89
+ export interface MountTable {
90
+ readonly mounted: Mounted;
91
+ /** Record that a key's presence may have changed since the last edge (called by register/unregister). */
92
+ markDirty(key: keyof MatbotServices): void;
93
+ /** At a quiescent edge, compute each dirty key's net presence transition and multicast it. */
94
+ flush(): void;
95
+ }
96
+
97
+ interface MountInterest {
98
+ readonly handler: (machine: MatbotMachine) => void | Promise<void>;
99
+ readonly onUnmount: ((machine: MatbotMachine) => void | Promise<void>) | undefined;
100
+ readonly signal: AbortSignal | undefined;
101
+ }
102
+
103
+ function reportMountHandlerError(e: unknown): void {
104
+ console.error('[matbot] mounted handler threw:', e instanceof Error ? e.message : e);
105
+ }
106
+
107
+ /**
108
+ * Build a {@link MountTable} over a lazily-read machine. Notifications batch to the quiescent edge
109
+ * ({@link flush}), where each dirty key's net presence (absent→present = mount, present→present =
110
+ * remount, present→absent = committed unload) is multicast to that key's subscribers. The clock holds
111
+ * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
112
+ * well-defined. Presence is read by member access on the unified machine, which resolves both the core
113
+ * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
114
+ */
115
+ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
116
+ const interests = new Map<string, Set<MountInterest>>();
117
+ const committed = new Map<string, boolean>(); // last-committed presence per key (the clock)
118
+ const dirty = new Set<string>();
119
+
120
+ const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
121
+
122
+ const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
123
+ try {
124
+ const r = fn(machine);
125
+ if (r instanceof Promise) r.catch(reportMountHandlerError);
126
+ } catch (e) { reportMountHandlerError(e); }
127
+ };
128
+
129
+ const mounted: Mounted = {
130
+ observe(options, handler) {
131
+ const { key, replay, signal, onUnmount } = options;
132
+ if (signal?.aborted === true) return;
133
+ const interest: MountInterest = {
134
+ handler: handler as MountInterest['handler'],
135
+ onUnmount: onUnmount as MountInterest['onUnmount'],
136
+ signal,
137
+ };
138
+ let set = interests.get(key);
139
+ if (set === undefined) { set = new Set(); interests.set(key, set); }
140
+ const subs = set;
141
+ subs.add(interest);
142
+ signal?.addEventListener('abort', () => { subs.delete(interest); }, { once: true });
143
+ // Replay on the next microtask (async-iterator parity — never inline in the consume() frame).
144
+ // Reads the live machine, not `committed`: replay is "current state", separate from the clock.
145
+ if (replay === true) queueMicrotask(() => {
146
+ if (signal?.aborted === true) return;
147
+ if (present(key as string)) run(interest.handler, getMachine());
148
+ });
149
+ },
150
+ };
151
+
152
+ return {
153
+ mounted,
154
+ markDirty(key) { dirty.add(key as string); },
155
+ flush() {
156
+ if (dirty.size === 0) return;
157
+ const keys = [...dirty];
158
+ dirty.clear();
159
+ const machine = getMachine();
160
+ for (const key of keys) {
161
+ const before = committed.get(key) ?? false;
162
+ const after = present(key);
163
+ committed.set(key, after);
164
+ const subs = interests.get(key);
165
+ if (subs === undefined) continue;
166
+ if (after) {
167
+ for (const i of subs) run(i.handler, machine); // mount / remount
168
+ } else if (before) {
169
+ for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
170
+ }
171
+ }
172
+ },
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
178
+ * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
179
+ * has no use for. Pure; the host invokes its own complete() with the result.
180
+ */
181
+ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
182
+ return {
183
+ provider: req.provider,
184
+ messages: [{
185
+ id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
186
+ content: [{ type: 'text', text: req.prompt }],
187
+ }],
188
+ ...(req.system !== undefined ? { system: req.system } : {}),
189
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
190
+ };
191
+ }
package/src/host.ts ADDED
@@ -0,0 +1,43 @@
1
+ /*
2
+ * `@matatbread/matbot-plugin-api/host` — the boot-time wiring an *embedder* performs to stand a machine
3
+ * up, as opposed to what a *plugin* is written against.
4
+ *
5
+ * The package root used to export both, which made carrier installers, swap proxies, the mount table and
6
+ * the hook registry implementation look like stable author-facing API. They are not: no plugin in this
7
+ * repo imports one, and only `apps/*` and `core` ever did. Splitting them here lets the root answer one
8
+ * question — what does a plugin need in order to be a plugin? — and lets these change without it being a
9
+ * plugin-API break.
10
+ *
11
+ * Three of these subsystems are deliberately *split* rather than moved whole, because each has a genuine
12
+ * author-facing half that stays at the root:
13
+ * - principal: `runAs` / `currentPrincipal` / `tryCurrentPrincipal` are read and established by plugins
14
+ * (a frontend scopes each inbound message); only *installing the carrier* is the host's job.
15
+ * - notifications: every plugin publishes and consumes; only *minting* the process notifier is the host's.
16
+ * - mount table: `services.mounted` (`Mounted`, `MountConsumeOptions`) is the plugin contract; the
17
+ * producer half (`MountTable`, `markDirty`/`flush`) is driven by the host's register/quiescent edge.
18
+ *
19
+ * `core` re-exports everything here, so a host that already depends on `@matatbread/matbot-core` needs no
20
+ * direct import of this subpath.
21
+ */
22
+
23
+ // Machine assembly: member-access unification, capture-safe swap proxies, the mount table's producer
24
+ // half, and the one-shot CompletionRequest builder behind MatbotRuntime.singleTurn.
25
+ export * from './host-machine.js';
26
+
27
+ // Ambient scope carriers. The host installs a platform impl once at boot (node: AsyncLocalStorage;
28
+ // browser/single-principal: a constant carrier) and never touches them again.
29
+ export { installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier } from './principal-context.js';
30
+ export type { PrincipalCarrier } from './principal-context.js';
31
+ export * from './usage-context.js';
32
+
33
+ // The machine half of a context switch: quiescent-edge flushers and the deferred-swap application point.
34
+ export * from './context-switch.js';
35
+
36
+ // Hook dispatch implementation. Plugins register hooks through `services.hooks`; only the host constructs
37
+ // the registry that backs it.
38
+ export { HookRegistry } from './hooks.js';
39
+
40
+ // The multi-subscriber fan-out primitive under `Notifier`. A plugin that wants to publish an event uses
41
+ // the Notifier — that is the sanctioned path, and the reason this stays host-side.
42
+ export * from './broadcast.js';
43
+ export { createNotifier, scopedNotifier } from './notify.js';
package/src/index.ts CHANGED
@@ -1,14 +1,32 @@
1
+ /*
2
+ * `@matatbread/matbot-plugin-api` — everything a plugin author needs, and nothing a host needs to *build*
3
+ * a machine. Boot wiring (carrier installers, swap proxies, the mount table's producer half, the hook
4
+ * registry impl, the broadcaster) lives behind the `/host` subpath; see host.ts for the reasoning.
5
+ *
6
+ * Star-exported where a file is wholly plugin-facing; enumerated where a file is deliberately split. The
7
+ * two enumerated cases use `export type *` for the shapes plus a named list of runtime values, so a new
8
+ * *type* flows out automatically and a new *value* is a decision someone has to make here.
9
+ */
10
+
1
11
  export type * from './types.js';
2
- export { CONFIRM_YES, CONFIRM_NO } from './types.js';
12
+ export { CONFIRM_YES, CONFIRM_NO, isTruncatedToolResult } from './types.js';
13
+
3
14
  export * from './store-query.js';
4
- export * from './vault.js';
5
15
  export * from './errors.js';
6
- export * from './principal-context.js';
7
- export * from './usage-context.js';
16
+ export * from './vault.js';
8
17
  export * from './session.js';
9
- export * from './context-switch.js';
10
- export * from './hooks.js';
11
- export * from './plugin.js';
12
- export * from './broadcast.js';
13
- export * from './notify.js';
14
18
  export * from './invoke-tool.js';
19
+
20
+ // The plugin half of the machine: MatbotPlugin/MatbotServices/MatbotRuntime, the mount-table *consumer*
21
+ // contract, and the API version a plugin declares. Host assembly is in ./host-machine.ts.
22
+ export type * from './plugin.js';
23
+ export { PLUGIN_API_VERSION } from './plugin.js';
24
+
25
+ // Ambient principal: read it, or establish one for an inbound request/message. Installing the carrier is
26
+ // the host's job (`/host`).
27
+ export { currentPrincipal, tryCurrentPrincipal, runAs } from './principal-context.js';
28
+
29
+ // Notifications: publish, consume, and the two kinds plugin-api itself defines. Minting the process
30
+ // notifier is the host's job (`/host`).
31
+ export type * from './notify.js';
32
+ export { ItemChangeKind, RegistryChangeKind, notifyingStore } from './notify.js';
@@ -1,6 +1,29 @@
1
- import type { MatbotMachine } from './plugin.js';
1
+ import type { MatbotMachine, MatbotPlugin } from './plugin.js';
2
2
  import type { ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField } from './types.js';
3
3
 
4
+ /** The host's plugin hot-load ops, as both the runner and {@link invokeTool} hold them. */
5
+ export interface PluginOps {
6
+ loadPlugin(specifier: string, prompt?: PromptFn, refresh?: boolean): Promise<MatbotPlugin>;
7
+ unloadPlugin(specifier: string): Promise<boolean>;
8
+ }
9
+
10
+ /**
11
+ * Bind the plugin hot-load ops for a {@link ToolContext}, closing over the turn's `prompt`.
12
+ *
13
+ * That closure is the whole point of `ToolContext.loadPlugin` existing alongside
14
+ * `MatbotRuntime.loadPlugin`: a tool cannot forget to pass the prompt, so an interactive load cannot
15
+ * silently become non-interactive at one call site out of six — the same reasoning that makes the
16
+ * principal ambient rather than threaded. What it should not be is written out twice, once in the runner
17
+ * and once here, since the injected middle argument is exactly the shape a signature change breaks
18
+ * quietly.
19
+ */
20
+ export function bindPluginOps(host: PluginOps, prompt: PromptFn): Pick<ToolContext, 'loadPlugin' | 'unloadPlugin'> {
21
+ return {
22
+ loadPlugin: (specifier, refresh) => host.loadPlugin(specifier, prompt, refresh),
23
+ unloadPlugin: (specifier) => host.unloadPlugin(specifier),
24
+ };
25
+ }
26
+
4
27
  /**
5
28
  * Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
6
29
  * forwarding a call to another tool should pass its own {@link ToolContext} straight through as
@@ -41,8 +64,7 @@ export function invokeTool<K extends string, const P>(
41
64
  signal: opts.signal,
42
65
  vault: machine.Vault,
43
66
  prompt,
44
- loadPlugin: (specifier, refresh) => machine.loadPlugin(specifier, prompt, refresh),
45
- unloadPlugin: (specifier) => machine.unloadPlugin(specifier),
67
+ ...bindPluginOps(machine, prompt),
46
68
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
47
69
  ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
48
70
  ...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
package/src/notify.ts CHANGED
@@ -98,6 +98,9 @@ export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange'
98
98
  * exhaustiveness checking is unsound here (unlike a closed discriminated union). A bridge that wants a
99
99
  * foreign kind typed augments this itself; otherwise it validates at its own boundary, like any other
100
100
  * system edge.
101
+ *
102
+ * One of matbot's five open-registry augmentation points — same technique at each; see
103
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
101
104
  */
102
105
  export interface Notifications {
103
106
  '@matatbread/matbot-plugin-api#ItemChange': ItemChange;
package/src/plugin.ts CHANGED
@@ -1,14 +1,26 @@
1
1
  import type {
2
- FileStore, Principal, Vault, Message, ModelParameters,
2
+ FileStore, Principal, Vault, Message,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
- Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
4
+ Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
5
  TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
6
6
  } from './types.js';
7
- import type { Routed } from './broadcast.js';
8
7
  import type { Notifier } from './notify.js';
9
- import type { HookRegistry } from './hooks.js';
10
8
 
11
- export const PLUGIN_API_VERSION = '0.1';
9
+ /**
10
+ * The plugin API contract version: this package's `major.minor`, and nothing else. It sat at `'0.1'`
11
+ * while the package shipped 0.3.x, so it conveyed no information and could not be reasoned about at a
12
+ * version boundary — the point at which it is the only thing every third-party plugin hardcodes.
13
+ *
14
+ * The gate (`checkApiVersion`, in core's registry) reads it as: **major must match exactly** — a
15
+ * mismatch is a hard load failure — and a plugin declaring a *newer minor* than the runtime warns and
16
+ * loads, since it may reach for something absent. Declare it as `apiVersion: PLUGIN_API_VERSION` and it
17
+ * stays right for free; that is what every plugin in this repo does.
18
+ *
19
+ * Bumping 0.1 → 0.4 breaks nothing: at 0.x the major is `'0'` either way, and an older declared minor
20
+ * does not warn. It matters at 1.0.0, where a plugin still declaring `'0.x'` will fail the major check
21
+ * loudly instead of loading against a contract it was never built for.
22
+ */
23
+ export const PLUGIN_API_VERSION = '0.4';
12
24
 
13
25
  // ── Sub-runner types ──────────────────────────────────────────────────────────
14
26
 
@@ -16,16 +28,6 @@ export interface CompletionRequest {
16
28
  provider: string;
17
29
  messages: Message[];
18
30
  system?: string;
19
- /**
20
- * @deprecated Per-call parameter overrides, shallow-merged over the provider config (request wins).
21
- * It DOES work — both hosts (CLI + web-bundle) honour it — but it is a speculative affordance whose
22
- * obvious use is an anti-pattern: a caller clamping tokens / disabling thinking for one call takes
23
- * control away from the provider config, where parameters belong and stay user-editable. Prefer a
24
- * dedicated provider profile (e.g. a `-classify` provider) instead of reaching for this. Kept, not
25
- * removed, only because the surface is tiny and a genuine per-call need may yet appear. If you found
26
- * this because you set it and it worked — reconsider whether a provider profile is the right home.
27
- */
28
- parameters?: Partial<ModelParameters>;
29
31
  signal?: AbortSignal;
30
32
  }
31
33
 
@@ -38,9 +40,6 @@ export interface SingleTurnRequest {
38
40
  provider: string;
39
41
  prompt: string;
40
42
  system?: string;
41
- /** @deprecated Forwarded verbatim to {@link CompletionRequest.parameters}; same speculative
42
- * anti-pattern caveat — prefer a dedicated provider profile over per-call overrides. */
43
- parameters?: Partial<ModelParameters>;
44
43
  signal?: AbortSignal;
45
44
  }
46
45
 
@@ -99,11 +98,15 @@ export interface PluginSelf {
99
98
  // ── Services container ────────────────────────────────────────────────────────
100
99
 
101
100
  /**
101
+ * One of matbot's five open-registry augmentation points — same technique at each; see
102
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
103
+ *
102
104
  * The registry bucket: the swappable, registerable services, keyed by interface name. This is the
103
105
  * `keyof` domain of {@link MatbotRuntime.register}/`get`, and the surface third-party plugins augment
104
106
  * (`declare module '@matatbread/matbot-plugin-api' { interface MatbotServices { Foo?: Foo } }`). The
105
- * core three carry a host boot default and revert to it when unregistered; an augmented service is
106
- * optional and simply drops. Read each as a member (`services.KnowledgeIndex`); swap with register().
107
+ * four swap-members (`StorageBackend`, `KnowledgeIndex`, `Vault`, `Notifier`) carry a host boot default
108
+ * and revert to it when unregistered; an augmented service is optional and simply drops. Read each as
109
+ * a member (`services.KnowledgeIndex`); swap with register().
107
110
  */
108
111
  export interface MatbotServices {
109
112
  readonly StorageBackend?: StorageBackend | undefined;
@@ -255,7 +258,7 @@ export interface MatbotRuntime {
255
258
  * runSession directly, so concurrent submits queue instead of clobbering the session. */
256
259
  readonly run?: SessionRunner | undefined;
257
260
  readonly files?: FileStore;
258
- readonly hooks: HookRegistry;
261
+ readonly hooks: HookRegistrar;
259
262
  readonly tools: ToolRegistry;
260
263
  readonly systemContext: SystemContextRegistry;
261
264
  /** Default working directory for tool execution. Plugins that create servers should forward this to tool contexts. */
@@ -274,74 +277,6 @@ export interface MatbotRuntime {
274
277
  isSubAgent(): boolean;
275
278
  }
276
279
 
277
- /**
278
- * Wrap a services object so every registered service is reachable as a member (`services.InterfaceName`),
279
- * not only via `services.get('InterfaceName')` — one access surface for base members and plugin-registered
280
- * services alike. A member read of a key the object doesn't carry falls back to its own `get()` (the
281
- * registry), so the augmentation that declares `InterfaceName?: InterfaceName` is also the access path;
282
- * the optional `?` is the single call-site signal of "may be absent, null-check it". Assignment throws,
283
- * directing callers to `register()` (the swap-aware write path). Applied to both the host services object
284
- * and the per-plugin scoped object, so plugins see the same surface the host does.
285
- */
286
- export function unifyServices(services: MatbotMachine): MatbotMachine {
287
- return new Proxy(services, {
288
- get(target, key, receiver) {
289
- if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
290
- return typeof key === 'string' ? target.get(key as keyof MatbotServices) : undefined;
291
- },
292
- has(target, key) {
293
- if (Reflect.has(target, key)) return true;
294
- return typeof key === 'string' && target.get(key as keyof MatbotServices) !== undefined;
295
- },
296
- set(_target, key) {
297
- throw new Error(
298
- `Cannot assign services.${String(key)} directly — use services.register('${String(key)}', impl) to register or replace a service.`,
299
- );
300
- },
301
- });
302
- }
303
-
304
- // ── Capture-safe swap proxies ───────────────────────────────────────────────────
305
-
306
- export type SwapFn<T extends object> = (next: T) => void;
307
-
308
- /**
309
- * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
310
- * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
311
- * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
312
- * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
313
- * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
314
- */
315
- export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
316
- return new Proxy({} as T, {
317
- get(_t, prop) {
318
- const cur = getCurrent();
319
- if (cur === undefined) return undefined;
320
- const val = Reflect.get(cur, prop, cur);
321
- return typeof val === 'function' ? (val as (...a: unknown[]) => unknown).bind(cur) : val;
322
- },
323
- has(_t, prop) { const cur = getCurrent(); return cur !== undefined && Reflect.has(cur, prop); },
324
- getPrototypeOf() { const cur = getCurrent(); return cur === undefined ? null : Reflect.getPrototypeOf(cur); },
325
- ownKeys() { const cur = getCurrent(); return cur === undefined ? [] : Reflect.ownKeys(cur); },
326
- getOwnPropertyDescriptor(_t, prop) {
327
- const cur = getCurrent();
328
- if (cur === undefined) return undefined;
329
- const d = Reflect.getOwnPropertyDescriptor(cur, prop);
330
- if (d !== undefined) d.configurable = true; // Proxy invariant: props absent from the {} target must be configurable.
331
- return d;
332
- },
333
- });
334
- }
335
-
336
- /**
337
- * Returns [proxy, swap]: the Store/FileStore handle plugins capture, plus the fn register() calls to
338
- * repoint it at a new backend's store. Built on forwardingProxy so capture-safety is uniform.
339
- */
340
- export function makeSwappable<T extends object>(initial: T): [T, SwapFn<T>] {
341
- let current = initial;
342
- return [forwardingProxy<T>(() => current), (next: T) => { current = next; }];
343
- }
344
-
345
280
  // ── Mount table ─────────────────────────────────────────────────────────────────
346
281
 
347
282
  /** The machine with one registry key narrowed to present — what a keyed mount handler receives. */
@@ -355,7 +290,10 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
355
290
  * (then on each later remount). Off by default — a cacher that did its initial load in setup() wants
356
291
  * only future transitions; a deferred dependency wants the latch. */
357
292
  readonly replay?: boolean;
358
- /** Ends the subscription (the consumer's own teardown). */
293
+ /** Ends the subscription *earlier* than the observing plugin's unload. Optional because it is a
294
+ * narrowing convenience, not the cleanup path: the host binds every plugin-scoped observe() to that
295
+ * plugin's load extent, so an interest can never outlive its owner. Pass one only when the
296
+ * subscription's life is shorter than the plugin's (a per-session cache, a one-shot latch). */
359
297
  readonly signal?: AbortSignal;
360
298
  /** The *dependency's* teardown: fired when `key` is committed-unloaded (removed and not replaced by
361
299
  * the quiescent edge) while this consumer lives on. The stream continues — a later remount re-fires
@@ -373,112 +311,7 @@ export interface Mounted {
373
311
  ): void;
374
312
  }
375
313
 
376
- /** The host-side mount table: {@link Mounted} for plugins, plus the producer half the host drives from
377
- * its register/unregister and quiescent-edge flush. */
378
- export interface MountTable {
379
- readonly mounted: Mounted;
380
- /** Record that a key's presence may have changed since the last edge (called by register/unregister). */
381
- markDirty(key: keyof MatbotServices): void;
382
- /** At a quiescent edge, compute each dirty key's net presence transition and multicast it. */
383
- flush(): void;
384
- }
385
-
386
- interface MountInterest {
387
- readonly handler: (machine: MatbotMachine) => void | Promise<void>;
388
- readonly onUnmount: ((machine: MatbotMachine) => void | Promise<void>) | undefined;
389
- readonly signal: AbortSignal | undefined;
390
- }
391
-
392
- function reportMountHandlerError(e: unknown): void {
393
- console.error('[matbot] mounted handler threw:', e instanceof Error ? e.message : e);
394
- }
395
-
396
- /**
397
- * Build a {@link MountTable} over a lazily-read machine. Notifications batch to the quiescent edge
398
- * ({@link flush}), where each dirty key's net presence (absent→present = mount, present→present =
399
- * remount, present→absent = committed unload) is multicast to that key's subscribers. The clock holds
400
- * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
401
- * well-defined. Presence is read by member access on the unified machine, which resolves both the core
402
- * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
403
- */
404
- export function createMountTable(getMachine: () => MatbotMachine): MountTable {
405
- const interests = new Map<string, Set<MountInterest>>();
406
- const committed = new Map<string, boolean>(); // last-committed presence per key (the clock)
407
- const dirty = new Set<string>();
408
-
409
- const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
410
-
411
- const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
412
- try {
413
- const r = fn(machine);
414
- if (r instanceof Promise) r.catch(reportMountHandlerError);
415
- } catch (e) { reportMountHandlerError(e); }
416
- };
417
-
418
- const mounted: Mounted = {
419
- observe(options, handler) {
420
- const { key, replay, signal, onUnmount } = options;
421
- if (signal?.aborted === true) return;
422
- const interest: MountInterest = {
423
- handler: handler as MountInterest['handler'],
424
- onUnmount: onUnmount as MountInterest['onUnmount'],
425
- signal,
426
- };
427
- let set = interests.get(key);
428
- if (set === undefined) { set = new Set(); interests.set(key, set); }
429
- const subs = set;
430
- subs.add(interest);
431
- signal?.addEventListener('abort', () => { subs.delete(interest); }, { once: true });
432
- // Replay on the next microtask (async-iterator parity — never inline in the consume() frame).
433
- // Reads the live machine, not `committed`: replay is "current state", separate from the clock.
434
- if (replay === true) queueMicrotask(() => {
435
- if (signal?.aborted === true) return;
436
- if (present(key as string)) run(interest.handler, getMachine());
437
- });
438
- },
439
- };
440
-
441
- return {
442
- mounted,
443
- markDirty(key) { dirty.add(key as string); },
444
- flush() {
445
- if (dirty.size === 0) return;
446
- const keys = [...dirty];
447
- dirty.clear();
448
- const machine = getMachine();
449
- for (const key of keys) {
450
- const before = committed.get(key) ?? false;
451
- const after = present(key);
452
- committed.set(key, after);
453
- const subs = interests.get(key);
454
- if (subs === undefined) continue;
455
- if (after) {
456
- for (const i of subs) run(i.handler, machine); // mount / remount
457
- } else if (before) {
458
- for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
459
- }
460
- }
461
- },
462
- };
463
- }
464
314
 
465
- /**
466
- * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
467
- * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
468
- * has no use for. Pure; the host invokes its own complete() with the result.
469
- */
470
- export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
471
- return {
472
- provider: req.provider,
473
- messages: [{
474
- id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
475
- content: [{ type: 'text', text: req.prompt }],
476
- }],
477
- ...(req.system !== undefined ? { system: req.system } : {}),
478
- ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
479
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
480
- };
481
- }
482
315
 
483
316
  // ── Factory types ─────────────────────────────────────────────────────────────
484
317