@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 +3 -2
- package/src/hooks.ts +6 -6
- package/src/host-machine.ts +191 -0
- package/src/host.ts +43 -0
- package/src/index.ts +27 -8
- package/src/invoke-tool.ts +25 -3
- package/src/notify.ts +242 -0
- package/src/plugin.ts +40 -205
- package/src/session.ts +30 -1
- package/src/store-query.ts +12 -0
- package/src/types/events.ts +81 -0
- package/src/types/files.ts +46 -0
- package/src/types/frontend.ts +11 -0
- package/src/types/health.ts +6 -0
- package/src/types/hooks.ts +217 -0
- package/src/types/knowledge.ts +27 -0
- package/src/types/messages.ts +154 -0
- package/src/types/primitives.ts +5 -0
- package/src/types/principal.ts +10 -0
- package/src/types/provider.ts +157 -0
- package/src/types/registries.ts +84 -0
- package/src/types/session-runner.ts +69 -0
- package/src/types/steering.ts +33 -0
- package/src/types/storage.ts +16 -0
- package/src/types/system-context.ts +15 -0
- package/src/types/tools.ts +262 -0
- package/src/types/typescript.ts +50 -0
- package/src/types/vault.ts +37 -0
- package/src/types.ts +27 -1101
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.
|
|
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
|
|
115
|
-
if (
|
|
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 =
|
|
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 './
|
|
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';
|
package/src/invoke-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|