@matatbread/matbot-plugin-api 0.4.13 → 0.4.15

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.4.13",
7
+ "version": "0.4.15",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/host.ts CHANGED
@@ -33,6 +33,11 @@ export * from './usage-context.js';
33
33
  // The machine half of a context switch: quiescent-edge flushers and the deferred-swap application point.
34
34
  export * from './context-switch.js';
35
35
 
36
+ // The boot `PermissionGate` a host installs as the base of the swap-member: ask through whatever
37
+ // channel is in scope, else answer the request's own `fallback`. A plugin supplying a *policy* is the
38
+ // author-facing half and needs only the `PermissionGate` type, which the root exports.
39
+ export { askPermissionGate } from './permission-gate.js';
40
+
36
41
  // Hook dispatch implementation. Plugins register hooks through `services.hooks`; only the host constructs
37
42
  // the registry that backs it.
38
43
  export { HookRegistry } from './hooks.js';
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export type * from './types.js';
12
- export { CONFIRM_YES, CONFIRM_NO, isTruncatedToolResult } from './types.js';
12
+ export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT } from './types.js';
13
13
 
14
14
  export * from './base64.js';
15
15
  export * from './bytes.js';
@@ -1,5 +1,6 @@
1
1
  import type { MatbotMachine, MatbotPlugin } from './plugin.js';
2
- import type { ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField } from './types.js';
2
+ import type { ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField, PermissionGate } from './types.js';
3
+ import { askPermissionGate } from './permission-gate.js';
3
4
 
4
5
  /** The host's plugin hot-load ops, as both the runner and {@link invokeTool} hold them. */
5
6
  export interface PluginOps {
@@ -24,6 +25,29 @@ export function bindPluginOps(host: PluginOps, prompt: PromptFn): Pick<ToolConte
24
25
  };
25
26
  }
26
27
 
28
+ /**
29
+ * Bind {@link ToolContext.gate} for a tool call, qualifying the suffix the tool supplies with the name
30
+ * it is registered under: `ctx.gate({ gate: 'add', … })` from the `plugin` tool asks `plugin.add`.
31
+ * Qualifying by TOOL name rather than package is what makes one policy cover both runtimes' versions
32
+ * of a tool (node's `tool-plugin` and the browser's `plugin-tool` both register `plugin`), and what
33
+ * stops a plugin addressing a gate it does not own — tool-name collision on `register` is already
34
+ * resolved, so the name is not something a second plugin can quietly claim.
35
+ *
36
+ * `ask` is the RAW per-turn prompt, never a stand-in that answers with a field's default: `undefined`
37
+ * is how "no human is reachable" reaches the policy, and a substitute would make that undecidable.
38
+ * `gate` is the registered `PermissionGate`; it is a non-optional service, but a hand-assembled
39
+ * machine (a test, a minimal embedder) may still have none, so the asking default stands in.
40
+ */
41
+ export function bindGate(
42
+ gate: PermissionGate | undefined,
43
+ toolName: string,
44
+ ask: PromptFn | undefined,
45
+ ): Pick<ToolContext, 'gate'> {
46
+ return {
47
+ gate: req => (gate ?? askPermissionGate).decide({ ...req, gate: `${toolName}.${req.gate}` }, ask),
48
+ };
49
+ }
50
+
27
51
  /**
28
52
  * Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
29
53
  * forwarding a call to another tool should pass its own {@link ToolContext} straight through as
@@ -44,7 +68,8 @@ export type InvokeToolOptions =
44
68
  * calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
45
69
  * plugin (un)loading, workdir/configPath/files) is filled from `machine`.
46
70
  *
47
- * Throws synchronously if no tool is registered under `name`. When no `prompt` is supplied the tool
71
+ * Throws synchronously if no tool is registered under `name`, or if `opts.signal` has already aborted.
72
+ * When no `prompt` is supplied the tool
48
73
  * runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
49
74
  * event. Pair with {@link toolText} to collapse the stream to its result string.
50
75
  */
@@ -56,6 +81,9 @@ export function invokeTool<K extends string, const P>(
56
81
  ): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
57
82
  const tool = machine.tools.resolve(name);
58
83
  if (tool === null) throw new Error(`Tool "${name}" is not registered`);
