@matatbread/matbot-plugin-api 0.3.7 → 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.7",
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,13 +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
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 ADDED
@@ -0,0 +1,242 @@
1
+ import type { Principal, Store } from './types.js';
2
+ import { createBroadcaster } from './broadcast.js';
3
+ import { tryCurrentPrincipal } from './principal-context.js';
4
+
5
+ /**
6
+ * What every notification carries, whoever produced it and however it arrived.
7
+ *
8
+ * Two independent axes ride here, and conflating them is the mistake this shape exists to prevent:
9
+ *
10
+ * - **attribution** — `instance`/`plugin`/`source`: who *produced* the fact. Drives per-producer
11
+ * filtering, and lets a bridge suppress its own echo.
12
+ * - **ownership** — `principal`: whose *data* the fact is about. This is the visibility axis, the
13
+ * input to `WatchVisibility.visible`. A notification with no principal is a global fact.
14
+ *
15
+ * A producer never sets `instance`: undefined means "this matbot". A bridge stamps it on ingress, so a
16
+ * forwarded event stays attributable to the server that produced it and never loops back out.
17
+ *
18
+ * `kind` is deliberately absent here: it is not something an arm declares but something its
19
+ * {@link Notifications} key *is*, grafted on by {@link Notification}. Declaring it on the arm as well
20
+ * would be the same string written twice with nothing keeping the two honest.
21
+ */
22
+ export interface NotificationBase {
23
+ /** Producing plugin's name; `core` is reserved for the host and core itself. Defaulted from the
24
+ * emitting plugin's scope, so an ordinary producer never sets it. It stays overridable rather than
25
+ * enforced because relaying another producer's event is a legitimate act — that is exactly what a
26
+ * bridge does with a notification it received from another instance. */
27
+ readonly plugin: string;
28
+ /** Producer-defined sub-channel within that plugin (`tools`, `save`, `job-output`, …). Opaque to core. */
29
+ readonly source: string;
30
+ /** Originating matbot instance. Undefined ⇒ produced locally. Set only by a bridge, on ingress. */
31
+ readonly instance?: string;
32
+ /** Whose data this is about — the visibility axis, NOT attribution. Undefined ⇒ a global fact. */
33
+ readonly principal?: Principal;
34
+ }
35
+
36
+ /**
37
+ * The thing addressed by `(namespace, id)` is no longer what you last read — re-read it.
38
+ *
39
+ * Deliberately named for that contract and not for a medium: this is published by `Store` writes
40
+ * (via {@link notifyingStore}), by `FileStore` writes, and by a share/unshare that passes through
41
+ * neither. **Use it whenever the change is an invalidation of an addressable item**, wherever the item
42
+ * lives — including a plugin watching something matbot doesn't own. Define a `kind` of your own only
43
+ * when you carry information a consumer *cannot* get by re-reading (progress, a measurement, an
44
+ * external event); squeezing that in here as `detail` is the mistake this note exists to prevent.
45
+ *
46
+ * Carries identity, never the value: an event is queued per subscriber and is stale the moment a
47
+ * concurrent writer lands, so a consumer that acts on a payload races the source it is mirroring.
48
+ *
49
+ * `namespace` is the ROUTING namespace (`files`, `skills`, `sessions`, a document namespace) — the axis
50
+ * a profile isolates and `visible` routes on — not a content sub-namespace. `detail` is advisory only:
51
+ * a cheap payload for an in-place UI update (a file's size) that a consumer may render but must never
52
+ * treat as authoritative.
53
+ */
54
+ export interface ItemChange extends NotificationBase {
55
+ readonly namespace: string;
56
+ readonly id: string;
57
+ readonly operation: 'saved' | 'deleted';
58
+ readonly detail?: unknown;
59
+ }
60
+
61
+ /** A process-global registry gained or lost a member (tools, plugins). No owner, no item identity. */
62
+ export interface RegistryChange extends NotificationBase {
63
+ readonly registry: string;
64
+ readonly name: string;
65
+ readonly operation: 'added' | 'removed';
66
+ }
67
+
68
+ /**
69
+ * The wire tags for the two kinds defined here. A `kind` is a globally-scoped token — unlike a type
70
+ * name, which an importer can rename out of a clash — so these exist to give one back: import the const
71
+ * under whatever local name suits (`import { ItemChangeKind as Changed }`) and use it as a `case` label
72
+ * or in a filter. `satisfies` ties each to a real key, so a typo fails the build here instead of
73
+ * becoming a predicate that silently never matches.
74
+ */
75
+ export const ItemChangeKind = '@matatbread/matbot-plugin-api#ItemChange' satisfies keyof Notifications;
76
+ export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange' satisfies keyof Notifications;
77
+
78
+ /**
79
+ * The registry of notification shapes, keyed by `kind` — augment it exactly like `MatbotServices` /
80
+ * `MarkerData` / `ToolContracts` to add your own:
81
+ *
82
+ * export interface JobProgress extends NotificationBase { done: number; total: number }
83
+ *
84
+ * declare module '@matatbread/matbot-plugin-api' {
85
+ * interface Notifications { '@fnarr/jobs#JobProgress': JobProgress }
86
+ * }
87
+ *
88
+ * **The key is `<package-name>#<InterfaceName>` — no translation, no invention.** As with the service
89
+ * registry, the string is the erasure-time stand-in for a type identity; unlike a type name, it is
90
+ * globally scoped and an importer cannot rename it out of a collision, so the package name (already
91
+ * unique, npm guarantees it) qualifies it. The prefix names the package that *defines* the shape, never
92
+ * the one emitting it — `plugin` is the emitter, and four different plugins emit `ItemChange`. The rule
93
+ * is enforced by {@link Qualified} at the `notify` call; export the key as a const beside the interface
94
+ * (see {@link ItemChangeKind}) so consumers get a renameable handle back.
95
+ *
96
+ * **The set is open at runtime.** A kind can arrive from a plugin that isn't in this build, or bridged
97
+ * from another server — so a `switch (n.kind)` over notifications must always have a `default`, and
98
+ * exhaustiveness checking is unsound here (unlike a closed discriminated union). A bridge that wants a
99
+ * foreign kind typed augments this itself; otherwise it validates at its own boundary, like any other
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.
104
+ */
105
+ export interface Notifications {
106
+ '@matatbread/matbot-plugin-api#ItemChange': ItemChange;
107
+ '@matatbread/matbot-plugin-api#RegistryChange': RegistryChange;
108
+ }
109
+
110
+ /** A notification as a consumer sees it: the arm, plus the `kind` its registry key supplies. Grafting
111
+ * `kind` on here rather than declaring it per-arm is what makes "the tag equals the key" unrepresentable
112
+ * rather than a convention to police. */
113
+ export type Notification = {
114
+ [K in keyof Notifications]: Notifications[K] & { readonly kind: K };
115
+ }[keyof Notifications];
116
+
117
+ /** One arm as a standalone type, for the rare annotation that wants a single kind rather than the union. */
118
+ export type Notified<K extends keyof Notifications> = Notifications[K] & { readonly kind: K };
119
+
120
+ /** Fails an arm whose key isn't `<package>#<Name>`. Intersected into {@link NotifyInput}, so a malformed
121
+ * key surfaces at the `notify` call — in the compilation that declared the augmentation, since the
122
+ * mapped type is resolved at the use site. Nothing at runtime; see `createNotifier` for the producers
123
+ * TypeScript can't see. */
124
+ type Qualified<K> = K extends `${string}#${string}` ? unknown
125
+ : { 'ERROR — notification kind must be `<package-name>#<InterfaceName>`': K };
126
+
127
+ /** What a producer passes to {@link Notifier.notify}: any registered arm, plus its key as `kind`, minus
128
+ * the `plugin` the runtime stamps. Mapped over the registry (not `Omit<Notification, …>`) so it
129
+ * distributes across arms and picks up augmentations. `plugin` stays settable for the one caller that
130
+ * legitimately relays another's event — a bridge replaying a remote notification. */
131
+ export type NotifyInput = {
132
+ [K in keyof Notifications]: Qualified<K> & Omit<Notifications[K], 'plugin'> & { readonly kind: K; plugin?: string };
133
+ }[keyof Notifications];
134
+
135
+ /** Per-subscriber predicate. Applied at emit time, so a subscriber never queues what it didn't ask for. */
136
+ export type NotificationFilter = (notification: Notification) => boolean;
137
+
138
+ /**
139
+ * The notification bus: one fan-out for every "something changed" fact a frontend, cache, or bridge
140
+ * needs, replacing the per-kind bespoke streams each producer used to hand out.
141
+ *
142
+ * A swappable {@link MatbotServices} member, not fixed runtime plumbing. The host boots an in-process
143
+ * broadcaster; `register('Notifier', …)` swaps in a distributed one (Redis, NATS, a matbot-to-matbot
144
+ * bridge) behind the usual capture-safe proxy, and unloading that plugin reverts to the host's default.
145
+ * Matbot owns the envelope and the registration surface; it does not own delivery.
146
+ *
147
+ * **What matbot guarantees, and an alternative implementation must preserve:**
148
+ * - `notify` never throws and never blocks — enqueue and return; a slow subscriber blocks nobody.
149
+ * - Ordered per producer to each subscriber. No ordering across producers.
150
+ * - Local delivery is at-most-once, in-memory. **No persistence, no replay, no delivery guarantee.**
151
+ * A sink attaching mid-flight has missed whatever preceded it — so a sink re-queries on attach and
152
+ * treats notifications purely as invalidation. Never put current state in the bus.
153
+ * - Filters are applied before queueing.
154
+ * - An implementation that forwards off-box MUST stamp `instance` on ingress and MUST NOT forward a
155
+ * notification that already carries a foreign `instance` — that is the loop break.
156
+ */
157
+ export interface Notifier {
158
+ /** Publish. `plugin` is stamped from the calling plugin's scope unless explicitly relayed. */
159
+ notify(notification: NotifyInput): void;
160
+ subscribe(signal?: AbortSignal, filter?: NotificationFilter): AsyncIterable<Notification>;
161
+ /** Detached observation loop: awaits each handler before pulling the next, isolates a throwing
162
+ * handler, ends when `signal` aborts. The fire-and-forget form of the for-await every sink writes. */
163
+ consume(handler: (notification: Notification) => void | Promise<void>, signal?: AbortSignal, filter?: NotificationFilter): void;
164
+ }
165
+
166
+ /**
167
+ * The host's boot default: in-process fan-out over the shared broadcaster, one queue per subscriber.
168
+ * `defaultPlugin` is stamped on anything published without one — the host passes `core`, and the
169
+ * per-plugin scope built in setupPlugin passes the plugin's own name.
170
+ */
171
+ export function createNotifier(defaultPlugin: string): Notifier {
172
+ const events = createBroadcaster<Notification>();
173
+ return {
174
+ notify(notification) {
175
+ const plugin = notification.plugin ?? defaultPlugin;
176
+ // The type-level rule is erased by the time this runs, and TypeScript never saw the producers that
177
+ // matter most here: a plain-JS plugin, or a bridge injecting another instance's traffic. Warn, don't
178
+ // throw — `notify` is contractually non-throwing, and a badly-named kind is a hygiene fault, not a
179
+ // delivery failure. Only the shape is checkable; that the prefix names the *definer* is not, since
180
+ // the emitter legitimately differs from it.
181
+ if (!notification.kind.includes('#')) {
182
+ console.warn(`[matbot] ${plugin} published unqualified notification kind "${notification.kind}" — expected <package-name>#<InterfaceName>`);
183
+ }
184
+ events.emit({ ...notification, plugin } as Notification);
185
+ },
186
+ async *subscribe(signal?: AbortSignal, filter?: NotificationFilter) {
187
+ for await (const event of events.subscribe(signal, filter ? e => filter(e.value) : undefined)) yield event.value;
188
+ },
189
+ consume(handler, signal?, filter?) {
190
+ events.consume(e => handler(e.value), signal, filter ? e => filter(e.value) : undefined);
191
+ },
192
+ };
193
+ }
194
+
195
+ /** Re-scope a notifier so everything published through it is attributed to `plugin`. */
196
+ export function scopedNotifier(notifier: Notifier, plugin: string): Notifier {
197
+ return {
198
+ notify(notification) { notifier.notify({ ...notification, plugin: notification.plugin ?? plugin } as NotifyInput); },
199
+ subscribe: (signal, filter) => notifier.subscribe(signal, filter),
200
+ consume: (handler, signal, filter) => notifier.consume(handler, signal, filter),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Wrap a store so every successful write announces itself. For a namespace with ONE writer, an explicit
206
+ * `notify` at that writer is clearer; use this where a namespace has many — `sessions` is written by the
207
+ * turn pump (title derivation, persist-at-turn-start, the assistant commit), the `session_action` tool
208
+ * (rename/hide/unhide), `session_edit` (fork/split), and the frontend (a session minted over HTTP). One
209
+ * wrapper covers them all and cannot be forgotten by the next writer to arrive.
210
+ *
211
+ * It sits ABOVE the backend, so it is storage-independent (no backend implements anything) and profile-
212
+ * independent (the principal is read ambiently at write time, which is also what makes it the correct
213
+ * partition for visibility filtering). It observes writes made THROUGH it — a second process, or a
214
+ * writer that bypassed this store, is not seen; that is what a backend's own watch, where one exists,
215
+ * is for.
216
+ */
217
+ export function notifyingStore<T extends { id: string; version: string }>(
218
+ store: Store<T>, notifier: Notifier, namespace: string, source: string,
219
+ ): Store<T> {
220
+ const announce = (operation: 'saved' | 'deleted', id: string): void => {
221
+ const principal = tryCurrentPrincipal();
222
+ notifier.notify({
223
+ kind: ItemChangeKind, source, operation, namespace, id,
224
+ ...(principal !== undefined ? { principal } : {}),
225
+ });
226
+ };
227
+ return {
228
+ get: id => store.get(id),
229
+ query: q => store.query(q),
230
+ async set(id, value) { await store.set(id, value); announce('saved', id); },
231
+ async cas(id, expected, next) {
232
+ const res = await store.cas(id, expected, next);
233
+ if (res.ok) announce('saved', id);
234
+ return res;
235
+ },
236
+ async delete(id, expectedVersion) {
237
+ const deleted = await store.delete(id, expectedVersion);
238
+ if (deleted) announce('deleted', id);
239
+ return deleted;
240
+ },
241
+ };
242
+ }