@matatbread/matbot-core 0.3.0 → 0.3.2
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 +8 -6
- package/src/loader.ts +14 -3
- package/src/registry.ts +32 -0
- package/src/runner.ts +16 -1
- package/src/session-runner.ts +13 -2
- package/src/storage-base/query/execute.ts +7 -1
- package/src/storage-base/query/validate.ts +14 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.
|
|
7
|
+
"version": "0.3.2",
|
|
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.3.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.3.2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext } from
|
|
|
2
2
|
|
|
3
3
|
declare module '@matatbread/matbot-plugin-api' {
|
|
4
4
|
interface ToolContracts {
|
|
5
|
-
about_matbot: ToolContract<{ version: string; about: string }, Record<string, never>>;
|
|
5
|
+
about_matbot: ToolContract<{ version: string; about: string, currentProvider: string | undefined }, Record<string, never>>;
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -17,17 +17,19 @@ const ABOUT = 'Matbot composable LLM harness';
|
|
|
17
17
|
*/
|
|
18
18
|
export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about_matbot'>> {
|
|
19
19
|
const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
|
|
20
|
-
async *execute(_input: unknown,
|
|
21
|
-
yield { type: 'result', value: { version, about: ABOUT } };
|
|
20
|
+
async *execute(_input: unknown, ctx: ToolContext) {
|
|
21
|
+
yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider } };
|
|
22
22
|
},
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
return {
|
|
26
26
|
name: 'about_matbot',
|
|
27
27
|
description:
|
|
28
|
-
'Report what you are running: the
|
|
29
|
-
'
|
|
30
|
-
'
|
|
28
|
+
'Report what you are running right now: the current model / LLM and provider profile powering THIS ' +
|
|
29
|
+
'conversation, plus the matbot harness version and a one-line description. Use it whenever asked ' +
|
|
30
|
+
'"what model / LLM are you using?", "which provider is this?", what version of matbot this is, or for ' +
|
|
31
|
+
'an "about" of the harness itself — distinct from the `provider` tool (which lists all configured ' +
|
|
32
|
+
'provider profiles) and the `plugin` tool (which lists loaded plugins).',
|
|
31
33
|
inputSchema: { type: 'object', properties: {} },
|
|
32
34
|
executor,
|
|
33
35
|
};
|
package/src/loader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MatbotPlugin, MatbotPluginSpec, MatbotMachine, PluginSource, Runtime } from './plugin.js';
|
|
2
2
|
import type { PromptFn } from './types.js';
|
|
3
3
|
import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-plugin-api';
|
|
4
|
-
import { registerPlugin, setupPlugin, unloadPlugin } from './registry.js';
|
|
4
|
+
import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin } from './registry.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The runtime this process is executing in. Detected the same way the rejected-import branch below
|
|
@@ -126,7 +126,9 @@ export async function loadPlugins(
|
|
|
126
126
|
if (onLoadError === 'throw') {
|
|
127
127
|
throw incompatibleRuntimeError(spec, runtimes, CURRENT_RUNTIME);
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
const reason = `declares matbotRuntime [${runtimes.join(', ')}], host runtime is "${CURRENT_RUNTIME}".`;
|
|
130
|
+
console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
|
|
131
|
+
recordFailedPlugin({ specifier: spec, error: reason, ...(name !== undefined ? { name } : {}) });
|
|
130
132
|
continue;
|
|
131
133
|
}
|
|
132
134
|
toLoad.push({
|
|
@@ -158,6 +160,7 @@ export async function loadPlugins(
|
|
|
158
160
|
const failLoad = (spec: string, reason: string, kind: 'load' | 'shape' = 'load'): void => {
|
|
159
161
|
if (onLoadError === 'throw') throw kind === 'shape' ? notAPluginError(spec, reason) : new Error(reason);
|
|
160
162
|
console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
|
|
163
|
+
recordFailedPlugin({ specifier: spec, error: reason });
|
|
161
164
|
};
|
|
162
165
|
|
|
163
166
|
for (let i = 0; i < toLoad.length; i++) {
|
|
@@ -169,6 +172,7 @@ export async function loadPlugins(
|
|
|
169
172
|
if (typeof window !== 'undefined') {
|
|
170
173
|
// Browser: warn and skip — browser environments have no node_modules fallback.
|
|
171
174
|
console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
|
|
175
|
+
recordFailedPlugin({ specifier: spec, error: reason });
|
|
172
176
|
continue;
|
|
173
177
|
}
|
|
174
178
|
console.error(`[matbot] Failed to load plugin "${spec}":`, result.reason);
|
|
@@ -233,8 +237,15 @@ export async function loadPlugins(
|
|
|
233
237
|
registered = true;
|
|
234
238
|
await setupPlugin(plugin, services, prompt);
|
|
235
239
|
loaded.push(plugin);
|
|
240
|
+
// Successful (re)load: a fixed plugin drops off the failed list (reload runs this same path).
|
|
241
|
+
clearFailedPlugin(spec);
|
|
236
242
|
} catch (err) {
|
|
237
|
-
|
|
243
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
244
|
+
console.error(`[matbot] Ignoring plugin "${plugin.name}" from "${spec}" due to an error:`, reason);
|
|
245
|
+
// Graceful but not silent: record the setup failure (skip mode — the boot batch) so the `plugin`
|
|
246
|
+
// tool and web UI can show it. Explicit single loads (`onLoadError: 'throw'`) surface the error to
|
|
247
|
+
// the caller directly, so they are not added to the persistent failed list.
|
|
248
|
+
if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: reason });
|
|
238
249
|
// Roll back partial registration so a failed load leaves no ghost state. setupPlugin
|
|
239
250
|
// can throw after the plugin is already in the registry with some of its tools / hooks /
|
|
240
251
|
// services / provider / storage wired (a wrong-platform plugin touching a missing
|
package/src/registry.ts
CHANGED
|
@@ -11,6 +11,15 @@ import type { SettingsDoc } from './settings.js';
|
|
|
11
11
|
|
|
12
12
|
// ── Internal state ────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A plugin the loader could not bring up — an incompatible runtime, an import that rejected, a
|
|
16
|
+
* module that is not plugin-shaped, or a setup() throw. Recorded (not discarded) so the failure is
|
|
17
|
+
* *graceful but not silent*: the `plugin` tool and web UI can show which configured plugins failed
|
|
18
|
+
* and why, instead of the failure vanishing into a boot-time console line. `error` is pre-stringified
|
|
19
|
+
* (sidesteps the `unknown` catch-var); `name` is present only when known before the failure.
|
|
20
|
+
*/
|
|
21
|
+
export type FailedPlugin = { specifier: string; name?: string; error: string };
|
|
22
|
+
|
|
14
23
|
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
15
24
|
const state = {
|
|
16
25
|
plugins: [] as MatbotPlugin[],
|
|
@@ -21,6 +30,7 @@ const state = {
|
|
|
21
30
|
serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
|
|
22
31
|
hookPlugins: new Set<string>(), // plugins that registered at least one hook
|
|
23
32
|
systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
|
|
33
|
+
failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
|
|
24
34
|
overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
|
|
25
35
|
};
|
|
26
36
|
|
|
@@ -196,6 +206,28 @@ export function getRegisteredPlugins(): readonly MatbotPlugin[] {
|
|
|
196
206
|
return state.plugins;
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Plugins the loader skipped rather than loaded — the graceful-but-not-silent record. One entry per
|
|
211
|
+
* specifier (a repeated failure overwrites the prior one; a subsequent successful load clears it via
|
|
212
|
+
* clearFailedPlugin). Surfaced by the `plugin` tool's `list` and the web plugins panel.
|
|
213
|
+
*/
|
|
214
|
+
export function getFailedPlugins(): readonly FailedPlugin[] {
|
|
215
|
+
return state.failedPlugins;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Record (or replace, by specifier) a plugin the loader skipped. Called from loader.ts skip points. */
|
|
219
|
+
export function recordFailedPlugin(entry: FailedPlugin): void {
|
|
220
|
+
const idx = state.failedPlugins.findIndex(f => f.specifier === entry.specifier);
|
|
221
|
+
if (idx === -1) state.failedPlugins.push(entry);
|
|
222
|
+
else state.failedPlugins[idx] = entry;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Drop any recorded failure for a specifier — a fixed + reloaded plugin drops off the failed list. */
|
|
226
|
+
export function clearFailedPlugin(specifier: string): void {
|
|
227
|
+
const idx = state.failedPlugins.findIndex(f => f.specifier === specifier);
|
|
228
|
+
if (idx !== -1) state.failedPlugins.splice(idx, 1);
|
|
229
|
+
}
|
|
230
|
+
|
|
199
231
|
export function getRegisteredFrontendPlugins(): ReadonlyMap<string, FrontendInfo> {
|
|
200
232
|
return state.frontendPlugins;
|
|
201
233
|
}
|
package/src/runner.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
|
+
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
7
8
|
import { currentUsageSink } from '@matatbread/matbot-plugin-api';
|
|
8
9
|
import { HookRegistry } from './hooks.js';
|
|
9
10
|
import { appendMessage, createMessage } from './session.js';
|
|
@@ -25,6 +26,14 @@ export interface RunSessionOpts {
|
|
|
25
26
|
* `tools`).
|
|
26
27
|
*/
|
|
27
28
|
toolRegistry?: ToolRegistry;
|
|
29
|
+
/**
|
|
30
|
+
* Optional presenter that picks which tools are advertised to the model on each provider call — a
|
|
31
|
+
* read-only view over the `tools` snapshot (e.g. deferring a large library behind a search tool and
|
|
32
|
+
* revealing matches mid-turn). Consulted *per provider call* so a reveal takes effect on the next
|
|
33
|
+
* loop iteration; absent ⇒ the whole snapshot is advertised (unchanged behaviour). Presentation never
|
|
34
|
+
* affects execution — a withheld tool still resolves by name through `toolRegistry`.
|
|
35
|
+
*/
|
|
36
|
+
toolPresenter?: ToolPresenter;
|
|
28
37
|
store: Store<Session>;
|
|
29
38
|
hooks?: HookRegistry;
|
|
30
39
|
systemContext?: SystemContextRegistry;
|
|
@@ -149,8 +158,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
149
158
|
outgoing: [...systemMsg, ...history],
|
|
150
159
|
session, config, signal,
|
|
151
160
|
});
|
|
161
|
+
// Tools advertised for THIS call. A presenter (optional) may present a subset of the snapshot and
|
|
162
|
+
// grow it across iterations as the model discovers tools; absent ⇒ the whole snapshot. Withheld
|
|
163
|
+
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
164
|
+
const advertised = opts.toolPresenter
|
|
165
|
+
? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
|
|
166
|
+
: [...tools.values()];
|
|
152
167
|
try {
|
|
153
|
-
for await (const ev of provider.complete(outgoing, providerConfig,
|
|
168
|
+
for await (const ev of provider.complete(outgoing, providerConfig, advertised, signal)) {
|
|
154
169
|
switch (ev.type) {
|
|
155
170
|
case 'text-delta':
|
|
156
171
|
textAcc += ev.delta;
|
package/src/session-runner.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { HookRegistry } from './hooks.js';
|
|
8
|
-
import type { ToolTypeIndex } from '@matatbread/matbot-plugin-api';
|
|
8
|
+
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
9
9
|
import { appendMessage, createMessage } from './session.js';
|
|
10
10
|
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
|
|
11
11
|
import { runSession } from './runner.js';
|
|
@@ -19,6 +19,9 @@ export interface SessionRunnerDeps {
|
|
|
19
19
|
// into the turn-start tool snapshot's description so the wire carries params/result text. Absent (e.g.
|
|
20
20
|
// browser) ⇒ tools keep their plain description.
|
|
21
21
|
toolTypeIndex?: () => ToolTypeIndex | undefined;
|
|
22
|
+
// Resolved live (registers after boot): picks which tools are advertised to the model per provider
|
|
23
|
+
// call. Absent ⇒ the whole turn snapshot is advertised. Threaded into runSession per turn.
|
|
24
|
+
toolPresenter?: () => ToolPresenter | undefined;
|
|
22
25
|
hooks?: HookRegistry;
|
|
23
26
|
systemContext?: SystemContextRegistry;
|
|
24
27
|
vault?: Vault;
|
|
@@ -119,6 +122,12 @@ function createSink(dispose: () => void): Sink {
|
|
|
119
122
|
};
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
export function wireDescription(description: string, wc: { params: string; result: string } | undefined) {
|
|
126
|
+
return wc
|
|
127
|
+
? `${description}\n\nTypeScript params:\n\`\`\`\n${wc.params}\n\`\`\`\n\nTypeScript result:\`\`\`${wc.result}\n\`\`\``
|
|
128
|
+
: description;
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
123
132
|
const states = new Map<string, SessionState>();
|
|
124
133
|
|
|
@@ -219,11 +228,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
219
228
|
// Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
|
|
220
229
|
// inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
|
|
221
230
|
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
231
|
+
const toolPresenter = deps.toolPresenter?.();
|
|
222
232
|
const toolMap = deps.tools !== undefined
|
|
223
233
|
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
224
234
|
const wc = wire?.[t.name];
|
|
225
235
|
return [t.name, wc !== undefined
|
|
226
|
-
? { ...t, description:
|
|
236
|
+
? { ...t, description: wireDescription(t.description, wc) }
|
|
227
237
|
: t];
|
|
228
238
|
}))
|
|
229
239
|
: undefined;
|
|
@@ -248,6 +258,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
248
258
|
unloadPlugin: deps.unloadPlugin,
|
|
249
259
|
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
250
260
|
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
261
|
+
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
251
262
|
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
252
263
|
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
253
264
|
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
@@ -22,6 +22,11 @@ function toQuery(p: PageState): StoreQuery {
|
|
|
22
22
|
// disjoint cover (page N re-applies the same sort as page 1, so the total order never shifts). A
|
|
23
23
|
// cursor is untrusted input, so its decoded query is validated exactly like a fresh one.
|
|
24
24
|
export function executeQuery<T extends { id: string; version: string }>(docs: T[], q: StoreQuery): QueryResult<T> {
|
|
25
|
+
// Validate the caller's RAW query first. The projection below keeps only where/sort/limit, so an
|
|
26
|
+
// unknown top-level key (e.g. `filter` instead of `where`) would otherwise be silently discarded
|
|
27
|
+
// and the query would degrade to match-everything with no feedback to the author.
|
|
28
|
+
validateQuery(q);
|
|
29
|
+
|
|
25
30
|
const page: PageState = q.cursor !== undefined
|
|
26
31
|
? decodeCursor(q.cursor)
|
|
27
32
|
: {
|
|
@@ -31,7 +36,8 @@ export function executeQuery<T extends { id: string; version: string }>(docs: T[
|
|
|
31
36
|
...(q.limit !== undefined ? { limit: q.limit } : {}),
|
|
32
37
|
};
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
// A cursor is untrusted input, so its decoded query is validated exactly like a fresh one.
|
|
40
|
+
if (q.cursor !== undefined) validateQuery(toQuery(page));
|
|
35
41
|
|
|
36
42
|
const predicate = page.where !== undefined ? compileFilter(page.where) : () => true;
|
|
37
43
|
const ordered = applySort(docs.filter(predicate), page.sort);
|
|
@@ -93,7 +93,21 @@ function validateFilter(f: Filter, ptr: string): void {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const QUERY_KEYS = new Set(['where', 'sort', 'limit', 'cursor']);
|
|
97
|
+
|
|
96
98
|
export function validateQuery(q: StoreQuery): void {
|
|
99
|
+
if (q === null || typeof q !== 'object' || Array.isArray(q))
|
|
100
|
+
throw new StoreQueryError('query must be an object with optional keys: where, sort, limit, cursor', '/', 'MALFORMED');
|
|
101
|
+
|
|
102
|
+
// Reject unknown top-level keys. Without this the envelope is silently permissive: a clause placed
|
|
103
|
+
// under a wrong key (e.g. `filter` instead of `where`, an SQL-ism LLMs reach for) is dropped, the
|
|
104
|
+
// query degrades to match-everything, and the author gets a plausible result with no signal it was
|
|
105
|
+
// malformed. Naming the bad key and the valid set turns a silent miss into a fixable error.
|
|
106
|
+
for (const key of Object.keys(q)) {
|
|
107
|
+
if (!QUERY_KEYS.has(key))
|
|
108
|
+
throw new StoreQueryError(`unknown query key '${key}' — valid keys are: where, sort, limit, cursor`, `/${key}`, 'MALFORMED');
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
if (q.where !== undefined) validateFilter(q.where, '/where');
|
|
98
112
|
|
|
99
113
|
if (q.sort !== undefined) {
|