84
+ // A call whose turn was cancelled never starts. Otherwise a composed body looping over `await tool.x()`
85
+ // goes on calling tools after the abort, each callee deciding for itself whether to honour the signal.
86
+ if (opts.signal.aborted) throw new Error(`Tool "${name}" was not started: the call was cancelled.`);
59
87
 
60
88
  const prompt = opts.prompt ?? rejectingPrompt;
61
89
  const ctx: ToolContext = {
@@ -65,6 +93,10 @@ export function invokeTool<K extends string, const P>(
65
93
  vault: machine.Vault,
66
94
  prompt,
67
95
  ...bindPluginOps(machine, prompt),
96
+ // The raw `opts.prompt`, not the rejecting stand-in above: a gate must be able to tell "nobody is
97
+ // here" from "a human answered", and `POST /tools/:name` reaching a privileged tool is exactly the
98
+ // non-interactive case the request's `fallback` exists to answer.
99
+ ...bindGate(machine.PermissionGate, name, opts.prompt),
68
100
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
69
101
  ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
70
102
  ...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
@@ -0,0 +1,24 @@
1
+ import { CONFIRM_NO, CONFIRM_YES } from './types/messages.js';
2
+ import type { FormField } from './types/messages.js';
3
+ import type { PermissionGate } from './types/permission.js';
4
+
5
+ /**
6
+ * The bare asking `PermissionGate`: ask through the channel in scope, and answer `req.fallback` when
7
+ * there is no channel at all. Nothing stored — no memory, no allowlist, no settings key.
8
+ *
9
+ * The floor under a hand-assembled machine (a test, a minimal embedder) and the fallback `bindGate`
10
+ * uses when a machine carries no policy at all. matbot's own hosts boot something slightly richer:
11
+ * `@matatbread/matbot-default-gate`'s policy, which is this behaviour plus remembered standing
12
+ * answers, seeded by the host rather than loaded as a plugin — so a minimal install still has both a
13
+ * policy and the `gate_action` tool. Whatever a host seeds is what `unregister` reverts to.
14
+ *
15
+ * A structured `confirm` field, so rich frontends render real buttons and the affirmative is the
16
+ * canonical `CONFIRM_YES` token rather than a parse of a rendered (and possibly localised) label.
17
+ */
18
+ export const askPermissionGate: PermissionGate = {
19
+ async decide(req, ask) {
20
+ if (ask === undefined) return req.fallback;
21
+ const field: FormField = { name: 'confirm', label: req.label, type: 'confirm', default: CONFIRM_NO };
22
+ return (await ask(field)).trim().toLowerCase() === CONFIRM_YES;
23
+ },
24
+ };
package/src/plugin.ts CHANGED
@@ -2,7 +2,7 @@ import type {
2
2
  FileStore, MediaStore, Principal, Vault, Message, ModelParameters,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
- TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent,
5
+ TypeScriptStripper, FunctionRunner, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
6
6
  } from './types.js';
7
7
  import type { Notifications, Notifier } from './notify.js';
8
8
 
@@ -71,6 +71,21 @@ export interface PluginSettings {
71
71
  get<T>(key: string): Promise<T | undefined>;
72
72
  set<T>(key: string, value: T): Promise<void>;
73
73
  delete(key: string): Promise<void>;
74
+ /**
75
+ * Everything in force for this plugin, as one map — layered exactly as {@link get} is: a stored key
76
+ * wins, else the install's configured default. The only way to answer "what is set?", which a plugin
77
+ * holding per-key state (a policy's standing answers, a per-item override) otherwise has to answer by
78
+ * maintaining a second copy of its own keyspace — a duplicate that cannot see a configured default,
79
+ * drifts when a write and its index-write are interrupted, and reports what was *ever* written
80
+ * rather than what is set.
81
+ *
82
+ * A map rather than key/value tuples, and whole rather than a `keys()`: a settings namespace IS one
83
+ * document, so the medium hands over the values with the names, and `keys()` + N × `get` would be
84
+ * N+1 reads of the document this returns in one. Cost is therefore exactly one `get`.
85
+ *
86
+ * Scoped like every other method — a plugin enumerates its own namespace and cannot name another's.
87
+ */
88
+ entries(): Promise<Record<string, unknown>>;
74
89
  }
75
90
 
76
91
  // ── Plugin identity ─────────────────────────────────────────────────────────────
@@ -125,7 +140,7 @@ export interface PluginSelf {
125
140
  * The registry bucket: the swappable, registerable services, keyed by interface name. This is the
126
141
  * `keyof` domain of {@link MatbotRuntime.register}/`get`, and the surface third-party plugins augment
127
142
  * (`declare module '@matatbread/matbot-plugin-api' { interface MatbotServices { Foo?: Foo } }`). The
128
- * four swap-members (`StorageBackend`, `KnowledgeIndex`, `Vault`, `Notifier`) carry a host boot default
143
+ * five swap-members (`StorageBackend`, `KnowledgeIndex`, `Vault`, `Notifier`, `PermissionGate`) carry a host boot default
129
144
  * and revert to it when unregistered; an augmented service is optional and simply drops. Read each as
130
145
  * a member (`services.KnowledgeIndex`); swap with register().
131
146
  */
@@ -139,6 +154,13 @@ export interface MatbotServices {
139
154
  * reference held across a swap keeps resolving to the live backend. Always present (boot default). */
140
155
  readonly Vault: Vault;
141
156
  readonly KnowledgeIndex: KnowledgeIndex;
157
+ /** The installation's permission policy — consulted by every privileged call site (`ctx.gate`, and
158
+ * core's own tool-collision decision) and also the `register('PermissionGate', impl)` swap key.
159
+ * NOT optional: absence is not a sensible state, since the behaviour has to exist. The host boots a
160
+ * default that asks the user (or answers `req.fallback` when nobody is reachable), and unregistering
161
+ * a policy plugin reverts to it — "unload the policy ⇒ back to asking", for free. See
162
+ * {@link PermissionGate}. */
163
+ readonly PermissionGate: PermissionGate;
142
164
  /** The notification bus — every "something changed" fact, one fan-out. Also the `register('Notifier',
143
165
  * impl)` swap key: the host boots an in-process broadcaster, a plugin may swap in a distributed one
144
166
  * (and unloading it reverts to the boot default). Capture-safe behind a proxy. Always present.
@@ -169,6 +191,10 @@ export interface MatbotServices {
169
191
  * `file-ref` degrades to the converters' `[Attached file: x]` note. A text-only deployment is
170
192
  * unaffected either way. See {@link MediaStore}. */
171
193
  readonly MediaStore?: MediaStore | undefined;
194
+ /** Optional bound on how long model-authored code may run without awaiting. The node host registers
195
+ * one; absent ⇒ such code runs unbounded, as it must where synchronous code cannot be interrupted.
196
+ * See {@link FunctionRunner}. */
197
+ readonly FunctionRunner?: FunctionRunner | undefined;
172
198
  }
173
199
 
174
200
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -95,15 +95,44 @@ export type Marker<K extends string = string> = {
95
95
  data: K extends keyof MarkerData ? MarkerData[K] : unknown;
96
96
  };
97
97
 
98
+ /**
99
+ * One choice in a `select`. A bare string is the ordinary form and means "the value IS the label";
100
+ * the pair form separates what the user *reads* from what the caller gets *back*.
101
+ *
102
+ * That separation exists because a rendered label is a cosmetic, host-specific, potentially localised
103
+ * string, and using it as an identity is a footgun with a proven bite: the permission gate offered
104
+ * *Allow* and *Always allow "…"*, and matching an answer against prose read the first as the second —
105
+ * granting standing permission nobody gave. `confirm` already avoided this with {@link CONFIRM_YES} /
106
+ * {@link CONFIRM_NO}, which are tokens rather than labels; this is the same medicine for `select`.
107
+ *
108
+ * A caller that BRANCHES on the answer should use the pair form and compare values. A caller whose
109
+ * options are themselves the answer — `ask_user`, where the options are the user's own words and
110
+ * `allowOther` free text arrives on the same channel — keeps bare strings, which is why the string
111
+ * form is not deprecated.
112
+ *
113
+ * Frontends: render {@link optionLabel}, return {@link optionValue}. `default` is matched against the
114
+ * VALUE, so a default survives a reworded label.
115
+ */
116
+ export type FormOption = string | { value: string; label: string };
117
+
118
+ /** The token a picked option resolves to — what a caller branches on. */
119
+ export const optionValue = (option: FormOption): string => typeof option === 'string' ? option : option.value;
120
+
121
+ /** What the user reads. Never compared against — see {@link FormOption}. */
122
+ export const optionLabel = (option: FormOption): string => typeof option === 'string' ? option : option.label;
123
+
98
124
  export interface FormField {
99
125
  name: string;
100
126
  label: string;
101
127
  type: 'text' | 'password' | 'select' | 'confirm';
102
- options?: string[];
128
+ /** select-only: the choices. See {@link FormOption} — a bare string means value === label. */
129
+ options?: FormOption[];
103
130
  /** select-only: render an "Other…" affordance that lets the user type a free-form answer instead
104
131
  * of picking an option. The typed value is returned verbatim, on the same channel as a pick —
105
- * callers never learn whether the answer was a preset or free text. Ignored for other types. */
132
+ * callers never learn whether the answer was a preset or free text (which is why a picked option
133
+ * resolves to a string, never an index). Ignored for other types. */
106
134
  allowOther?: boolean;
135
+ /** select: matched against an option's VALUE, not its label. */
107
136
  default?: string;
108
137
  required?: boolean;
109
138
  /** Presentation hint only (default true): whether the frontend offers a cancel affordance (the
@@ -0,0 +1,46 @@
1
+ import type { PromptFn } from './tools.js';
2
+
3
+ /**
4
+ * A privileged operation asking to proceed. Boolean by construction: every gate in the system is
5
+ * allow-or-not, because the *value*-returning prompts (a secret, an API key, `ask_user`) never reach
6
+ * a gate at all — they stay on `ctx.prompt`. Authorization and elicitation are different channels,
7
+ * which is what keeps this shape from growing a routing layer.
8
+ */
9
+ export interface PermissionRequest {
10
+ /** Qualified gate id — a small, documented vocabulary (`plugin.add`, `tools.overwrite`). A tool
11
+ * supplies the SUFFIX only (`'add'`); {@link ToolContext.gate} qualifies it with the tool's
12
+ * registered name, so a plugin cannot address a gate it does not own, and tool-name collision on
13
+ * `register` closes the impersonation route. The host reserves `tools.*` for core's own gates.
14
+ * Open at runtime: a plugin this build never compiled against contributes ids, so a policy must
15
+ * treat an id it does not recognise as "ask", never as "allow". */
16
+ gate: string;
17
+ /** What the operation acts on: a tool name, a plugin specifier, a provider profile, an MCP server.
18
+ * The identifier a policy keys its memory or its delegation on — never folded into `gate`, which
19
+ * would make the vocabulary unbounded and "unknown gate ⇒ ask" unwritable. It is the identifier
20
+ * the call site *has*, not a canonical identity: at `plugin.add` the plugin is not loaded, so the
21
+ * subject is the specifier **as typed**, and a remembered allow for `@x/foo` will not match
22
+ * `https://…/foo.ts` (different trust root, different decision). */
23
+ subject: string;
24
+ /** Host- or tool-authored prose naming the specific act. The gate renders it; it never rewrites it. */
25
+ label: string;
26
+ /** What this site does when nothing can be asked and nothing decides — today's non-interactive
27
+ * behaviour, stated once per site instead of implied at each. */
28
+ fallback: boolean;
29
+ }
30
+
31
+ /**
32
+ * Decides whether a privileged operation may proceed. A swap-member of `MatbotServices`: the host
33
+ * installs a boot default (ask if a channel exists, else `req.fallback`), a plugin may replace it,
34
+ * and `unregister` reverts to the host's — so unloading a policy plugin means "back to asking" with
35
+ * no revert rule of its own.
36
+ *
37
+ * `ask` is the channel in scope for THIS call — the turn's `PromptFn`, or core's registration-path
38
+ * one — passed per call rather than held, because it is per-turn and frontend-owned. `undefined`
39
+ * means **no human is reachable**; a policy that wants to behave differently then simply checks it.
40
+ *
41
+ * A gate that wants "my rules, else the previous behaviour" captures the gate it displaces and
42
+ * delegates to it (the `ToolCallValidator` idiom), so a pair composes in either load order.
43
+ */
44
+ export interface PermissionGate {
45
+ decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<boolean>;
46
+ }
@@ -2,6 +2,7 @@ import type { FileHandle, FileStore } from './files.js';
2
2
  import type { FormField, MessageContent, Session } from './messages.js';
3
3
  import type { MatbotPlugin } from '../plugin.js';
4
4
  import type { JSONSchema } from './primitives.js';
5
+ import type { PermissionRequest } from './permission.js';
5
6
  import type { Vault } from './vault.js';
6
7
 
7
8
  // ── Tools ─────────────────────────────────────────────────────────────────────
@@ -213,6 +214,17 @@ export interface ToolContext {
213
214
  files?: FileStore;
214
215
  /** Prompt the user for input. The host provides a readline or form implementation. */
215
216
  prompt: PromptFn;
217
+ /**
218
+ * Ask permission for a privileged operation — install a plugin, remove a provider profile, add an
219
+ * MCP server. Resolves `true` to proceed. The call site declares *that* acceptance is needed; the
220
+ * registered {@link PermissionGate} decides *how* it is obtained (the default asks the user).
221
+ *
222
+ * `gate` is the SUFFIX (`'add'`): the host qualifies it with this tool's registered name, so one
223
+ * policy covers both runtimes' implementations of a tool (node's `plugin` and the browser's) and a
224
+ * plugin cannot address a gate it does not own. `fallback` is what happens when no human is
225
+ * reachable and no policy decides.
226
+ */
227
+ gate(req: PermissionRequest): Promise<boolean>;
216
228
  /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
217
229
  * `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
218
230
  * re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
@@ -17,6 +17,109 @@ export interface TypeScriptStripper {
17
17
  strip(source: string): string | Promise<string>;
18
18
  }
19
19
 
20
+ /**
21
+ * Optional, host-supplied: compiles already-stripped JavaScript into an async function whose SYNCHRONOUS
22
+ * execution is bounded. Model-authored code (a `tool_function` body) runs on the one event loop every
23
+ * session, frontend and schedule shares, so a loop that never awaits freezes all of them — and nothing
24
+ * in-process can stop it, an abort signal included, since stopping needs the loop to yield. Only the
25
+ * stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
26
+ *
27
+ * Absent ⇒ a consumer compiles with `new AsyncFunction` and runs unbounded, which is all a platform with
28
+ * no way to interrupt synchronous code (the browser) can do. An embedder may register its own.
29
+ */
30
+ export interface FunctionRunner {
31
+ /** Compile `body` as the body of an async function taking `params`. Throws on a syntax error. A call
32
+ * stopped at the limit rejects with an error whose `code` is {@link FUNCTION_TIMEOUT}. */
33
+ compile(params: readonly string[], body: string): (...args: unknown[]) => Promise<unknown>;
34
+ }
35
+
36
+ /** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
37
+ * consumer can tell a stopped runaway from an ordinary failure without matching message text. */
38
+ export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
39
+
40
+ /**
41
+ * One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
42
+ *
43
+ * `rendered` travels WITH the record rather than being left for the consumer to rebuild: the annotated
44
+ * form (caret-anchored frame, related locations, a directed hint) is the thing a model repairs from,
45
+ * and the renderer lives behind the service. So a consumer displays `rendered` and counts, groups or
46
+ * routes on the fields — instead of regexing `line \d+ TS\d+` back out of prose, which is what the
47
+ * flattened `string[]` made every consumer do.
48
+ */
49
+ export interface ToolCheckDiagnostic {
50
+ /** The rule's one name, in every renderer: `TS2339`, or `CAST-GATE` for a structural cast-gate finding. */
51
+ label: string;
52
+ /** The numeric code. Cast-gate findings use a private 9000x range — read {@link syn}, not the number. */
53
+ code: number;
54
+ message: string;
55
+ file?: string;
56
+ line?: number;
57
+ col?: number;
58
+ /** Caret-anchored source frame. */
59
+ frame?: string;
60
+ related?: string[];
61
+ hint?: string;
62
+ /** A structural cast-gate finding (a rule of this harness) rather than a `tsc` error. */
63
+ syn?: true;
64
+ /** The annotated block for display: message, frame, related locations and hint. */
65
+ rendered: string;
66
+ }
67
+
68
+ /**
69
+ * The outcome of a {@link ToolTypeIndex.check}. `ok` is `total === 0`.
70
+ *
71
+ * Detail is capped, because a cascade of errors must not become the bulk of a turn's context — but the
72
+ * cap is expressed as data, not smuggled in as a prose element of the findings array. It was appended as
73
+ * one: `diagnostics.length` then counted a summary line as a finding, and any per-code breakdown derived
74
+ * by iterating the array was wrong wherever an overflow had occurred.
75
+ */
76
+ export interface ToolCheckReport {
77
+ ok: boolean;
78
+ /**
79
+ * Whether a check actually RAN. It qualifies `ok`: `ok: true` with `checked: false` means nothing was
80
+ * examined, not that the source is sound — an index that cannot type-check (the browser, which has no
81
+ * TypeScript program) reports exactly that.
82
+ *
83
+ * Required, not optional, because the alternative is the failure this whole path is built to avoid: a
84
+ * checker that reports success while checking nothing is indistinguishable from one that works, and a
85
+ * caller recording "verified" off the back of it records something false.
86
+ */
87
+ checked: boolean;
88
+ /** Every finding, including those not detailed below — the number to report. */
89
+ total: number;
90
+ /** The detailed findings, capped. */
91
+ diagnostics: ToolCheckDiagnostic[];
92
+ /** Present only when the cap hid some, tallied by the same `label` the detailed ones carry. */
93
+ omitted?: { count: number; byLabel: Record<string, number> };
94
+ }
95
+
96
+ /**
97
+ * A {@link ToolCheckReport} as the text an author — usually a model repairing its own code — reads:
98
+ * each finding's annotated block, then the overflow summary as a TRAILING LINE.
99
+ *
100
+ * Here rather than in the checker because two packages render one report: the node checker that
101
+ * produces it, and any consumer that must put it in front of whoever wrote the code. The overflow line
102
+ * is the reason it is worth sharing at all — appending it to the findings ARRAY instead is exactly the
103
+ * bug this shape exists to prevent, and one renderer is one place for that to stay true.
104
+ */
105
+ export function renderToolCheck(report: ToolCheckReport): string {
106
+ const parts = report.diagnostics.map(d => d.rendered);
107
+ if (report.omitted !== undefined) parts.push(renderToolCheckOmitted(report.omitted));
108
+ return parts.join('\n');
109
+ }
110
+
111
+ /** The overflow summary alone — one line, for a consumer laying the findings out itself. Separate so it
112
+ * can be had without rendering every finding in order to keep the last line of the result. */
113
+ export function renderToolCheckOmitted(omitted: NonNullable<ToolCheckReport['omitted']>): string {
114
+ const tally = Object.entries(omitted.byLabel).map(([l, n]) => `${l}×${n}`).join(', ');
115
+ // The cascade advice is about tsc's own errors: a cast-gate finding is a structural rule fired at one
116
+ // site, cascades from nothing, and inviting a reader to expect it to vanish with the first fix is what
117
+ // would get it ignored.
118
+ const cascade = Object.keys(omitted.byLabel).some(l => l.startsWith('TS'))
119
+ ? ' — likely cascading from the errors above.' : '';
120
+ return `…plus ${omitted.count} more: ${tally}${cascade}`;
121
+ }
122
+
20
123
  /**
21
124
  * Optional, node-only developer-experience service: the live `.d.ts` of the types the loaded tools
22
125
  * expose — what `toolResult(invokeTool(…, name, …))`, or a `function-tools` `await tool.name(…)`, resolves
@@ -38,9 +141,9 @@ export interface ToolTypeIndex {
38
141
  dts(): Promise<string>;
39
142
  /** Type-check a TypeScript `snippet` against exactly the {@link dts} above — the `tool` proxy ({@link
40
143
  * ToolProxy}: each multi-action tool an overload set, so `await tool.x(params)` narrows its result by the
41
- * params) is in scope. Returns human-readable diagnostics scoped to the snippet ([] means clean). A
144
+ * params) is in scope. Returns a {@link ToolCheckReport} whose positions are snippet-relative. A
42
145
  * composer uses it to catch bad tool-call code before running/registering it. */
43
- check(snippet: string): Promise<string[]>;
146
+ check(snippet: string): Promise<ToolCheckReport>;
44
147
  /** Per live tool (that has a contract): the `params`/`result` wire text, flattened from the one contract —
45
148
  * a source tool's `ToolContracts` arms (via the source scan) or a source-less tool's `toolContract` string.
46
149
  * The single contract is thus also the source of the wire description; the host folds this into the
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@ export * from './types/storage.js';
17
17
  export * from './types/knowledge.js';
18
18
  export * from './types/typescript.js';
19
19
  export * from './types/tools.js';
20
+ export * from './types/permission.js';
20
21
  export * from './types/builtin-tools.js';
21
22
  export * from './types/files.js';
22
23
  export * from './types/frontend.js';