@matatbread/matbot-plugin-api 0.2.4 → 0.2.5

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.2.4",
7
+ "version": "0.2.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from './context-switch.js';
10
10
  export * from './hooks.js';
11
11
  export * from './plugin.js';
12
12
  export * from './broadcast.js';
13
+ export * from './invoke-tool.js';
@@ -0,0 +1,100 @@
1
+ import type { MatbotMachine } from './plugin.js';
2
+ import type { ToolContext, ToolEvent, ToolResultOf, PromptFn, FormField } from './types.js';
3
+
4
+ /**
5
+ * Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
6
+ * forwarding a call to another tool should pass its own {@link ToolContext} straight through as
7
+ * `opts` — `session`, `signal`, `prompt` and crucially **`provider`** all propagate, so a callee
8
+ * that needs an LLM (e.g. `find_fact`, or anything using `singleTurn`) inherits the same model
9
+ * rather than finding no provider. Only spell out individual fields when starting a call from
10
+ * scratch or deliberately overriding one; never hand-pick a subset of `ctx` and drop the rest.
11
+ */
12
+ export type InvokeToolOptions =
13
+ Pick<ToolContext, 'session' | 'signal'>
14
+ & Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId'>>;
15
+
16
+ /**
17
+ * Programmatically invoke a tool by name, the same way the harness does — resolve it off the
18
+ * machine's tool registry, build a `ToolContext` from the machine, and return its event stream.
19
+ * The host-only bits a one-shot caller can't derive (the session under which the call runs, its
20
+ * abort signal, and an optional interactive `prompt`/`provider`) come in via `opts` — pass the
21
+ * calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
22
+ * plugin (un)loading, workdir/configPath/files) is filled from `machine`.
23
+ *
24
+ * Throws synchronously if no tool is registered under `name`. When no `prompt` is supplied the tool
25
+ * runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
26
+ * event. Pair with {@link toolText} to collapse the stream to its result string.
27
+ */
28
+ export function invokeTool<K extends string>(
29
+ machine: MatbotMachine,
30
+ name: K,
31
+ params: unknown,
32
+ opts: InvokeToolOptions,
33
+ ): AsyncIterable<ToolEvent<ToolResultOf<K>>> {
34
+ const tool = machine.tools.resolve(name);
35
+ if (tool === null) throw new Error(`Tool "${name}" is not registered`);
36
+
37
+ const prompt = opts.prompt ?? rejectingPrompt;
38
+ const ctx: ToolContext = {
39
+ callId: opts.callId ?? crypto.randomUUID(),
40
+ session: opts.session,
41
+ signal: opts.signal,
42
+ vault: machine.Vault,
43
+ prompt,
44
+ loadPlugin: (specifier, refresh) => machine.loadPlugin(specifier, prompt, refresh),
45
+ unloadPlugin: (specifier) => machine.unloadPlugin(specifier),
46
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
47
+ ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
48
+ ...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
49
+ ...(machine.files !== undefined ? { files: machine.files } : {}),
50
+ };
51
+
52
+ // The registry is name-keyed and untyped (`ToolExecutor.execute` yields `ToolEvent<unknown>`); the
53
+ // `ToolResults` mapping is the call-site contract, asserted here so callers read a typed result.
54
+ return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultOf<K>>>;
55
+ }
56
+
57
+ /**
58
+ * Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
59
+ * paired with `invokeTool(machine, name, …)` it returns whatever `ToolResults[name]` declares (or
60
+ * `unknown` for an unregistered tool). This is the structured counterpart to {@link toolText} — use it
61
+ * for any tool that returns data; use `toolText` when you want the result collapsed to a string.
62
+ * Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
63
+ */
64
+ export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
65
+ let value!: R;
66
+ let hadResult = false;
67
+ for await (const ev of events) {
68
+ if (ev.type === 'result') { value = ev.value; hadResult = true; }
69
+ else if (ev.type === 'error') { throw new Error(ev.message); }
70
+ }
71
+ if (!hadResult) throw new Error('Tool produced no result');
72
+ return value;
73
+ }
74
+
75
+ /**
76
+ * Drain a tool event stream (e.g. {@link invokeTool}'s return) to its result as text. Stops and
77
+ * throws on the first `error` event, or if the tool finished without yielding a `result`. The result
78
+ * value is rendered the way the model would see it: a string verbatim, a `{ content: string }`
79
+ * (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
80
+ */
81
+ export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
82
+ let result: unknown;
83
+ let hadResult = false;
84
+ for await (const ev of events) {
85
+ if (ev.type === 'result') { result = ev.value; hadResult = true; }
86
+ else if (ev.type === 'error') { throw new Error(ev.message); }
87
+ }
88
+ if (!hadResult) throw new Error('Tool produced no result');
89
+
90
+ if (typeof result === 'string') return result;
91
+ if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
92
+ return (result as { content: string }).content;
93
+ }
94
+ return JSON.stringify(result, null, 2);
95
+ }
96
+
97
+ const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
98
+ const label = typeof p === 'string' ? p : p.label;
99
+ return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
100
+ }) as PromptFn);
package/src/plugin.ts CHANGED
@@ -116,8 +116,11 @@ export interface MatbotRuntime {
116
116
  * Hot-load a plugin by specifier into the running process. Returns the loaded plugin.
117
117
  * `prompt`, when supplied, resolves tool-name collisions interactively during the new
118
118
  * plugin's setup(); the runner injects the triggering session's prompt automatically.
119
+ * `refresh` (default false) forces a remote (github/http) plugin's `.plugins/` cache subtree
120
+ * to be evicted and re-downloaded — a reload of changed upstream source. Cache-first otherwise:
121
+ * a programmatic loader gets the cached copy and stays offline-tolerant.
119
122
  */
120
- loadPlugin(specifier: string, prompt?: PromptFn): Promise<MatbotPlugin>;
123
+ loadPlugin(specifier: string, prompt?: PromptFn, refresh?: boolean): Promise<MatbotPlugin>;
121
124
 
122
125
  /**
123
126
  * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
package/src/types.ts CHANGED
@@ -438,11 +438,11 @@ export interface KnowledgeIndex {
438
438
 
439
439
  // ── Tools ─────────────────────────────────────────────────────────────────────
440
440
 
441
- export type ToolEvent =
441
+ export type ToolEvent<Result = unknown> =
442
442
  | { type: 'stdout'; chunk: string }
443
443
  | { type: 'stderr'; chunk: string }
444
444
  | { type: 'progress'; pct: number; message?: string }
445
- | { type: 'result'; value: unknown }
445
+ | { type: 'result'; value: Result }
446
446
  | { type: 'file'; handle: FileHandle }
447
447
  // A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
448
448
  // side-effect). Persisted as a `marker`-role message; elided from provider submission like any
@@ -451,6 +451,26 @@ export type ToolEvent =
451
451
  | { type: 'marker'; creator: string; data: unknown }
452
452
  | { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
453
453
 
454
+ /**
455
+ * Per-tool result-type registry: maps a tool's `name` to the type of the `value` it yields in its
456
+ * `result` event. Augment it exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the
457
+ * concrete result type back from {@link invokeTool} + `toolResult` instead of `unknown`:
458
+ *
459
+ * declare module '@matatbread/matbot-plugin-api' {
460
+ * interface ToolResults { find_fact: string[] | null }
461
+ * }
462
+ *
463
+ * The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
464
+ * `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
465
+ * name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
466
+ * type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
467
+ * executor already produces. Keep the entry in sync with what the executor actually yields.
468
+ */
469
+ export interface ToolResults {}
470
+
471
+ /** The `result` value a tool named `K` yields, or `unknown` when `K` isn't registered in {@link ToolResults}. */
472
+ export type ToolResultOf<K extends string> = K extends keyof ToolResults ? ToolResults[K] : unknown;
473
+
454
474
  /**
455
475
  * Ask the user a question and resolve with their answer. The host supplies the
456
476
  * implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
@@ -488,8 +508,10 @@ export interface ToolContext {
488
508
  files?: FileStore;
489
509
  /** Prompt the user for input. The host provides a readline or form implementation. */
490
510
  prompt: PromptFn;
491
- /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin. */
492
- loadPlugin(specifier: string): Promise<MatbotPlugin>;
511
+ /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
512
+ * `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
513
+ * re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
514
+ loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
493
515
  /**
494
516
  * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
495
517
  * Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
@@ -649,6 +671,7 @@ export type PipelineEvent =
649
671
  | { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
650
672
  | { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
651
673
  | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
674
+ | { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
652
675
  | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
653
676
  | { type: 'file'; handle: FileHandle; traceId: string }
654
677
  | ({ type: 'usage'; traceId: string } & Usage)