@matatbread/matbot-plugin-api 0.2.4 → 0.2.6

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.6",
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, ToolResultFor, 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, const P>(
29
+ machine: MatbotMachine,
30
+ name: K,
31
+ params: P,
32
+ opts: InvokeToolOptions,
33
+ ): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
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<ToolResultFor<K, P>>>;
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.
@@ -488,7 +491,13 @@ export interface MatbotPluginSpec {
488
491
  */
489
492
  export interface MatbotPlugin extends MatbotPluginSpec {
490
493
  readonly name: string; // resolver.identify(specifier)
491
- readonly specifier: string; // how it was loaded
494
+ readonly specifier: string; // how it was loaded (config entry / pre-resolved)
495
+ /** The stable URL the loader actually imported, minus any reload cache-bust stamp — a `file:` URL
496
+ * for local/remote-cached plugins, the bare specifier when nothing was pre-resolved. Lets a consumer
497
+ * map a loaded plugin back to its on-disk source without re-running specifier resolution (e.g.
498
+ * skills_compiler builds a types program over the live plugin set). Set by the loader; absent only
499
+ * on hosts that construct MatbotPlugin by hand. */
500
+ readonly resolvedUrl?: string;
492
501
  readonly source?: PluginSource;
493
502
  // The package.json `matbotRuntime`, captured at load from the same value used for the pre-import
494
503
  // gate (the host reads it once; downstream just reads this). Absent ⇒ undeclared. Stored rather
package/src/types.ts CHANGED
@@ -431,6 +431,11 @@ export interface KnowledgeEntry {
431
431
 
432
432
  export interface KnowledgeIndex {
433
433
  index(entry: KnowledgeEntry): Promise<void>;
434
+ /** Remove the entry with this id, if present (idempotent — removing an absent id is a no-op). The
435
+ * id is the index's sole primary key (`index` replaces by it), so retraction is by id alone; the
436
+ * index never inspects an entry's opaque `source`. The party that indexed an entry owns retracting
437
+ * it — e.g. a skill manager retracts a hidden/deleted skill rather than the index policing tenants. */
438
+ remove(id: string): Promise<void>;
434
439
  search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
435
440
  /** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
436
441
  entries?(): Iterable<KnowledgeEntry>;
@@ -438,11 +443,11 @@ export interface KnowledgeIndex {
438
443
 
439
444
  // ── Tools ─────────────────────────────────────────────────────────────────────
440
445
 
441
- export type ToolEvent =
446
+ export type ToolEvent<Result = unknown> =
442
447
  | { type: 'stdout'; chunk: string }
443
448
  | { type: 'stderr'; chunk: string }
444
449
  | { type: 'progress'; pct: number; message?: string }
445
- | { type: 'result'; value: unknown }
450
+ | { type: 'result'; value: Result }
446
451
  | { type: 'file'; handle: FileHandle }
447
452
  // A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
448
453
  // side-effect). Persisted as a `marker`-role message; elided from provider submission like any
@@ -451,6 +456,76 @@ export type ToolEvent =
451
456
  | { type: 'marker'; creator: string; data: unknown }
452
457
  | { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
453
458
 
459
+ /**
460
+ * Per-tool result-type registry: maps a tool's `name` to the type of the `value` it yields in its
461
+ * `result` event. Augment it exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the
462
+ * concrete result type back from {@link invokeTool} + `toolResult` instead of `unknown`:
463
+ *
464
+ * declare module '@matatbread/matbot-plugin-api' {
465
+ * interface ToolResults { find_fact: string[] | null }
466
+ * }
467
+ *
468
+ * The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
469
+ * `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
470
+ * name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
471
+ * type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
472
+ * executor already produces. Keep the entry in sync with what the executor actually yields.
473
+ *
474
+ * **Multi-action tools** are a weird form of overloaded function: the same tool returns different
475
+ * shapes depending on its params. Register such a tool as a union of {@link ToolResult} *arms*, each
476
+ * pairing a result with the discriminating params *pattern* that selects it — `invokeTool` matches the
477
+ * call's literal params against the patterns and narrows the result to the matching arm:
478
+ *
479
+ * declare module '@matatbread/matbot-plugin-api' {
480
+ * interface ToolResults {
481
+ * session_action:
482
+ * | ToolResult<Session, { action: 'get' }>
483
+ * | ToolResult<{ id: string; title: string }, { action: 'rename' }>;
484
+ * }
485
+ * }
486
+ * // invokeTool(machine, 'session_action', { action: 'get', sessionId }) → result is Session
487
+ *
488
+ * The pattern is *any* discriminating field(s), not just `action` (a tool keying on `interval`'s
489
+ * presence registers `ToolResult<…, { interval: string }>`). It is a *pattern*, not the full input:
490
+ * key only on the discriminant so a call carrying just that field still matches. When no arm's pattern
491
+ * matches (a non-literal discriminant, or an absence-discriminant the positive patterns can't express),
492
+ * the result falls back to the union of every arm — always sound, just less narrow.
493
+ */
494
+ export interface ToolResults {}
495
+
496
+ /**
497
+ * One overload arm of a multi-action tool registered in {@link ToolResults}: the `Result` it yields
498
+ * for a call whose params match the discriminating pattern `Args`. `Args` defaults to `unknown` (an
499
+ * arm that matches any params). Purely type-level — the `__` fields are phantom and never constructed.
500
+ */
501
+ export interface ToolResult<Result, Args = unknown> {
502
+ readonly __result: Result;
503
+ readonly __args: Args;
504
+ }
505
+
506
+ type ToolResultArmed<E> = E extends ToolResult<unknown, unknown> ? true : false;
507
+ type ToolResultUnion<E> = E extends ToolResult<infer R, unknown> ? R : never;
508
+ type ToolResultMatched<E, P> = E extends ToolResult<infer R, infer A> ? (P extends A ? R : never) : never;
509
+
510
+ /** The full set of `result` values a tool named `K` may yield — the union over all its arms (for an
511
+ * arm-based entry), the entry itself (for a plain entry), or `unknown` when `K` is unregistered. This
512
+ * is what an executor binds against (`ToolExecutor<ToolResultOf<'my_tool'>>`): it must cover every arm. */
513
+ export type ToolResultOf<K extends string> =
514
+ K extends keyof ToolResults
515
+ ? ToolResultArmed<ToolResults[K]> extends true ? ToolResultUnion<ToolResults[K]> : ToolResults[K]
516
+ : unknown;
517
+
518
+ /** The `result` a call to tool `K` with params `P` yields: for an arm-based entry, the arm whose
519
+ * pattern `P` matches (or the full union when none match — always sound); otherwise {@link ToolResultOf}. */
520
+ export type ToolResultFor<K extends string, P> =
521
+ K extends keyof ToolResults
522
+ ? ToolResultArmed<ToolResults[K]> extends true
523
+ ? ([ToolResultMatched<ToolResults[K], P>] extends [never]
524
+ ? ToolResultUnion<ToolResults[K]>
525
+ : ToolResultMatched<ToolResults[K], P>)
526
+ : ToolResults[K]
527
+ : unknown;
528
+
454
529
  /**
455
530
  * Ask the user a question and resolve with their answer. The host supplies the
456
531
  * implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
@@ -488,8 +563,10 @@ export interface ToolContext {
488
563
  files?: FileStore;
489
564
  /** Prompt the user for input. The host provides a readline or form implementation. */
490
565
  prompt: PromptFn;
491
- /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin. */
492
- loadPlugin(specifier: string): Promise<MatbotPlugin>;
566
+ /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
567
+ * `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
568
+ * re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
569
+ loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
493
570
  /**
494
571
  * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
495
572
  * Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
@@ -498,15 +575,23 @@ export interface ToolContext {
498
575
  unloadPlugin(specifier: string): Promise<boolean>;
499
576
  }
500
577
 
501
- export interface ToolExecutor {
502
- execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent>;
578
+ /**
579
+ * A tool's runtime. `R` is the type of the `value` carried by its `result` event — declared once,
580
+ * at the source, so the executor's yields and the tool's {@link ToolResults} registry entry can't
581
+ * silently drift. The `unknown` default keeps untyped executors compiling untouched, and covariance
582
+ * means a narrower `ToolExecutor<X>` still satisfies `ToolExecutor` (i.e. `ToolExecutor<unknown>`),
583
+ * so the heterogeneous registry boundary (`Tool[]`) accepts any executor. Bind `R` to the registry
584
+ * entry with `ToolExecutor<ToolResultOf<'my_tool'>>` so the single augmentation is the source of truth.
585
+ */
586
+ export interface ToolExecutor<R = unknown> {
587
+ execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent<R>>;
503
588
  }
504
589
 
505
- export interface Tool {
590
+ export interface Tool<R = unknown> {
506
591
  name: string;
507
592
  description: string;
508
593
  inputSchema: JSONSchema;
509
- executor: ToolExecutor;
594
+ executor: ToolExecutor<R>;
510
595
  pluginName?: string;
511
596
  }
512
597
 
@@ -649,6 +734,7 @@ export type PipelineEvent =
649
734
  | { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
650
735
  | { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
651
736
  | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
737
+ | { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
652
738
  | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
653
739
  | { type: 'file'; handle: FileHandle; traceId: string }
654
740
  | ({ type: 'usage'; traceId: string } & Usage)