@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 +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 -9
- package/src/invoke-tool.ts +25 -3
- package/src/notify.ts +3 -0
- package/src/plugin.ts +28 -195
- package/src/session.ts +30 -1
- 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 -1089
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,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 './
|
|
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';
|
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
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
106
|
-
* optional and simply drops. Read each as
|
|
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:
|
|
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
|
|
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
|
|