@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.0",
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.0"
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, _ctx: ToolContext) {
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 matbot harness version and a one-line description. Use it when ' +
29
- 'asked what version of matbot this is, or for an "about" of the harness itself (distinct from the ' +
30
- 'plugin tool, which lists loaded plugins).',
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
- console.warn(`[matbot] Skipping plugin "${spec}": declares matbotRuntime [${runtimes.join(', ')}], host runtime is "${CURRENT_RUNTIME}".`);
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
- console.error(`[matbot] Ignoring plugin "${plugin.name}" from "${spec}" due to an error:`, err instanceof Error ? err.message : err);
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, [...tools.values()], signal)) {
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;
@@ -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: `${t.description}\n\nTypeScript:\n params: ${wc.params}\n result: ${wc.result}` }
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
- validateQuery(toQuery(page));
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) {