@matatbread/matbot-core 0.4.16 → 0.4.17
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 +2 -2
- package/src/about.ts +2 -3
- package/src/assemble.ts +378 -0
- package/src/index.ts +1 -0
- package/src/media.ts +3 -2
- package/src/registry.ts +31 -18
- package/src/runner.ts +2 -3
- package/src/settings.ts +1 -1
- package/src/system-context.ts +6 -3
- package/src/usage.ts +6 -3
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.17",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.17"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Tool, ToolExecutor, ToolContract, NoParams, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
|
|
3
3
|
} from '@matatbread/matbot-plugin-api';
|
|
4
|
+
import { joinSystemContext } from './system-context.js';
|
|
4
5
|
|
|
5
6
|
declare module '@matatbread/matbot-plugin-api' {
|
|
6
7
|
interface ToolContracts {
|
|
@@ -36,9 +37,7 @@ export function createAboutMatbotTool(version: string, services: MatbotMachine):
|
|
|
36
37
|
const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
|
|
37
38
|
async *execute(_input: unknown, ctx: ToolContext) {
|
|
38
39
|
const systemContext = await services.systemContext.parts({ session: ctx.session, signal: ctx.signal });
|
|
39
|
-
|
|
40
|
-
// sent prompt cannot differ in their separators.
|
|
41
|
-
const systemPrompt = systemContext.length > 0 ? systemContext.map(p => p.text).join('\n\n') : null;
|
|
40
|
+
const systemPrompt = joinSystemContext(systemContext);
|
|
42
41
|
yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider, systemPrompt, systemContext } };
|
|
43
42
|
},
|
|
44
43
|
};
|
package/src/assemble.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MatbotMachine, MatbotServices, MatbotPlugin, Store, Session, FileStore, StorageBackend, Vault, Notifier,
|
|
3
|
+
KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, Tool, Usage,
|
|
4
|
+
} from '@matatbread/matbot-plugin-api';
|
|
5
|
+
import {
|
|
6
|
+
forwardingProxy, makeSwappable, createMountTable, scheduleAtEdge, createNotifier, recordUsage, singleTurnRequest,
|
|
7
|
+
HookRegistry,
|
|
8
|
+
} from '@matatbread/matbot-plugin-api/host';
|
|
9
|
+
import type { SwapFn } from '@matatbread/matbot-plugin-api/host';
|
|
10
|
+
import { notifyingStore, createMessage } from '@matatbread/matbot-plugin-api';
|
|
11
|
+
import { mediumGuard } from './storage-base/medium-guard.js';
|
|
12
|
+
import { unifyServices } from './plugin.js';
|
|
13
|
+
import { instantiateProvider, recordServiceKey, getPluginNameForSpecifier, getRegisteredPlugins } from './registry.js';
|
|
14
|
+
import { installSettingsDefaults, installSettingsNotifier, makePluginSettings, settingsDefaultNamespaces } from './settings.js';
|
|
15
|
+
import type { SettingsDoc } from './settings.js';
|
|
16
|
+
import { ToolRegistryImpl } from './tool-registry.js';
|
|
17
|
+
import type { ToolInputValidator } from './tool-registry.js';
|
|
18
|
+
import type { ProviderRegistryImpl } from './provider-registry.js';
|
|
19
|
+
import { SystemContextRegistryImpl } from './system-context.js';
|
|
20
|
+
import { LookupKnowledgeIndex } from './knowledge/index.js';
|
|
21
|
+
import { createSessionRunner } from './session-runner.js';
|
|
22
|
+
import { createSingleTurnTool } from './single-turn.js';
|
|
23
|
+
import { createAboutMatbotTool } from './about.js';
|
|
24
|
+
import { addUsage } from './usage.js';
|
|
25
|
+
|
|
26
|
+
/** A storage backend a configured plugin's manifest supplied at boot, and the config entry it came from. */
|
|
27
|
+
export interface PreScannedStorage {
|
|
28
|
+
backend: StorageBackend;
|
|
29
|
+
spec: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open the first configured plugin's manifest `storageBackend`, before any store exists. Import failures
|
|
34
|
+
* are left for the loader to report. Node caches the imported modules, so the later load is free.
|
|
35
|
+
*/
|
|
36
|
+
export async function preScanStorage(
|
|
37
|
+
specs: readonly { spec: string; importSpec: string }[],
|
|
38
|
+
dir: string,
|
|
39
|
+
): Promise<PreScannedStorage | undefined> {
|
|
40
|
+
for (const { spec, importSpec } of specs) {
|
|
41
|
+
try {
|
|
42
|
+
const mod = await import(/* @vite-ignore */ importSpec) as Record<string, unknown>;
|
|
43
|
+
const plug = (mod['plugin'] ?? (mod['default'] as Record<string, unknown> | undefined)?.['plugin']) as MatbotPlugin | undefined;
|
|
44
|
+
if (plug?.storageBackend !== undefined) return { backend: await plug.storageBackend.open(dir), spec };
|
|
45
|
+
} catch { /* the loader surfaces it */ }
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The host's default permission policy, and the tools that inspect it (`@matatbread/matbot-default-gate`). */
|
|
51
|
+
export interface HostGate {
|
|
52
|
+
namespace: string;
|
|
53
|
+
create(settings: PluginSettings): PermissionGate;
|
|
54
|
+
tools(settings: PluginSettings): Tool[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The machine members only a host can supply. */
|
|
58
|
+
export type HostMembers =
|
|
59
|
+
Pick<MatbotMachine, 'loadPlugin' | 'unloadPlugin' | 'resolver' | 'isSubAgent' | 'TypeScriptStripper' | 'workdir' | 'configPath'>;
|
|
60
|
+
|
|
61
|
+
export interface AssembleOptions {
|
|
62
|
+
/** The host's own storage: the revert target when a plugin's StorageBackend is unregistered. */
|
|
63
|
+
bootBackend: StorageBackend;
|
|
64
|
+
preScanned?: PreScannedStorage | undefined;
|
|
65
|
+
/** The boot vault, behind a swappable proxy like every other swap-member. */
|
|
66
|
+
vault: Vault;
|
|
67
|
+
providers: ProviderRegistryImpl;
|
|
68
|
+
gate: HostGate;
|
|
69
|
+
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined;
|
|
70
|
+
/** Registry values present from boot, which an unregister reverts to rather than deleting. */
|
|
71
|
+
seed?: Partial<MatbotServices>;
|
|
72
|
+
builtinTools?: Tool[];
|
|
73
|
+
/** Resolve one `${NAME}` reference; the browser prompts for a missing secret. Defaults to the vault. */
|
|
74
|
+
resolveSecret?(ref: string, vault: Vault): Promise<string>;
|
|
75
|
+
/** Reported by `about_matbot`. */
|
|
76
|
+
version: string;
|
|
77
|
+
/** Called with a late-bound handle, since these members call back into the machine being built. */
|
|
78
|
+
host(machine: () => MatbotMachine): HostMembers;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AssembledMachine {
|
|
82
|
+
services: MatbotMachine;
|
|
83
|
+
/** A session runner over any store — the shared one is `services.run`. */
|
|
84
|
+
makeRunner(store: Store<Session>): SessionRunner;
|
|
85
|
+
/** Call once the configured plugins have loaded. */
|
|
86
|
+
loaded(): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NEVER_ABORT = new AbortController().signal;
|
|
90
|
+
type AnyStore = Store<{ id: string; version: string }>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stand a machine up: the swappable services and their boot defaults, the stores, the registries, the
|
|
94
|
+
* deferred storage swap and mount table, the session runner and the core tools. The one expression of
|
|
95
|
+
* the boot graph, so a step added here reaches every host. What differs by platform — carriers, where
|
|
96
|
+
* storage and secrets live, how a plugin is acquired — is the host's to pass in.
|
|
97
|
+
*
|
|
98
|
+
* The host installs its principal and usage carriers first: nothing here reads an identity, but the
|
|
99
|
+
* runner and every plugin will.
|
|
100
|
+
*/
|
|
101
|
+
export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
102
|
+
const { bootBackend, preScanned, providers } = opts;
|
|
103
|
+
installSettingsDefaults(opts.defaultSettings);
|
|
104
|
+
|
|
105
|
+
// Swap-members sit behind capture-safe proxies, so a captured reference — including a destructure
|
|
106
|
+
// like `const { KnowledgeIndex, StorageBackend } = services` — follows a register()-driven swap.
|
|
107
|
+
let activeVault = opts.vault;
|
|
108
|
+
const vault = forwardingProxy<Vault>(() => activeVault);
|
|
109
|
+
let activeNotifier: Notifier = createNotifier('core');
|
|
110
|
+
const notifier = forwardingProxy<Notifier>(() => activeNotifier);
|
|
111
|
+
// Settings writes announce on the same bus. The proxy, not the impl, so a registered distributed
|
|
112
|
+
// Notifier relays them too.
|
|
113
|
+
installSettingsNotifier(notifier);
|
|
114
|
+
let knowledgeImpl: KnowledgeIndex = new LookupKnowledgeIndex();
|
|
115
|
+
const knowledge = forwardingProxy<KnowledgeIndex>(() => knowledgeImpl);
|
|
116
|
+
|
|
117
|
+
// ── Storage ───────────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
let activeBackend: StorageBackend = preScanned?.backend ?? bootBackend;
|
|
119
|
+
// Held only to say so, once, when a plugin displaces it — see swapStorage.
|
|
120
|
+
let preScanBackend = preScanned?.backend;
|
|
121
|
+
const backend = forwardingProxy<StorageBackend>(() => activeBackend);
|
|
122
|
+
|
|
123
|
+
const storeProxies = new Map<string, [AnyStore, SwapFn<AnyStore>]>();
|
|
124
|
+
// Bumped by every swap, and read by the medium guard. A document handed out under one generation
|
|
125
|
+
// cannot be written back under another: nothing is migrated between backends, so that write would
|
|
126
|
+
// move it, silently, into a backend that never issued it.
|
|
127
|
+
let storageGeneration = 0;
|
|
128
|
+
const guarded = new Map<string, AnyStore>();
|
|
129
|
+
const createStore = <T extends { id: string; version: string }>(namespace: string): Store<T> => {
|
|
130
|
+
let entry = storeProxies.get(namespace);
|
|
131
|
+
if (entry === undefined) {
|
|
132
|
+
entry = makeSwappable<AnyStore>(activeBackend.createStore(namespace));
|
|
133
|
+
storeProxies.set(namespace, entry);
|
|
134
|
+
}
|
|
135
|
+
// Guard OUTSIDE the swap proxy: one stable object per namespace across every swap, so a captured
|
|
136
|
+
// store reference keeps working and the guard sees each call whichever backend is current.
|
|
137
|
+
let store = guarded.get(namespace);
|
|
138
|
+
if (store === undefined) {
|
|
139
|
+
store = mediumGuard<{ id: string; version: string }>(entry[0], () => storageGeneration, namespace);
|
|
140
|
+
guarded.set(namespace, store);
|
|
141
|
+
}
|
|
142
|
+
return store as Store<T>;
|
|
143
|
+
};
|
|
144
|
+
const [fileStore, swapFiles] = makeSwappable<FileStore>(activeBackend.fileStore);
|
|
145
|
+
// Every session write announces itself: the namespace has many writers (the turn pump, session_action,
|
|
146
|
+
// session_edit, a session minted over HTTP), and one wrapper covers them all, including the next.
|
|
147
|
+
const sessions = notifyingStore(createStore<Session>('sessions'), notifier, 'sessions', 'session');
|
|
148
|
+
|
|
149
|
+
// Re-point every store proxy and the file proxy at `next`. Synchronous, so readers see `next` at once;
|
|
150
|
+
// the displaced backend is closed in the background, because a slow or throwing close() (node:sqlite's
|
|
151
|
+
// rejecting on a still-open statement) must never gate the swap or suppress its mount notification.
|
|
152
|
+
// Driven only from the quiescent edge, never mid-turn.
|
|
153
|
+
const swapStorage = (next: StorageBackend): boolean => {
|
|
154
|
+
const removed = activeBackend;
|
|
155
|
+
if (removed === next) return false;
|
|
156
|
+
// Only ONE backend is ever active, and a plugin registering one displaces whatever the boot pre-scan
|
|
157
|
+
// opened — silently, since the swap is supported and the loser leaves nothing but an orphaned file.
|
|
158
|
+
// Said once, naming the plugin, at the moment it stops being true.
|
|
159
|
+
if (removed === preScanBackend && next !== bootBackend) {
|
|
160
|
+
preScanBackend = undefined;
|
|
161
|
+
console.warn(
|
|
162
|
+
`[matbot] storage: the backend opened at startup by "${preScanned?.spec}" has been replaced by another ` +
|
|
163
|
+
`plugin's storage backend. Only one is ever active and nothing is migrated between them, so anything ` +
|
|
164
|
+
`it already wrote is no longer read. Configure just one storage plugin to remove this ambiguity.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
activeBackend = next;
|
|
168
|
+
storageGeneration++;
|
|
169
|
+
for (const [ns, [, swap]] of storeProxies) swap(next.createStore(ns));
|
|
170
|
+
swapFiles(next.fileStore);
|
|
171
|
+
void Promise.resolve(removed.close?.()).catch(e => console.error('[matbot] closing displaced StorageBackend:', e));
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ── Registries ────────────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// The host's file area doubles as the media store, so attachments work with no plugin. Seeded into the
|
|
177
|
+
// registry rather than spelled on the base object, because `unifyServices` resolves an own property
|
|
178
|
+
// first — a member spelled there is one `register()` could never reach.
|
|
179
|
+
const seed: Partial<MatbotServices> = { MediaStore: fileStore, ...opts.seed };
|
|
180
|
+
const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
|
|
181
|
+
// The validator lookup is late-bound and read per call: one is registered by a plugin long after the
|
|
182
|
+
// builtins are seeded, and may be unloaded again. It wraps the executor, so every door — the runner,
|
|
183
|
+
// `POST /tools/:name`, `invokeTool` — is covered by one wrapper rather than a hook on the model's path.
|
|
184
|
+
const tools = new ToolRegistryImpl(opts.builtinTools, notifier,
|
|
185
|
+
() => serviceRegistry.get('ToolCallValidator') as ToolInputValidator | undefined);
|
|
186
|
+
const hooks = new HookRegistry();
|
|
187
|
+
const systemContext = new SystemContextRegistryImpl();
|
|
188
|
+
|
|
189
|
+
// The installation's permission policy. Deliberately NOT behind a forwardingProxy: the documented way
|
|
190
|
+
// to replace a policy is to capture the one displaced and delegate to it, and a capture-safe proxy
|
|
191
|
+
// would make that capture resolve to the capturing gate itself — which then calls itself until the
|
|
192
|
+
// stack overflows. A getter keeps the member read late-bound; every consumer resolves it per call.
|
|
193
|
+
const gateSettings = makePluginSettings(createStore<SettingsDoc>('settings'), opts.gate.namespace);
|
|
194
|
+
let activeGate = opts.gate.create(gateSettings);
|
|
195
|
+
|
|
196
|
+
const boot = { vault: activeVault, knowledge: knowledgeImpl, notifier: activeNotifier, gate: activeGate };
|
|
197
|
+
|
|
198
|
+
// ── The quiescent edge ────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
// A StorageBackend swap waits for it, since swapping the system of record under a running turn would
|
|
200
|
+
// split a compare-and-swap across two backends. The mount table batches mount notifications to it:
|
|
201
|
+
// register/unregister mark a key dirty, and the edge multicasts each key's net transition, so a
|
|
202
|
+
// reload within one turn collapses to a single remount.
|
|
203
|
+
const mountTable = createMountTable(() => services);
|
|
204
|
+
// A last-write-wins slot read at fire time, so three registers before an edge install one backend. One
|
|
205
|
+
// callback also orders the swap ahead of the mount flush, so the remount it marks lands in that edge.
|
|
206
|
+
let pendingSwap: StorageBackend | undefined;
|
|
207
|
+
const scheduleEdge = scheduleAtEdge(() => {
|
|
208
|
+
if (pendingSwap !== undefined) {
|
|
209
|
+
const next = pendingSwap;
|
|
210
|
+
pendingSwap = undefined;
|
|
211
|
+
if (swapStorage(next)) mountTable.markDirty('StorageBackend');
|
|
212
|
+
}
|
|
213
|
+
mountTable.flush();
|
|
214
|
+
});
|
|
215
|
+
const stageSwap = (next: StorageBackend): void => { pendingSwap = next; scheduleEdge(); };
|
|
216
|
+
const swapKnowledge = (next: KnowledgeIndex): void => {
|
|
217
|
+
const prev = knowledgeImpl;
|
|
218
|
+
if (prev === next) return;
|
|
219
|
+
knowledgeImpl = next;
|
|
220
|
+
if (prev.entries !== undefined) for (const e of prev.entries()) void next.index(e);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const resolveSecret = (ref: string): Promise<string> => opts.resolveSecret?.(ref, vault) ?? vault.resolve(ref);
|
|
224
|
+
const resolveConfig = async (cfg: ProviderConfig): Promise<ProviderConfig> => {
|
|
225
|
+
let credentials: Record<string, string> | undefined;
|
|
226
|
+
if (cfg.credentials !== undefined) {
|
|
227
|
+
credentials = {};
|
|
228
|
+
for (const [k, v] of Object.entries(cfg.credentials)) credentials[k] = await resolveSecret(v);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
...cfg,
|
|
232
|
+
...(credentials !== undefined ? { credentials } : {}),
|
|
233
|
+
...(cfg.endpoint !== undefined ? { endpoint: await resolveSecret(cfg.endpoint) } : {}),
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let runner: SessionRunner | undefined;
|
|
238
|
+
|
|
239
|
+
const baseServices: MatbotMachine = {
|
|
240
|
+
// Plugins always receive the plugin-scoped override built in setupPlugin; the base is never called.
|
|
241
|
+
settings(): PluginSettings {
|
|
242
|
+
throw new Error('settings() is only available within a plugin scope (use the services passed to setup()).');
|
|
243
|
+
},
|
|
244
|
+
createStore,
|
|
245
|
+
get(key) { return serviceRegistry.get(key as string) as never; },
|
|
246
|
+
// StorageBackend is staged for the quiescent edge, which marks its mount dirty once the swap lands;
|
|
247
|
+
// the other swap-keys repoint at once and mark dirty for the edge to multicast.
|
|
248
|
+
async register(key, value) {
|
|
249
|
+
if (key === 'StorageBackend') stageSwap(value as StorageBackend);
|
|
250
|
+
else if (key === 'KnowledgeIndex') swapKnowledge(value as KnowledgeIndex);
|
|
251
|
+
else if (key === 'Vault') activeVault = value as Vault;
|
|
252
|
+
else if (key === 'Notifier') activeNotifier = value as Notifier;
|
|
253
|
+
else if (key === 'PermissionGate') activeGate = value as PermissionGate;
|
|
254
|
+
else serviceRegistry.set(key as string, value);
|
|
255
|
+
if (key !== 'StorageBackend') { mountTable.markDirty(key); scheduleEdge(); }
|
|
256
|
+
},
|
|
257
|
+
// Symmetric with register: a swap-key or seeded key reverts to its boot value rather than dangling on
|
|
258
|
+
// the unloaded plugin's impl — unloading a plugin that put media on S3 leaves attachments working on
|
|
259
|
+
// disk, not off until a restart. Anything else is deleted.
|
|
260
|
+
unregister(key: string) {
|
|
261
|
+
if (key === 'StorageBackend') stageSwap(bootBackend);
|
|
262
|
+
else if (key === 'KnowledgeIndex') knowledgeImpl = boot.knowledge;
|
|
263
|
+
else if (key === 'Vault') activeVault = boot.vault;
|
|
264
|
+
else if (key === 'Notifier') activeNotifier = boot.notifier;
|
|
265
|
+
else if (key === 'PermissionGate') activeGate = boot.gate;
|
|
266
|
+
else if (key in seed) serviceRegistry.set(key, seed[key as keyof MatbotServices]);
|
|
267
|
+
else serviceRegistry.delete(key);
|
|
268
|
+
if (key !== 'StorageBackend') { mountTable.markDirty(key as keyof MatbotServices); scheduleEdge(); }
|
|
269
|
+
},
|
|
270
|
+
registerFrontend() { /* bound per plugin in setupPlugin's scope */ },
|
|
271
|
+
|
|
272
|
+
async complete(req) {
|
|
273
|
+
const raw = providers.get(req.provider);
|
|
274
|
+
if (raw === undefined) throw new Error(`complete(): unknown provider "${req.provider}". Available: ${[...providers.keys()].join(', ')}`);
|
|
275
|
+
const resolved = await resolveConfig({
|
|
276
|
+
...raw,
|
|
277
|
+
// Per-call overrides shallow-merged over the config's own parameters (request wins).
|
|
278
|
+
...(req.parameters !== undefined ? { parameters: { ...raw.parameters, ...req.parameters } } : {}),
|
|
279
|
+
});
|
|
280
|
+
const adapter = await instantiateProvider(services, resolved);
|
|
281
|
+
if (adapter === null) throw new Error(`complete(): provider "${req.provider}" has no loadable adapter (module "${resolved.module}").`);
|
|
282
|
+
const messages = req.system !== undefined
|
|
283
|
+
? [createMessage({ role: 'system', content: [{ type: 'text', text: req.system }], traceId: crypto.randomUUID() }), ...req.messages]
|
|
284
|
+
: req.messages;
|
|
285
|
+
let text = '';
|
|
286
|
+
// Folded as the runner folds a turn's usage, because an adapter may report one call in several
|
|
287
|
+
// parts: anthropic sends input + cache counts on `message_start` and output on `message_delta`.
|
|
288
|
+
let usage: Usage = { inputTokens: 0, outputTokens: 0 };
|
|
289
|
+
const startedAt = Date.now();
|
|
290
|
+
for await (const ev of adapter.complete(messages, resolved, [], req.signal ?? NEVER_ABORT)) {
|
|
291
|
+
if (ev.type === 'text-delta') text += ev.delta;
|
|
292
|
+
if (ev.type === 'usage') usage = addUsage(usage, ev);
|
|
293
|
+
}
|
|
294
|
+
// Into the ambient usage sink, so a tool running this completion has its spend attributed to the
|
|
295
|
+
// site in force. Measured here because this is where the call is issued.
|
|
296
|
+
recordUsage(req.provider, usage, { startedAt: new Date(startedAt).toISOString(), durationMs: Date.now() - startedAt });
|
|
297
|
+
return { text, usage };
|
|
298
|
+
},
|
|
299
|
+
async singleTurn(req) {
|
|
300
|
+
return this.complete(singleTurnRequest(req));
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
...opts.host(() => services),
|
|
304
|
+
providers,
|
|
305
|
+
mounted: mountTable.mounted,
|
|
306
|
+
sessions,
|
|
307
|
+
files: fileStore,
|
|
308
|
+
get run() { return runner; },
|
|
309
|
+
hooks,
|
|
310
|
+
tools,
|
|
311
|
+
systemContext,
|
|
312
|
+
StorageBackend: backend,
|
|
313
|
+
Vault: vault,
|
|
314
|
+
Notifier: notifier,
|
|
315
|
+
get PermissionGate() { return activeGate; },
|
|
316
|
+
get KnowledgeIndex() { return knowledge; },
|
|
317
|
+
};
|
|
318
|
+
const services: MatbotMachine = unifyServices(baseServices);
|
|
319
|
+
|
|
320
|
+
// Resolved per turn, so it sees live `provider` edits. instantiateProvider force-loads an adapter that
|
|
321
|
+
// is not registered yet, and yields null rather than throwing when it cannot.
|
|
322
|
+
const resolveProvider = async (name: string): Promise<{ adapter: ProviderAdapter; config: ProviderConfig } | null> => {
|
|
323
|
+
const cfg = providers.get(name);
|
|
324
|
+
if (cfg === undefined) return null;
|
|
325
|
+
const config = await resolveConfig(cfg);
|
|
326
|
+
const adapter = await instantiateProvider(services, config);
|
|
327
|
+
return adapter === null ? null : { adapter, config };
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Services a plugin registers after boot are resolved live, per turn.
|
|
331
|
+
const makeRunner = (store: Store<Session>): SessionRunner => createSessionRunner({
|
|
332
|
+
store,
|
|
333
|
+
resolveProvider,
|
|
334
|
+
tools,
|
|
335
|
+
hooks,
|
|
336
|
+
systemContext,
|
|
337
|
+
vault,
|
|
338
|
+
files: fileStore,
|
|
339
|
+
toolTypeIndex: () => services.ToolTypeIndex,
|
|
340
|
+
toolPresenter: () => services.ToolPresenter,
|
|
341
|
+
steeringPolicy: () => services.SteeringPolicy,
|
|
342
|
+
mediaStore: () => services.MediaStore,
|
|
343
|
+
permissionGate: () => services.PermissionGate,
|
|
344
|
+
...(services.workdir !== undefined ? { workdir: services.workdir } : {}),
|
|
345
|
+
...(services.configPath !== undefined ? { configPath: services.configPath } : {}),
|
|
346
|
+
loadPlugin: services.loadPlugin.bind(services),
|
|
347
|
+
unloadPlugin: services.unloadPlugin.bind(services),
|
|
348
|
+
});
|
|
349
|
+
runner = makeRunner(sessions);
|
|
350
|
+
|
|
351
|
+
// Seeded rather than carried by configured plugins: `gate_action` because a minimal install's first act
|
|
352
|
+
// (adding a plugin or provider) is gated, so inspecting an answer cannot depend on a config line.
|
|
353
|
+
for (const tool of opts.gate.tools(gateSettings)) tools.register(tool);
|
|
354
|
+
tools.register(createSingleTurnTool(services));
|
|
355
|
+
// The harness is not a plugin (no `plugin list` row), so its version gets a tool of its own.
|
|
356
|
+
tools.register(createAboutMatbotTool(opts.version, services));
|
|
357
|
+
|
|
358
|
+
const loaded = (): void => {
|
|
359
|
+
// A defaults key is a plugin NAME — loader-derived, not necessarily what `plugins:` spells. One
|
|
360
|
+
// naming nothing loaded is the feature's one silent failure. The gate's namespace is seeded by the
|
|
361
|
+
// host rather than a plugin, and reserved dunder namespaces are not plugins; both are exempt.
|
|
362
|
+
const names = new Set(getRegisteredPlugins().map(p => p.name));
|
|
363
|
+
for (const ns of settingsDefaultNamespaces()) {
|
|
364
|
+
if (names.has(ns) || ns === opts.gate.namespace || (ns.startsWith('__') && ns.endsWith('__'))) continue;
|
|
365
|
+
console.warn(
|
|
366
|
+
`[matbot] default settings name "${ns}", which is not a loaded plugin — they apply only once a plugin ` +
|
|
367
|
+
`of that name loads. Key them by the plugin's package name (\`plugin list\` reports them).`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
// The pre-scan opened a manifest backend before the loader knew the plugin's name, bypassing the
|
|
371
|
+
// scoped register() that attributes a service key. Attributing it now makes unloading that plugin
|
|
372
|
+
// revert storage to the host base, exactly as for a runtime register().
|
|
373
|
+
const name = preScanned !== undefined ? getPluginNameForSpecifier(preScanned.spec) : undefined;
|
|
374
|
+
if (name !== undefined) recordServiceKey(name, 'StorageBackend');
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
return { services, makeRunner, loaded };
|
|
378
|
+
}
|
package/src/index.ts
CHANGED
package/src/media.ts
CHANGED
|
@@ -54,8 +54,9 @@ const INLINE_ARMS = new Set(['image', 'document', 'audio']);
|
|
|
54
54
|
* an arm that will fail: by the time the 400 arrives the `file-ref` is in persisted history, where it
|
|
55
55
|
* resolves into every subsequent outgoing copy and fails the session for good.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* `workspace`'s `showArm` deliberately admits more (any `image/*`): its media is wire-only and dies with
|
|
58
|
+
* the turn, so a type the endpoint rejects costs one turn there, where here it costs the session. Do not
|
|
59
|
+
* unify the two.
|
|
59
60
|
*/
|
|
60
61
|
const DECODABLE_IMAGE: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
61
62
|
|
package/src/registry.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type { SettingsDoc } from './settings.js';
|
|
|
14
14
|
// `FailedPlugin` now lives in plugin-api: `plugin list` reports it, so it is part of that tool's
|
|
15
15
|
// contract, and the contract has to be declarable from a package this one can reach.
|
|
16
16
|
|
|
17
|
-
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
18
17
|
const state = {
|
|
19
18
|
plugins: [] as MatbotPlugin[],
|
|
20
19
|
providers: new Map<string, ProviderAdapterFactory>(),
|
|
@@ -370,11 +369,12 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
370
369
|
build: (ctx) => services.systemContext.build(ctx),
|
|
371
370
|
parts: (ctx) => services.systemContext.parts(ctx),
|
|
372
371
|
},
|
|
372
|
+
// Attributed only once the registration has succeeded: a key recorded for a register() that threw
|
|
373
|
+
// would make unload unregister a service this plugin never held — for a swap-member, reverting
|
|
374
|
+
// someone else's.
|
|
373
375
|
async register(key, svc) {
|
|
374
|
-
const keys = state.serviceKeys.get(plugin.name) ?? [];
|
|
375
|
-
keys.push(key as string);
|
|
376
|
-
state.serviceKeys.set(plugin.name, keys);
|
|
377
376
|
await services.register(key, svc);
|
|
377
|
+
recordServiceKey(plugin.name, key as string);
|
|
378
378
|
},
|
|
379
379
|
registerFrontend(info) {
|
|
380
380
|
state.frontendPlugins.set(plugin.name, info);
|
|
@@ -416,33 +416,46 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
416
416
|
|
|
417
417
|
state.plugins.splice(idx, 1);
|
|
418
418
|
services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
419
|
+
await boundedTeardown(plugin);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** How long one plugin's teardown() may take before it is abandoned. */
|
|
424
|
+
const TEARDOWN_TIMEOUT_MS = 10_000;
|
|
425
|
+
|
|
426
|
+
// The one teardown both paths use, so a runtime unload and process exit cannot drift apart on the budget.
|
|
427
|
+
// The timer is cleared however the race settles: left dangling it outlived every SUCCESSFUL teardown,
|
|
428
|
+
// holding the event loop open for the rest of its budget — so `plugin unload` followed by exit sat there
|
|
429
|
+
// with nothing to wait for.
|
|
430
|
+
async function boundedTeardown(plugin: MatbotPlugin): Promise<void> {
|
|
422
431
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
423
432
|
try {
|
|
424
433
|
await Promise.race([
|
|
425
434
|
plugin.teardown?.(),
|
|
426
435
|
new Promise<void>((_, reject) => {
|
|
427
|
-
timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${
|
|
436
|
+
timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${plugin.name}`)), TEARDOWN_TIMEOUT_MS);
|
|
428
437
|
}),
|
|
429
438
|
]);
|
|
430
439
|
} finally {
|
|
431
440
|
if (timer !== undefined) clearTimeout(timer);
|
|
432
441
|
}
|
|
433
|
-
return true;
|
|
434
442
|
}
|
|
435
443
|
|
|
436
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* Run each plugin's teardown() in reverse-registration order, one at a time, each within the same budget
|
|
446
|
+
* as {@link unloadPlugin}. Errors and timeouts are logged, not thrown, and do not stop the rest.
|
|
447
|
+
*
|
|
448
|
+
* Sequential and reversed because a plugin loaded later may depend on a service an earlier one provides,
|
|
449
|
+
* and must be able to flush through it before it closes. Bounded because a sequential loop is otherwise
|
|
450
|
+
* held hostage by one teardown that never settles — and this runs on the way to process exit.
|
|
451
|
+
*/
|
|
437
452
|
export async function teardownPlugins(): Promise<void> {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (result.status === 'rejected') {
|
|
444
|
-
console.error(`[matbot] teardown error in plugin "${ordered[i]?.name}":`, result.reason);
|
|
453
|
+
for (const plugin of [...state.plugins].reverse()) {
|
|
454
|
+
try {
|
|
455
|
+
await boundedTeardown(plugin);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error(`[matbot] teardown error in plugin "${plugin.name}":`, err);
|
|
445
458
|
}
|
|
446
|
-
}
|
|
459
|
+
}
|
|
447
460
|
}
|
|
448
461
|
|
package/src/runner.ts
CHANGED
|
@@ -18,9 +18,8 @@ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
|
18
18
|
// context, losing any scope entered around the loop body. Scoping each `next()` instead means the
|
|
19
19
|
// tool's body — and anything it kicks off while running — always resumes under its own site.
|
|
20
20
|
//
|
|
21
|
-
// `scopeIterable` is the
|
|
22
|
-
//
|
|
23
|
-
// iterator's own members, prototype and `instanceof`.
|
|
21
|
+
// `scopeIterable` is the per-pull wrap `runAs` also uses for the principal. It wraps the iterator rather
|
|
22
|
+
// than replacing it, so a class-based tool iterator keeps its own members, prototype and `instanceof`.
|
|
24
23
|
const siteScoped = <T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> =>
|
|
25
24
|
scopeIterable(src, f => withUsageSite(site, f));
|
|
26
25
|
|
package/src/settings.ts
CHANGED
|
@@ -148,7 +148,7 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
|
|
|
148
148
|
const doc = await getDoc();
|
|
149
149
|
const data = mutate(doc?.data ?? {});
|
|
150
150
|
if (data === undefined) return;
|
|
151
|
-
const next: SettingsDoc = { id, version:
|
|
151
|
+
const next: SettingsDoc = { id, version: crypto.randomUUID(), data };
|
|
152
152
|
if (doc === null) { await store.set(id, next); return; }
|
|
153
153
|
const r = await store.cas(id, doc.version, next);
|
|
154
154
|
if (r.ok) return;
|
package/src/system-context.ts
CHANGED
|
@@ -5,6 +5,10 @@ interface TaggedContributor {
|
|
|
5
5
|
pluginName?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** The system prompt as sent: the parts joined, or null when there are none. */
|
|
9
|
+
export const joinSystemContext = (parts: readonly SystemContextPart[]): string | null =>
|
|
10
|
+
parts.length > 0 ? parts.map(p => p.text).join('\n\n') : null;
|
|
11
|
+
|
|
8
12
|
export class SystemContextRegistryImpl implements SystemContextRegistry {
|
|
9
13
|
private readonly _contributors: TaggedContributor[] = [];
|
|
10
14
|
|
|
@@ -32,9 +36,8 @@ export class SystemContextRegistryImpl implements SystemContextRegistry {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// The joined form is derived from the attributed one, so the prompt the model receives and the
|
|
35
|
-
// breakdown `about_matbot` reports cannot drift: there is one traversal and one
|
|
39
|
+
// breakdown `about_matbot` reports cannot drift: there is one traversal, one filter and one join.
|
|
36
40
|
async build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null> {
|
|
37
|
-
|
|
38
|
-
return parts.length > 0 ? parts.map(p => p.text).join('\n\n') : null;
|
|
41
|
+
return joinSystemContext(await this.parts(ctx));
|
|
39
42
|
}
|
|
40
43
|
}
|
package/src/usage.ts
CHANGED
|
@@ -12,12 +12,15 @@ export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
|
12
12
|
const a = acc ?? { inputTokens: 0, outputTokens: 0 };
|
|
13
13
|
const add = (x: number | undefined, y: number | undefined): number | undefined =>
|
|
14
14
|
x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
|
|
15
|
+
const cacheReadTokens = add(a.cacheReadTokens, next.cacheReadTokens);
|
|
16
|
+
const cacheCreationTokens = add(a.cacheCreationTokens, next.cacheCreationTokens);
|
|
17
|
+
const reported = addReported(a.reported, next.reported);
|
|
15
18
|
return {
|
|
16
19
|
inputTokens: a.inputTokens + next.inputTokens,
|
|
17
20
|
outputTokens: a.outputTokens + next.outputTokens,
|
|
18
|
-
...(
|
|
19
|
-
...(
|
|
20
|
-
...(
|
|
21
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
22
|
+
...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
|
|
23
|
+
...(reported !== undefined ? { reported } : {}),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
|