@matatbread/matbot-plugin-api 0.3.8 → 0.4.0

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.
@@ -0,0 +1,157 @@
1
+ import type { HealthStatus } from './health.js';
2
+ import type { Message } from './messages.js';
3
+ import type { Tool } from './tools.js';
4
+
5
+ // ── Provider ──────────────────────────────────────────────────────────────────
6
+
7
+ export interface ModelParameters {
8
+ temperature?: number;
9
+ maxTokens?: number;
10
+ topP?: number;
11
+ stopSequences?: string[];
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface ProviderConfig {
16
+ name: string;
17
+ /** Plugin module specifier (npm name or file URL) that provides the adapter for this provider. */
18
+ module: string;
19
+ model: string;
20
+ credentials?: Record<string, string>;
21
+ endpoint?: string;
22
+ parameters?: ModelParameters;
23
+ /**
24
+ * Ceiling on the agentic rounds one turn may take against this provider — a round being one provider
25
+ * call plus the tool batch it asked for. Reaching it ends the turn (`aborted`, reason `round-limit`)
26
+ * rather than starting another round. Absent ⇒ unbounded, which is the historical behaviour.
27
+ *
28
+ * It lives here, per provider, rather than as one global or a per-call override because that is the
29
+ * unit spend is actually denominated in: a local model can afford to grind, a frontier model at 100×
30
+ * the rate cannot, and the same deployment runs both. Not in `parameters` — those are forwarded to
31
+ * the endpoint unmodified, and this never leaves matbot.
32
+ */
33
+ maxRounds?: number;
34
+ }
35
+
36
+ /**
37
+ * Token (and optional cost) counters for one provider call — pure accounting data, never conversation
38
+ * content. Recorded onto the message stream for cost reckoning but elided from provider submission. The
39
+ * producing provider is recorded alongside at each storage site (an assistant turn carries it on the
40
+ * message's `providerName`; a tool-invoked completion pairs it explicitly — see `tool-result`), since a
41
+ * tool may run a completion against any configured provider, each with its own rates.
42
+ */
43
+ export interface Usage {
44
+ inputTokens: number;
45
+ outputTokens: number;
46
+ costUsd?: number;
47
+ cacheReadTokens?: number;
48
+ cacheCreationTokens?: number;
49
+ }
50
+
51
+ /** One provider call's usage, tagged with the provider billed — the unit a tool accrues (a tool may
52
+ * run completions against any provider, each with its own rates) and the element persisted on a
53
+ * `tool-result`'s `usage` addendum. See the ambient usage carrier (`recordUsage`/`currentUsageSink`). */
54
+ export interface UsageRecord {
55
+ provider: string;
56
+ usage: Usage;
57
+ }
58
+
59
+ /**
60
+ * Opaque, provider-specific round-trip metadata attached to a tool-call — captured from a completion,
61
+ * persisted, and re-sent verbatim when the call is replayed in history (e.g. a Gemini 3 "thought
62
+ * signature", which every historical `functionCall` must carry or the request 400s). The harness never
63
+ * interprets it: it stores, renders, or elides it. Empty by default — a provider package augments its
64
+ * OWN slice from its own module (namespaced by provider family, so slices from different providers in a
65
+ * mixed-provider session never collide), so adding a provider that needs round-trip state touches NO
66
+ * core code:
67
+ *
68
+ * declare module '@matatbread/matbot-plugin-api' {
69
+ * interface ProviderMeta { google?: { thoughtSignature?: string } }
70
+ * }
71
+ *
72
+ * A single session interleaves tool-calls from many providers, so this must be an open, additive union
73
+ * (interface augmentation) — a generic type parameter can bind only one provider per instantiation and
74
+ * so could not type a heterogeneous transcript.
75
+ *
76
+ * One of matbot's five open-registry augmentation points — same technique at each; see
77
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
78
+ */
79
+ export interface ProviderMeta {}
80
+
81
+ export type CompletionEvent =
82
+ | { type: 'text-delta'; delta: string }
83
+ | { type: 'tool-call'; id: string; name: string; input: unknown;
84
+ /** Provider-specific round-trip metadata for this call, opaque to the harness — see `ProviderMeta`. */
85
+ meta?: ProviderMeta;
86
+ /**
87
+ * Set when the call's arguments were severed mid-stream and never parsed — nearly always the
88
+ * response hitting its token limit part-way through a large call. `input` is then `{}` (the wire
89
+ * requires an object; the real arguments are unrecoverable), and the runner does NOT execute the
90
+ * call: it pairs it with an error result saying so, exactly as it does a rejected one, and the
91
+ * model self-corrects on the next round.
92
+ *
93
+ * Only adapters that accumulate arguments as a streamed JSON *string* can detect this — the
94
+ * Anthropic (`input_json_delta`) and OpenAI (`function.arguments` fragments) shapes. Gemini
95
+ * delivers each `functionCall` complete with `args` already an object, so there is nothing to
96
+ * sever and the field is never set there.
97
+ */
98
+ truncated?: { bytes: number; stopReason?: string } }
99
+ | { type: 'tool-result'; id: string; result: unknown }
100
+ | { type: 'thinking'; delta: string }
101
+ | { type: 'thinking-block'; thinking: string; signature: string }
102
+ | { type: 'redacted-thinking'; data: string }
103
+ | { type: 'reasoning-block'; reasoning: string }
104
+ | { type: 'refusal'; text: string }
105
+ | { type: 'unknown-block'; blockType: string; raw: unknown }
106
+ /**
107
+ * The response was cut short — the model did not choose to stop. `max-tokens` is the provider saying
108
+ * so outright (`stop_reason: "max_tokens"` / `finish_reason: "length"` / `finishReason: "MAX_TOKENS"`);
109
+ * `stream-end` is the stream ending with no finish reason at all, e.g. a dropped connection.
110
+ *
111
+ * Emitted whether or not a tool call was caught in it, because the far commoner case has no tool call
112
+ * in it at all: prose stopping mid-sentence, which matbot previously did not surface anywhere. The
113
+ * runner records it as a durable `matbot-truncation` marker — LLM-invisible, so it informs the reader
114
+ * and the audit without the model narrating its own cut-off. Acting on it (continuing, re-asking with
115
+ * a larger budget) is a `followup` hook's business, not the harness's.
116
+ */
117
+ | { type: 'truncated'; reason: 'max-tokens' | 'stream-end'; raw?: string }
118
+ | ({ type: 'usage' } & Usage)
119
+ | { type: 'done' };
120
+
121
+ /**
122
+ * The result the runner records for a tool call whose arguments were truncated (see the `truncated`
123
+ * field on the `tool-call` event). The call did not run and has no side effect.
124
+ *
125
+ * The `truncated` addendum is what makes this distinguishable from an ordinary tool failure, so a
126
+ * `toolresult` hook can recognise it and fold in advice the harness has no business knowing — which
127
+ * argument to split, which of *this* tool's parameters offers a cheaper edit. Narrow with
128
+ * {@link isTruncatedToolResult} rather than duck-typing the shape:
129
+ *
130
+ * services.hooks.register({ on: 'toolresult', handler: ctx => {
131
+ * if (!isTruncatedToolResult(ctx.result)) return;
132
+ * return { result: { ...ctx.result, error: `${ctx.result.error} For a large edit, pass start_line/end_line instead.` } };
133
+ * }});
134
+ */
135
+ export interface TruncatedToolResult {
136
+ error: string;
137
+ truncated: { tool: string; bytes: number; stopReason?: string };
138
+ }
139
+
140
+ export function isTruncatedToolResult(result: unknown): result is TruncatedToolResult {
141
+ if (typeof result !== 'object' || result === null) return false;
142
+ const t = (result as { truncated?: unknown }).truncated;
143
+ return typeof t === 'object' && t !== null
144
+ && typeof (t as { tool?: unknown }).tool === 'string'
145
+ && typeof (t as { bytes?: unknown }).bytes === 'number';
146
+ }
147
+
148
+ export interface ProviderAdapter {
149
+ readonly name: string;
150
+ complete(
151
+ messages: Message[],
152
+ config: ProviderConfig,
153
+ tools: readonly Tool[],
154
+ signal: AbortSignal
155
+ ): AsyncIterable<CompletionEvent>;
156
+ health(): Promise<HealthStatus>;
157
+ }
@@ -0,0 +1,84 @@
1
+ import type { Hook } from './hooks.js';
2
+ import type { Session } from './messages.js';
3
+ import type { ProviderConfig } from './provider.js';
4
+ import type { Tool } from './tools.js';
5
+
6
+ // ── Registries ────────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * What `services.hooks` is: the *registration* half of hook dispatch, and the whole of what a plugin can
10
+ * do with it. `MatbotRuntime.hooks` was typed as the `HookRegistry` class, so the plugin contract also
11
+ * advertised the host's dispatch surface (`runScreen`, `runContribute`, `runToolCall`, `runToolResult`,
12
+ * `runFollowup`) — none of which a plugin may call, and which the scoped per-plugin facade had to be
13
+ * `as unknown as`-cast to satisfy. The cast was the tell. Core still holds the class where it dispatches.
14
+ */
15
+ export interface HookRegistrar {
16
+ register(hook: Hook): void;
17
+ removeByPlugin(pluginName: string): void;
18
+ }
19
+
20
+ /** Tool CRUD is observed on the {@link Notifier}, as a `RegistryChange` with `registry: 'tools'` — this
21
+ * registry had its own broadcaster over the same primitive, which is duplication, not layering. One
22
+ * notification per tool (removeByPlugin announces a `removed` per matched tool); the registering
23
+ * plugin's name rides in `detail`, advisory as ever — resolve the name for anything authoritative. */
24
+ export interface ToolRegistry {
25
+ /** Registers `tool` and returns — there is nothing to await. The name is free in the overwhelming
26
+ * case, and that path completes in the calling tick, so `setup()` can fire-and-forget. When a
27
+ * *different* plugin already owns the name the host may resolve it out of band (it can ask the user),
28
+ * in which case the tool lands after `setup()` has returned, or not at all if the user keeps the
29
+ * incumbent. The host owns that outcome — a failure to resolve keeps the existing tool, and neither
30
+ * case can reject into the caller. Registration order across plugins is not a contract; resolve by
31
+ * name at call time. */
32
+ register(tool: Tool): void;
33
+ remove(name: string): void;
34
+ resolve(name: string): Tool | null;
35
+ list(): readonly Tool[];
36
+ removeByPlugin(pluginName: string): void;
37
+ }
38
+
39
+ /** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
40
+ export interface PresentContext {
41
+ /** The session as of this provider call — the persisted user turn plus any assistant/tool turns
42
+ * taken so far this turn. A presenter ranks the tool set against this. */
43
+ session: Session;
44
+ /** The provider profile name this turn runs under, so a presenter can tailor to provider
45
+ * capabilities (e.g. Anthropic's in-context tool_reference expansion). */
46
+ provider: string;
47
+ }
48
+
49
+ /**
50
+ * Chooses which tools are advertised to the model for a single provider call — a read-only *view* over
51
+ * the turn's tool snapshot, NOT tool management. Presentation is orthogonal to the {@link ToolRegistry},
52
+ * which stays the single source of registration, resolution, and type-contracts (its fragile, load-bearing
53
+ * machinery is never a swap surface). The runner consults the presenter before *every* provider call, so
54
+ * an impl may present a subset (deferring a large library behind a search tool) and grow it mid-turn as the
55
+ * model discovers tools. Executor resolution is unaffected — a tool withheld here stays callable by name
56
+ * through the live registry — so presentation only ever changes what the model is *told about*.
57
+ *
58
+ * Optional {@link MatbotServices} member (offer-loosely): absent ⇒ the runner advertises the whole
59
+ * snapshot, byte-identical to today. `present` may return synchronously or async (a reranker).
60
+ */
61
+ export interface ToolPresenter {
62
+ present(tools: readonly Tool[], ctx: PresentContext): readonly Tool[] | Promise<readonly Tool[]>;
63
+ }
64
+
65
+ /**
66
+ * The named provider profiles the runtime resolves by name. A `ReadonlyMap` for the many read-only
67
+ * consumers (`services.providers.get`/`has`/`keys`/…), plus a sanctioned write path so a plugin can
68
+ * *contribute* profiles live — a storage backend replaying provider definitions it captured in its own
69
+ * medium, or the built-in `provider` tool — exactly as a plugin contributes tools via {@link ToolRegistry}.
70
+ * Keyed by `config.name`; `register` upserts.
71
+ */
72
+ export interface ProviderRegistry extends ReadonlyMap<string, ProviderConfig> {
73
+ register(config: ProviderConfig): void;
74
+ /** Explicit delete (the `provider` tool's `remove`) — removes the profile outright. */
75
+ remove(name: string): boolean;
76
+ /**
77
+ * Undo a contribution: restore the boot-baseline profile for `name` if one existed (a plugin-supplied
78
+ * profile that shadowed a configured one), otherwise delete it. A plugin that registers providers
79
+ * (e.g. a storage backend replaying them from its medium) calls this for each in its `teardown()`, so
80
+ * unloading it reverts the provider set to the host's boot condition — the multi-valued analogue of a
81
+ * swap-member reverting to its captured boot default.
82
+ */
83
+ revert(name: string): void;
84
+ }
@@ -0,0 +1,69 @@
1
+ import type { PipelineEvent } from './events.js';
2
+ import type { MessageContent, Session } from './messages.js';
3
+ import type { Principal } from './principal.js';
4
+ import type { SteeringMode } from './steering.js';
5
+ import type { PromptFn } from './tools.js';
6
+
7
+ // ── Session runner ──────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * A view onto a session returned by `SessionRunner.open`. `session` is the authoritative
11
+ * server-side state — committed messages plus an overlay of any queued-but-not-yet-run
12
+ * submissions (each carrying `metadata.pending: true`). `events` is a lazy, per-session live
13
+ * tap: accessing it subscribes to the turn event stream from now (replaying the in-flight
14
+ * turn, if any); never touching it costs nothing.
15
+ */
16
+ export interface SessionView {
17
+ session: Session;
18
+ /** Submissions waiting behind the current turn (does not count the running turn). */
19
+ queued: number;
20
+ /** Correlation id of the submission this call enqueued — present only when content was supplied. */
21
+ traceId?: string;
22
+ readonly events: AsyncIterable<PipelineEvent>;
23
+ }
24
+
25
+ /** Observe a session without submitting anything. */
26
+ export interface OpenOpts {
27
+ sessionId: string;
28
+ signal: AbortSignal;
29
+ /** Optional caller-supplied correlation id; one is generated when absent. */
30
+ traceId?: string;
31
+ }
32
+
33
+ /** Observe a session AND enqueue a submission. The compiler enforces provider/principal here;
34
+ * a remote frontend deserializing a request body must still validate the wire input itself. */
35
+ export interface SubmitOpenOpts extends OpenOpts {
36
+ content: MessageContent[];
37
+ provider: string;
38
+ principal: Principal;
39
+ /** When true, this submission may be merged with others drained in the same batch. Default false
40
+ * (queue mode: one turn per submission). */
41
+ concatQueue?: boolean;
42
+ /** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
43
+ * 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
44
+ * committed partial work — and runs this next, 'auto' defers to the registered SteeringPolicy (else
45
+ * the host default). Meaningless when nothing is running (degrades to a plain enqueue). */
46
+ mode?: SteeringMode;
47
+ /** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
48
+ * it must target the frontend's per-session client connections, not a single request. */
49
+ prompt?: PromptFn;
50
+ }
51
+
52
+ /**
53
+ * Serialises turns per session. A submission never executes concurrently with another for the
54
+ * same session; the in-memory queue (lost on process restart, by design) absorbs anything that
55
+ * arrives mid-turn. The server is the source of truth: a frontend renders whatever `open()`
56
+ * returns and treats the live `events` stream purely as an optimisation.
57
+ */
58
+ export interface SessionRunner {
59
+ open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView>;
60
+ /** Abort the running turn (if any) and drop all queued submissions, emitting `cancelled` for each. */
61
+ abort(sessionId: string): void;
62
+ /** Abandon the running turn (if any) WITHOUT touching the queue — `pump` advances to the next
63
+ * queued submission, or idles. The "give up on this turn" path (a prompt cancel); contrast
64
+ * `abort`, which also clears the queue. A no-op if nothing is running. */
65
+ cancelTurn(sessionId: string): void;
66
+ /** Snapshot of a session's live state: whether a turn is running and how many submissions wait
67
+ * behind it. `busy` is `running || queued > 0`. */
68
+ status(sessionId: string): { busy: boolean; running: boolean; queued: number };
69
+ }
@@ -0,0 +1,33 @@
1
+ import type { MessageContent, Session } from './messages.js';
2
+
3
+ // ── Steering ────────────────────────────────────────────────────────────────────
4
+
5
+ /** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
6
+ * turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
7
+ * turn boundary. */
8
+ export type SteeringDecision = 'queue' | 'interrupt';
9
+ /** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
10
+ * {@link SteeringPolicy} (else the host default). */
11
+ export type SteeringMode = SteeringDecision | 'auto';
12
+
13
+ /**
14
+ * How the runner disposes of a mid-turn submission under `mode: 'auto'`, and how an interrupt's
15
+ * continuation is nudged. An optional, registerable service ({@link MatbotServices}); absent ⇒ the
16
+ * runner uses its own defaults. Both members are optional so a plugin may override one, the other, or
17
+ * both. `classify` is deliberately NOT assumed to be an LLM — a regex or a semantic classifier is a
18
+ * first-class implementation (return synchronously); an LLM `singleTurn` returns a promise.
19
+ */
20
+ export interface SteeringPolicy {
21
+ /**
22
+ * Consulted only for `mode: 'auto'` while a turn is running. `session` is the COMMITTED session
23
+ * (history up to the running turn's start — the in-flight partial is not reachable at submit time);
24
+ * `steer` is the incoming submission. Return synchronously (regex) or async (semantic / singleTurn).
25
+ * Absent ⇒ the host default disposition.
26
+ */
27
+ classify?(ctx: { session: Session; steer: MessageContent[] }): SteeringDecision | Promise<SteeringDecision>;
28
+ /**
29
+ * The ephemeral "keep going, noting the above" context folded onto an interrupt's continuation turn
30
+ * (never persisted). Absent ⇒ the host default nudge.
31
+ */
32
+ nudge?(ctx: { session: Session; steer: MessageContent[] }): MessageContent[];
33
+ }
@@ -0,0 +1,16 @@
1
+ import type { QueryResult, StoreQuery } from '../store-query.js';
2
+
3
+ // ── Storage ───────────────────────────────────────────────────────────────────
4
+ // The query grammar (Filter AST, StoreQuery, QueryResult, StoreQueryError) lives in ./store-query.
5
+
6
+ export type CASResult<T> =
7
+ | { ok: true; doc: T }
8
+ | { ok: false; current: T | null };
9
+
10
+ export interface Store<T extends { id: string; version: string }> {
11
+ get(id: string): Promise<T | null>;
12
+ set(id: string, value: T): Promise<void>;
13
+ cas(id: string, expected: string, next: T): Promise<CASResult<T>>;
14
+ delete(id: string, expectedVersion?: string): Promise<boolean>;
15
+ query(q: StoreQuery): Promise<QueryResult<T>>;
16
+ }
@@ -0,0 +1,15 @@
1
+ import type { Session } from './messages.js';
2
+
3
+ // ── System context ────────────────────────────────────────────────────────────
4
+
5
+ export type SystemContextContributor = (ctx: {
6
+ session: Session;
7
+ signal: AbortSignal;
8
+ }) => string | null | Promise<string | null>;
9
+
10
+ export interface SystemContextRegistry {
11
+ register(contributor: SystemContextContributor, pluginName?: string): void;
12
+ removeByPlugin(pluginName: string): void;
13
+ /** Calls all contributors and joins non-null, non-empty results with double newlines. */
14
+ build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null>;
15
+ }
@@ -0,0 +1,262 @@
1
+ import type { FileHandle, FileStore } from './files.js';
2
+ import type { FormField, MessageContent, Session } from './messages.js';
3
+ import type { MatbotPlugin } from '../plugin.js';
4
+ import type { JSONSchema } from './primitives.js';
5
+ import type { Vault } from './vault.js';
6
+
7
+ // ── Tools ─────────────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Media a tool hands to the model to *look at*, as opposed to the JSON it reads as a `result`. The
11
+ * inline arms of {@link MessageContent} — bytes plus a mime type, carrying their own meaning with no
12
+ * dependency on anything that could resolve a reference. Where the tool got the bytes (a `FileStore`,
13
+ * an HTTP fetch, a chart rendered in memory) is entirely the tool's business; the harness never asks.
14
+ */
15
+ export type ModelContent = Extract<MessageContent, { type: 'image' | 'document' | 'audio' }>;
16
+
17
+ export type ToolEvent<Result = unknown> =
18
+ | { type: 'stdout'; chunk: string }
19
+ | { type: 'stderr'; chunk: string }
20
+ | { type: 'progress'; pct: number; message?: string }
21
+ | { type: 'result'; value: Result }
22
+ | { type: 'file'; handle: FileHandle }
23
+ // Media for the model's eyes, pinned to this tool call's result and carried on the wire for the
24
+ // REST OF THIS TURN only — never persisted. Independent of `result`: the durable record of what the
25
+ // tool did is its `result`, and a transcript that re-reads clean is the point. A later turn that
26
+ // needs the bytes again calls the tool again, which is the same pull the model already performed.
27
+ //
28
+ // Rest-of-turn rather than next-call-only because withdrawing content the model has already seen
29
+ // mid-history breaks the prompt cache from that point and leaves it referring to something no longer
30
+ // there. The corollary is cost: a large document is re-sent on every subsequent round of the turn,
31
+ // so a tool should hand over the smallest thing that answers the question (a page range, a thumbnail)
32
+ // and `maxRounds` is the ceiling on how often it is paid for.
33
+ | { type: 'model-content'; content: ModelContent[] }
34
+ // A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
35
+ // side-effect). Persisted as a `marker`-role message; elided from provider submission like any
36
+ // marker. Independent of `result` — a tool may emit markers and no result (a silent side-effect,
37
+ // e.g. a trigger-fired tool), a result and no markers, or both.
38
+ | { type: 'marker'; creator: string; data: unknown }
39
+ | { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
40
+
41
+ /**
42
+ * Per-tool call-contract registry: maps a tool's `name` to a {@link ToolContract} arm (or a union of
43
+ * them) pairing the `value` it yields in its `result` event with the params that produce it. Augment it
44
+ * exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the concrete result type back
45
+ * from {@link invokeTool} + `toolResult` instead of `unknown`:
46
+ *
47
+ * declare module '@matatbread/matbot-plugin-api' {
48
+ * interface ToolContracts {
49
+ * find_fact: ToolContract<string[] | null, { question: string; terms: { term: string }[] }>;
50
+ * }
51
+ * }
52
+ *
53
+ * The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
54
+ * `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
55
+ * name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
56
+ * type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
57
+ * executor already produces. Keep the entry in sync with what the executor actually yields.
58
+ *
59
+ * Even a single-action tool declares an **arm**, never a bare `name: Result`. The bare form still
60
+ * type-checks — `ToolResultOf`/`ToolResultFor` keep a plain-entry path so an unregistered or foreign
61
+ * tool stays loose — but it carries no params, so {@link ToolProxy} derives no call signature from it
62
+ * and `await tool.find_fact(…)` is uncallable.
63
+ *
64
+ * **Multi-action tools** are a weird form of overloaded function: the same tool returns different
65
+ * shapes depending on its params. Register such a tool as a union of {@link ToolContract} *arms*, each
66
+ * pairing a result with the discriminating params *pattern* that selects it — `invokeTool` matches the
67
+ * call's literal params against the patterns and narrows the result to the matching arm:
68
+ *
69
+ * declare module '@matatbread/matbot-plugin-api' {
70
+ * interface ToolContracts {
71
+ * session_action:
72
+ * | ToolContract<Session, { action: 'get' }>
73
+ * | ToolContract<{ id: string; title: string }, { action: 'rename' }>;
74
+ * }
75
+ * }
76
+ * // invokeTool(machine, 'session_action', { action: 'get', sessionId }) → result is Session
77
+ *
78
+ * The pattern is *any* discriminating field(s), not just `action` (a tool keying on `interval`'s
79
+ * presence registers `ToolContract<…, { interval: string }>`). It is a *pattern*, not the full input:
80
+ * key only on the discriminant so a call carrying just that field still matches. When no arm's pattern
81
+ * matches (a non-literal discriminant, or an absence-discriminant the positive patterns can't express),
82
+ * the result falls back to the union of every arm — always sound, just less narrow.
83
+ *
84
+ * One of matbot's five open-registry augmentation points — same technique at each; see
85
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
86
+ */
87
+ export interface ToolContracts {}
88
+
89
+ // Phantom carriers for ToolContract's two parameters. `unique symbol` keys rather than `__result`/`__args`
90
+ // so the type is *uninhabitable*: a plugin author cannot name these keys, so no value of ToolContract can
91
+ // be written down by accident (or on purpose, to smuggle a shape past the checker) — where the previous
92
+ // `__`-prefixed properties were merely conventionally-private and constructible with a couple of casts.
93
+ // A cast is the one hole through which a hallucinated result shape survives to runtime, which is why the
94
+ // generated-code checker closes it deterministically; this closes the same hole in the type itself.
95
+ declare const RESULT: unique symbol;
96
+ declare const ARGS: unique symbol;
97
+
98
+ /**
99
+ * One overload arm of a multi-action tool registered in {@link ToolContracts}: the `Result` it yields
100
+ * for a call whose params match the discriminating pattern `Args`. `Args` defaults to `unknown` (an
101
+ * arm that matches any params). Purely type-level — the fields below are phantom and uninstantiable.
102
+ */
103
+ export interface ToolContract<Result, Args = unknown> {
104
+ readonly [RESULT]: Result;
105
+ readonly [ARGS]: Args;
106
+ }
107
+
108
+ type ToolResultArmed<E> = E extends ToolContract<unknown, unknown> ? true : false;
109
+ type ToolResultUnion<E> = E extends ToolContract<infer R, unknown> ? R : never;
110
+ type ToolResultMatched<E, P> = E extends ToolContract<infer R, infer A> ? (P extends A ? R : never) : never;
111
+
112
+ /** The full set of `result` values a tool named `K` may yield — the union over all its arms (for an
113
+ * arm-based entry), the entry itself (for a plain entry), or `unknown` when `K` is unregistered. This
114
+ * is what an executor binds against (`ToolExecutor<ToolResultOf<'my_tool'>>`): it must cover every arm. */
115
+ export type ToolResultOf<K extends string> =
116
+ K extends keyof ToolContracts
117
+ ? ToolResultArmed<ToolContracts[K]> extends true ? ToolResultUnion<ToolContracts[K]> : ToolContracts[K]
118
+ : unknown;
119
+
120
+ /** The `result` a call to tool `K` with params `P` yields: for an arm-based entry, the arm whose
121
+ * pattern `P` matches (or the full union when none match — always sound); otherwise {@link ToolResultOf}. */
122
+ export type ToolResultFor<K extends string, P> =
123
+ K extends keyof ToolContracts
124
+ ? ToolResultArmed<ToolContracts[K]> extends true
125
+ ? ([ToolResultMatched<ToolContracts[K], P>] extends [never]
126
+ ? ToolResultUnion<ToolContracts[K]>
127
+ : ToolResultMatched<ToolContracts[K], P>)
128
+ : ToolContracts[K]
129
+ : unknown;
130
+
131
+ // Distribute a union to its intersection: `A | B` → `A & B`. An intersection of function types is an
132
+ // overload set, so this turns a union of per-arm call signatures into an overloaded function.
133
+ type UnionToIntersection<U> =
134
+ (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
135
+
136
+ // One `ToolContracts` arm → its call signature. A fully-untyped arm (`ToolContract<unknown, unknown>`, the
137
+ // fallback for a foreign/MCP tool carrying only a JSON-Schema `inputSchema`) keeps a loose, optional-param
138
+ // signature so it stays callable; a typed arm requires its declared params.
139
+ type ArmCallSig<E> =
140
+ E extends ToolContract<infer R, infer P>
141
+ ? (unknown extends P ? (params?: unknown) => Promise<R> : (params: P) => Promise<R>)
142
+ : never;
143
+
144
+ // The union of a tool's arm params — the parameter type of the trailing catch-all signature below.
145
+ type ArmArgsUnion<E> = E extends ToolContract<unknown, infer A> ? A : never;
146
+
147
+ /**
148
+ * The type of the injected `tool` proxy that `function-tools` and compiled skills call
149
+ * (`await tool.name(params)`). Each key is derived from its {@link ToolContracts} entry: a multi-arm
150
+ * (multi-action) entry becomes an **overload set** — one `(params) => Promise<result>` signature per arm —
151
+ * so `await tool.name(params)` narrows its result by the params passed, exactly the contract a code
152
+ * generator needs to write a correct call first time. A single-arm entry is one signature. This is what the
153
+ * generated `dts()` declares `tool` as; it is *derived* — never hand-authored — from the same `ToolContracts`
154
+ * augmentation that types authors' `invokeTool`/`toolResult` calls, so the human editor and the LLM see one
155
+ * source of truth.
156
+ *
157
+ * The trailing catch-all signature (params union → result union) exists for soundness at the *meta-type*
158
+ * level, not for call sites: overload resolution is first-match, so a well-formed call still hits its
159
+ * specific arm and narrows precisely, and a malformed call matches nothing and still errors. What it
160
+ * changes: `ReturnType<typeof tool.x>` — which on a bare overload set resolves to an arbitrary (last) arm,
161
+ * a real-world codegen trap — now degrades to the sound full union; and a params value typed as a union of
162
+ * arms (dynamic multi-action dispatch) becomes callable, returning the result union.
163
+ */
164
+ export type ToolProxy = { [K in keyof ToolContracts]:
165
+ UnionToIntersection<ArmCallSig<ToolContracts[K]>>
166
+ & ((params: ArmArgsUnion<ToolContracts[K]>) => Promise<ToolResultUnion<ToolContracts[K]>>) };
167
+
168
+ /**
169
+ * Ask the user a question and resolve with their answer. The host supplies the
170
+ * implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
171
+ * matbot's single mechanism for eliciting user input at runtime; it is injected both into
172
+ * tool execution (`ToolContext.prompt`) and into plugin loading (collision resolution in setup).
173
+ *
174
+ * Two call forms:
175
+ * - `(question, defaultValue?)` — free text; resolves to the typed string (or the default).
176
+ * - `(field: FormField)` — a single structured field (`select`/`confirm`/`password`/`text`),
177
+ * letting rich frontends render real controls (buttons, masked input). Frontends that can't
178
+ * render it fall back to `field.label` as plain text. Resolves to the chosen/typed value.
179
+ *
180
+ * Cancellation is the "we can't proceed — give up" path (distinct from a graceful "decline", which
181
+ * is simply one of the offered `options`). The host rejects the promise with `PromptCancelledError`
182
+ * and abandons the current turn, returning to idle; a caller's surrounding try/catch turns the
183
+ * rejection into a tool error that closes the tool call cleanly.
184
+ * This reuses `FormField` rather than a parallel type; it is a one-shot request/response and
185
+ * deliberately does NOT engage the session-bound `form`/`form-response` flow.
186
+ */
187
+ export interface PromptFn {
188
+ (question: string, defaultValue?: string): Promise<string>;
189
+ (field: FormField): Promise<string>;
190
+ }
191
+
192
+ export interface ToolContext {
193
+ callId: string;
194
+ session: Session;
195
+ signal: AbortSignal;
196
+ vault: Vault;
197
+ /** The provider key driving the current turn (`RunConfig.provider`). A tool that spawns further
198
+ * work should default to this so the child inherits the same model rather than the config default. */
199
+ provider?: string;
200
+ workdir?: string;
201
+ configPath?: string;
202
+ files?: FileStore;
203
+ /** Prompt the user for input. The host provides a readline or form implementation. */
204
+ prompt: PromptFn;
205
+ /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
206
+ * `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
207
+ * re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
208
+ loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
209
+ /**
210
+ * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
211
+ * Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
212
+ * to unload. A failed `teardown()` (e.g. timeout) still throws — the plugin was resident in that case.
213
+ */
214
+ unloadPlugin(specifier: string): Promise<boolean>;
215
+ }
216
+
217
+ /**
218
+ * The read-only facts about the call a composed function is running under, injected into a
219
+ * `tool_function` body as the ambient `context` binding beside `tool`/`toolInContext`. Deliberately
220
+ * narrower than {@link ToolContext}: the identity of the call, not its capabilities — `vault`,
221
+ * `files`, `prompt` and plugin (un)loading stay off a surface authored by a model and reachable only
222
+ * through the tool contracts. A tool with real source takes `ToolContext` and needs none of this.
223
+ *
224
+ * Not `ToolCallContext` — that name belongs to the `toolcall` hook's context; this is the composed
225
+ * caller's own view, not a hook's view of a call about to run.
226
+ */
227
+ export interface ComposedCallContext {
228
+ readonly callId: string;
229
+ readonly sessionId: string;
230
+ /** The provider key driving the turn — the same one nested `tool.x(…)` calls inherit. */
231
+ readonly provider?: string;
232
+ readonly workdir?: string;
233
+ readonly signal: AbortSignal;
234
+ }
235
+
236
+ /**
237
+ * A tool's runtime. `R` is the type of the `value` carried by its `result` event — declared once,
238
+ * at the source, so the executor's yields and the tool's {@link ToolContracts} registry entry can't
239
+ * silently drift. The `unknown` default keeps untyped executors compiling untouched, and covariance
240
+ * means a narrower `ToolExecutor<X>` still satisfies `ToolExecutor` (i.e. `ToolExecutor<unknown>`),
241
+ * so the heterogeneous registry boundary (`Tool[]`) accepts any executor. Bind `R` to the registry
242
+ * entry with `ToolExecutor<ToolResultOf<'my_tool'>>` so the single augmentation is the source of truth.
243
+ */
244
+ export interface ToolExecutor<R = unknown> {
245
+ execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent<R>>;
246
+ }
247
+
248
+ export interface Tool<R = unknown> {
249
+ name: string;
250
+ description: string;
251
+ inputSchema: JSONSchema;
252
+ /** The call contract as TypeScript source — a {@link ToolContract}`<Result, Params>` (or a union of arms
253
+ * for a multi-action tool), *identical in shape* to what a `ToolContracts` augmentation declares. Only a
254
+ * tool with **no scannable source** carries this — a `function-tools` function (whose signature supplies
255
+ * both halves), and any similarly runtime-defined tool: the host splices it verbatim into the generated
256
+ * dts's `ToolContracts`, and derives the wire `params`/`result` text from it. A tool WITH source declares
257
+ * its contract as a `ToolContracts` augmentation instead and omits this. Foreign tools (e.g. MCP proxies)
258
+ * that have neither fall back to the loose `inputSchema`. */
259
+ toolContract?: string;
260
+ executor: ToolExecutor<R>;
261
+ pluginName?: string;
262
+ }