@matatbread/matbot-core 0.4.15 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.15",
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.15"
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
- // Joined exactly as the registry joins it for the wire — one source, so the reported prompt and the
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
  };
@@ -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
+ }
@@ -86,6 +86,21 @@ function toProviderConfig(name: string, raw: YamlMap): ProviderConfig {
86
86
  return config;
87
87
  }
88
88
 
89
+ /**
90
+ * Parse a config document, optionally over a `base` one it `extends:`.
91
+ *
92
+ * **The merge is per TOP-LEVEL KEY, and the derived document REPLACES rather than extends.** A child
93
+ * declaring `plugins:` supplies the whole list — the base's entries are not appended — and the same
94
+ * holds for `providers:` and `default_settings:`. There is no deep merge and no per-entry merge: the
95
+ * unit is the section.
96
+ *
97
+ * That is deliberately stated rather than deliberately chosen. Section-replacement is what the one-line
98
+ * spread has always done, and a caller reading "extends" reasonably expects the other reading, so the
99
+ * semantics are pinned by test (config-extends.test.ts) — change them on purpose, not by editing this
100
+ * line. Note also that `extends:` does not today give you a shared base: the CLI chdirs to the base's
101
+ * directory and rewrites `configPath`, so the base becomes the project (`.data`, `.env`, and every yaml
102
+ * write land there). See CLAUDE.md, *Default plugin settings*.
103
+ */
89
104
  export function parseConfig(
90
105
  text: string,
91
106
  base?: string,
@@ -6,7 +6,7 @@
6
6
  * - Block sequences (- item), including an item whose value is an indented block under a bare `-`
7
7
  * - Scalar types: string, number, boolean, null
8
8
  * - Quoted strings (single and double), as values and as keys
9
- * - Comments (# ...)
9
+ * - Comments (# ...), outside quotes and at a line start or after whitespace (see stripComment)
10
10
  *
11
11
  * Does NOT support anchors, aliases, flow syntax, or ${NAME} expansion.
12
12
  * ${NAME} placeholders are left intact for the Vault to resolve.
@@ -36,11 +36,47 @@ interface Token {
36
36
  line: number;
37
37
  }
38
38
 
39
+ // Where a `#` opens a comment, per YAML's own rule: outside a quoted scalar, and at the start of a
40
+ // line or after whitespace. Stripping every `#` truncated values that legitimately contain one —
41
+ // `github:owner/repo#path:sub` lost its subdirectory, and a quoted value lost everything from the `#`
42
+ // on, INCLUDING its closing quote, so it then failed to unquote and kept a stray `"`. Both were silent.
43
+ //
44
+ // A quote only opens a quoted scalar where a scalar can start (line start, or after the `:`/`-` that
45
+ // introduces a value); elsewhere it is an apostrophe in a plain scalar (`title: Matt's config`), and
46
+ // treating it as an opening quote would swallow a real trailing comment.
47
+ //
48
+ // Still unhandled, as before: a `#` inside a block scalar (`|`/`>`) body, and a backslash-escaped quote
49
+ // — this parser does not process escapes anywhere (see `unquote`).
50
+ function stripComment(line: string): string {
51
+ let quote: string | undefined;
52
+ for (let i = 0; i < line.length; i++) {
53
+ const ch = line[i]!;
54
+ if (quote !== undefined) {
55
+ if (ch === quote) quote = undefined;
56
+ continue;
57
+ }
58
+ if ((ch === '"' || ch === "'") && opensScalar(line, i)) { quote = ch; continue; }
59
+ if (ch === '#' && (i === 0 || line[i - 1] === ' ' || line[i - 1] === '\t')) return line.slice(0, i);
60
+ }
61
+ return line;
62
+ }
63
+
64
+ // Is position `i` somewhere a scalar can begin — nothing but whitespace before it, or the `:`/`-` that
65
+ // introduces one? Keys count: `'@scope/pkg':` is a quoted scalar at line start.
66
+ function opensScalar(line: string, i: number): boolean {
67
+ for (let j = i - 1; j >= 0; j--) {
68
+ const ch = line[j]!;
69
+ if (ch === ' ' || ch === '\t') continue;
70
+ return ch === ':' || ch === '-';
71
+ }
72
+ return true;
73
+ }
74
+
39
75
  function tokenize(text: string): Token[] {
40
76
  const tokens: Token[] = [];
41
77
  const lines = text.split('\n');
42
78
  for (let n = 0; n < lines.length; n++) {
43
- const stripped = lines[n]!.replace(/#.*$/, '').trimEnd();
79
+ const stripped = stripComment(lines[n]!).trimEnd();
44
80
  if (stripped.trim() === '') continue;
45
81
  const indent = stripped.length - stripped.trimStart().length;
46
82
  tokens.push({ indent, raw: stripped.trimStart(), line: n + 1 });
package/src/index.ts CHANGED
@@ -46,10 +46,11 @@ export {
46
46
  contextSwitch, machineBusy, quiesced, onContextQuiesce, scheduleAtEdge,
47
47
  installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
48
48
  currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
49
+ scopeIterator, scopeIterable,
49
50
  } from '@matatbread/matbot-plugin-api/host';
50
51
  export type {
51
52
  Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
52
- UsageScope, Quiescer,
53
+ UsageScope, Quiescer, ScopeFn,
53
54
  } from '@matatbread/matbot-plugin-api/host';
54
55
  export * from './session.js';
55
56
  export * from './usage.js';
@@ -69,3 +70,4 @@ export * from './about.js';
69
70
  export * from './config/index.js';
70
71
  export * from './security/index.js';
71
72
  export * from './knowledge/index.js';
73
+ export * from './assemble.js';
@@ -124,9 +124,10 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
124
124
  }
125
125
 
126
126
  async search(
127
- terms: Array<{ term: string; context?: string }>,
128
- _signal: AbortSignal,
127
+ terms: Array<{ term: string; context?: string }>,
128
+ signal: AbortSignal,
129
129
  ): Promise<KnowledgeEntry[]> {
130
+ signal.throwIfAborted();
130
131
  if (terms.length === 0) return [];
131
132
 
132
133
  const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
package/src/loader.ts CHANGED
@@ -4,9 +4,10 @@ import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-pl
4
4
  import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin, announcePluginLoaded } from './registry.js';
5
5
 
6
6
  /**
7
- * The runtime this process is executing in. Detected the same way the rejected-import branch below
8
- * already distinguishes platforms (`typeof window`). Used only for the declarative pre-import gate;
9
- * an absent `matbotRuntime` declaration bypasses the gate entirely.
7
+ * The runtime this process is executing in — the one place this module decides what platform it is on.
8
+ * Read by the declarative pre-import gate (an absent `matbotRuntime` declaration bypasses it entirely)
9
+ * and by the import-rejection branch, which re-derived `typeof window` inline until the two could have
10
+ * disagreed.
10
11
  */
11
12
  const CURRENT_RUNTIME: Runtime = typeof window !== 'undefined' ? 'browser' : 'node';
12
13
 
@@ -30,6 +31,23 @@ const FRESH_PARAM = 'mbfresh';
30
31
  // produce the same stamp and re-hit the cache.
31
32
  let freshSeq = 0;
32
33
 
34
+ /**
35
+ * One entry to load, for a host that has already done its own resolution — the object form of a plain
36
+ * specifier string. Every field is documented on {@link loadPlugins}, which is the only consumer.
37
+ *
38
+ * Named, because it was written out twice: once in the parameter list and once again in the `as` cast
39
+ * of the line that normalises the strings. Two spellings of one shape, where adding a field to the
40
+ * wrong one narrows what the loader will read from a host that is already passing it.
41
+ */
42
+ export interface LoadRequest {
43
+ spec: string;
44
+ importSpec?: string;
45
+ name?: string;
46
+ version?: string;
47
+ runtimes?: readonly Runtime[];
48
+ notes?: readonly string[];
49
+ }
50
+
33
51
  /**
34
52
  * Load plugins from the given specifiers, register them, then run setup().
35
53
  *
@@ -100,13 +118,13 @@ let freshSeq = 0;
100
118
  * only as a confusing empty-result error downstream — fail loudly with the reason instead.
101
119
  */
102
120
  export async function loadPlugins(
103
- specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] })[],
121
+ specifiers: readonly (string | LoadRequest)[],
104
122
  services: MatbotMachine,
105
123
  bustCache = false,
106
124
  prompt?: PromptFn,
107
125
  onLoadError: 'skip' | 'throw' = 'skip',
108
126
  ): Promise<MatbotPlugin[]> {
109
- const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] });
127
+ const reqs: LoadRequest[] = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s));
110
128
 
111
129
  // A reason reaching a user, decorated with what the host noticed while resolving this spec. The
112
130
  // failure a load reports is usually downstream of its cause, and the host is the only layer that
@@ -200,7 +218,7 @@ export async function loadPlugins(
200
218
 
201
219
  if (result.status === 'rejected') {
202
220
  const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
203
- if (typeof window !== 'undefined') {
221
+ if (CURRENT_RUNTIME === 'browser') {
204
222
  // Browser: warn and skip — browser environments have no node_modules fallback.
205
223
  console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
206
224
  recordFailedPlugin({ specifier: spec, error: reason });
@@ -307,19 +325,6 @@ export async function loadPlugins(
307
325
  return loaded;
308
326
  }
309
327
 
310
- /**
311
- * Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh
312
- * evaluation (and, with the node resolve hook, a fresh subtree — see FRESH_PARAM).
313
- *
314
- * Diagnostics: cache-busting silently degrades in two ways that are
315
- * indistinguishable from "it worked" at the call site, so both are logged here.
316
- * 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve
317
- * relative to *this* module's URL) — we fall back to the bare spec, which
318
- * re-imports the *cached* module. No busting happens at all.
319
- * 2. Resolution succeeds but no resolve hook is installed: only the entry is
320
- * re-evaluated, while everything it statically imports stays cached. We can
321
- * detect (1) here; (2) is noted at the call site.
322
- */
323
328
  /**
324
329
  * Classify how a specifier resolves to code, from its shape alone (platform-neutral; no fs).
325
330
  * github shorthand is only recognised via the explicit `github:` prefix — a bare `owner/repo`
@@ -379,6 +384,18 @@ function stampFresh(url: string): string {
379
384
  return u.href;
380
385
  }
381
386
 
387
+ /**
388
+ * Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh evaluation (and, with
389
+ * the node resolve hook, a fresh subtree — see FRESH_PARAM).
390
+ *
391
+ * Diagnostics: cache-busting silently degrades in two ways that are indistinguishable from "it worked"
392
+ * at the call site, so both are logged.
393
+ * 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve relative to *this*
394
+ * module's URL) — we fall back to the bare spec, which re-imports the *cached* module. No busting
395
+ * happens at all. Detected here.
396
+ * 2. Resolution succeeds but no resolve hook is installed: only the entry is re-evaluated, while
397
+ * everything it statically imports stays cached. Noted at the call site.
398
+ */
382
399
  function toFreshUrl(spec: string): string {
383
400
  try {
384
401
  const fresh = stampFresh(import.meta.resolve(spec));
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
- * Same list and same reasoning as `workspace`'s `showArm`. The two stay separate because they answer
58
- * for separate stores; if a third ever needs it, this is the one to hoist.
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
 
@@ -151,8 +152,13 @@ export async function ingestMedia(
151
152
  }
152
153
 
153
154
  // Derived, not counted — see MAX_MEDIA_BYTES_PER_SESSION. One listing for the whole batch.
155
+ //
156
+ // Filtered by NAMESPACE as well as session: the media store is routinely the host's own file area, so
157
+ // a `sessionId`-tagged workspace file or a detached job's output shares the listing and was being
158
+ // charged to the media quota. The quota is over what THIS path stored, which is what the namespace
159
+ // names — and it is the same pair the per-ref ownership check below tests against.
154
160
  let sessionBytes = 0;
155
- for await (const held of store.list({ sessionId })) sessionBytes += held.size;
161
+ for await (const held of store.list({ sessionId, namespace: MEDIA_NAMESPACE })) sessionBytes += held.size;
156
162
 
157
163
  const out: MessageContent[] = [];
158
164
  for (const c of content) {