@matatbread/matbot-plugin-api 0.4.12 → 0.4.14

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.12",
7
+ "version": "0.4.14",
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,13 +9,14 @@
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 } from './types.js';
13
13
 
14
14
  export * from './base64.js';
15
15
  export * from './bytes.js';
16
16
  export * from './store-query.js';
17
17
  export * from './errors.js';
18
18
  export * from './vault.js';
19
+ export * from './provider-patch.js';
19
20
  export * from './session.js';
20
21
  export * from './invoke-tool.js';
21
22
 
@@ -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
@@ -65,6 +89,10 @@ export function invokeTool<K extends string, const P>(
65
89
  vault: machine.Vault,
66
90
  prompt,
67
91
  ...bindPluginOps(machine, prompt),
92
+ // The raw `opts.prompt`, not the rejecting stand-in above: a gate must be able to tell "nobody is
93
+ // here" from "a human answered", and `POST /tools/:name` reaching a privileged tool is exactly the
94
+ // non-interactive case the request's `fallback` exists to answer.
95
+ ...bindGate(machine.PermissionGate, name, opts.prompt),
68
96
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
69
97
  ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
70
98
  ...(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, 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.
@@ -0,0 +1,33 @@
1
+ import type { ProviderConfig } from './types.js';
2
+ import type { ProviderPatch } from './types/builtin-tools.js';
3
+
4
+ /**
5
+ * The `provider update` policy, written once so every implementer of it behaves identically — the same
6
+ * reasoning as `applyCreateSecret` in `vault.ts`. Two hosts apply this patch (the node `provider` tool
7
+ * against matbot.yaml, the browser bootstrap against its persisted map) and the *semantics* of a patch
8
+ * must not be one of the things they differ on: `null` clears a field, absent leaves it alone.
9
+ *
10
+ * `parameters` is replaced wholesale rather than merged per key. Its values are forwarded to the
11
+ * endpoint unmodified and so have no shape to merge against — and `provider list` reports them in full,
12
+ * which is what makes a read-modify-write something a caller can actually carry out. (Contrast
13
+ * `default_settings`, which merges per key: there the keys are matbot's own.)
14
+ *
15
+ * `credentials` and `module` are absent from `ProviderPatch` and therefore ride through untouched; see
16
+ * that type for why neither belongs in an update.
17
+ */
18
+ export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch): ProviderConfig {
19
+ const next: ProviderConfig = { ...cur };
20
+ // Spelled out per field rather than looped: `exactOptionalPropertyTypes` makes "assign, or delete if
21
+ // null" a different statement for each type, and a loop only reaches them through a cast that would
22
+ // stop the compiler checking the one thing worth checking here.
23
+ if (patch.model !== undefined) next.model = patch.model;
24
+ if (patch.endpoint !== undefined) { if (patch.endpoint === null) delete next.endpoint; else next.endpoint = patch.endpoint; }
25
+ if (patch.parameters !== undefined) { if (patch.parameters === null) delete next.parameters; else next.parameters = patch.parameters; }
26
+ if (patch.maxRounds !== undefined) { if (patch.maxRounds === null) delete next.maxRounds; else next.maxRounds = patch.maxRounds; }
27
+ return next;
28
+ }
29
+
30
+ /** The fields a patch actually changes, in a stable order — for a confirmation prompt or a result message. */
31
+ export function patchedFields(patch: ProviderPatch): readonly ('model' | 'endpoint' | 'parameters' | 'maxRounds')[] {
32
+ return (['model', 'endpoint', 'parameters', 'maxRounds'] as const).filter(f => patch[f] !== undefined);
33
+ }
@@ -174,6 +174,31 @@ export interface ProviderActionResult {
174
174
  message: string;
175
175
  }
176
176
 
177
+ /**
178
+ * The fields `provider update` can change on an existing profile, and the whole of them.
179
+ *
180
+ * `null` clears a field, absent leaves it alone — `undefined` cannot mean "clear", since it is already
181
+ * what an unsupplied optional looks like. `model` takes no `null`: a profile without one is unrunnable.
182
+ *
183
+ * Two fields are deliberately absent. **`credentials`**, because it is the one member of a profile that
184
+ * `provider list` does not report back (it projects to `hasCredentials`, and a secret must not ride the
185
+ * transcript) — so a caller cannot read-modify-write it, and an `update` that accepted it would have to
186
+ * guess between rewriting a `${NAME}` reference and replacing a literal. Credentials are
187
+ * `plugin store-key`'s, which addresses the vault name directly. And **`module`**, because changing the
188
+ * adapter re-opens every resolution question `add` answers (is it loaded, does it resolve by package
189
+ * name, is it even a provider adapter); `remove` + `add` is the honest route for that.
190
+ *
191
+ * A named shape rather than an inline arm because the browser's `ProviderAdmin.update` takes the same
192
+ * patch, and because it is the augmentable half — a host whose profiles carry an extra field adds it
193
+ * here without touching `ToolContracts`.
194
+ */
195
+ export interface ProviderPatch {
196
+ model?: string;
197
+ endpoint?: string | null;
198
+ parameters?: ModelParameters | null;
199
+ maxRounds?: number | null;
200
+ }
201
+
177
202
  /**
178
203
  * The `provider` tool's contract. Both implementations declare `provider: ProviderToolContract`.
179
204
  *
@@ -181,6 +206,8 @@ export interface ProviderActionResult {
181
206
  * supplies its own, while node's executor requires one; `credentialKey`/`credentialEnvVar` name where
182
207
  * node's vault should hold the secret, and the browser (which prompts and stores under its own key)
183
208
  * ignores them.
209
+ *
210
+ * `update` carries no credential of any kind — see {@link ProviderPatch}.
184
211
  */
185
212
  export type ProviderToolContract =
186
213
  | ToolContract<ProviderListResult, { action: 'list' }>
@@ -195,4 +222,5 @@ export type ProviderToolContract =
195
222
  parameters?: ModelParameters;
196
223
  maxRounds?: number;
197
224
  }>
225
+ | ToolContract<ProviderActionResult, { action: 'update'; name: string } & ProviderPatch>
198
226
  | ToolContract<ProviderActionResult, { action: 'remove'; name: string }>;
@@ -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,89 @@ export interface TypeScriptStripper {
17
17
  strip(source: string): string | Promise<string>;
18
18
  }
19
19
 
20
+ /**
21
+ * One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
22
+ *
23
+ * `rendered` travels WITH the record rather than being left for the consumer to rebuild: the annotated
24
+ * form (caret-anchored frame, related locations, a directed hint) is the thing a model repairs from,
25
+ * and the renderer lives behind the service. So a consumer displays `rendered` and counts, groups or
26
+ * routes on the fields — instead of regexing `line \d+ TS\d+` back out of prose, which is what the
27
+ * flattened `string[]` made every consumer do.
28
+ */
29
+ export interface ToolCheckDiagnostic {
30
+ /** The rule's one name, in every renderer: `TS2339`, or `CAST-GATE` for a structural cast-gate finding. */
31
+ label: string;
32
+ /** The numeric code. Cast-gate findings use a private 9000x range — read {@link syn}, not the number. */
33
+ code: number;
34
+ message: string;
35
+ file?: string;
36
+ line?: number;
37
+ col?: number;
38
+ /** Caret-anchored source frame. */
39
+ frame?: string;
40
+ related?: string[];
41
+ hint?: string;
42
+ /** A structural cast-gate finding (a rule of this harness) rather than a `tsc` error. */
43
+ syn?: true;
44
+ /** The annotated block for display: message, frame, related locations and hint. */
45
+ rendered: string;
46
+ }
47
+
48
+ /**
49
+ * The outcome of a {@link ToolTypeIndex.check}. `ok` is `total === 0`.
50
+ *
51
+ * Detail is capped, because a cascade of errors must not become the bulk of a turn's context — but the
52
+ * cap is expressed as data, not smuggled in as a prose element of the findings array. It was appended as
53
+ * one: `diagnostics.length` then counted a summary line as a finding, and any per-code breakdown derived
54
+ * by iterating the array was wrong wherever an overflow had occurred.
55
+ */
56
+ export interface ToolCheckReport {
57
+ ok: boolean;
58
+ /**
59
+ * Whether a check actually RAN. It qualifies `ok`: `ok: true` with `checked: false` means nothing was
60
+ * examined, not that the source is sound — an index that cannot type-check (the browser, which has no
61
+ * TypeScript program) reports exactly that.
62
+ *
63
+ * Required, not optional, because the alternative is the failure this whole path is built to avoid: a
64
+ * checker that reports success while checking nothing is indistinguishable from one that works, and a
65
+ * caller recording "verified" off the back of it records something false.
66
+ */
67
+ checked: boolean;
68
+ /** Every finding, including those not detailed below — the number to report. */
69
+ total: number;
70
+ /** The detailed findings, capped. */
71
+ diagnostics: ToolCheckDiagnostic[];
72
+ /** Present only when the cap hid some, tallied by the same `label` the detailed ones carry. */
73
+ omitted?: { count: number; byLabel: Record<string, number> };
74
+ }
75
+
76
+ /**
77
+ * A {@link ToolCheckReport} as the text an author — usually a model repairing its own code — reads:
78
+ * each finding's annotated block, then the overflow summary as a TRAILING LINE.
79
+ *
80
+ * Here rather than in the checker because two packages render one report: the node checker that
81
+ * produces it, and any consumer that must put it in front of whoever wrote the code. The overflow line
82
+ * is the reason it is worth sharing at all — appending it to the findings ARRAY instead is exactly the
83
+ * bug this shape exists to prevent, and one renderer is one place for that to stay true.
84
+ */
85
+ export function renderToolCheck(report: ToolCheckReport): string {
86
+ const parts = report.diagnostics.map(d => d.rendered);
87
+ if (report.omitted !== undefined) parts.push(renderToolCheckOmitted(report.omitted));
88
+ return parts.join('\n');
89
+ }
90
+
91
+ /** The overflow summary alone — one line, for a consumer laying the findings out itself. Separate so it
92
+ * can be had without rendering every finding in order to keep the last line of the result. */
93
+ export function renderToolCheckOmitted(omitted: NonNullable<ToolCheckReport['omitted']>): string {
94
+ const tally = Object.entries(omitted.byLabel).map(([l, n]) => `${l}×${n}`).join(', ');
95
+ // The cascade advice is about tsc's own errors: a cast-gate finding is a structural rule fired at one
96
+ // site, cascades from nothing, and inviting a reader to expect it to vanish with the first fix is what
97
+ // would get it ignored.
98
+ const cascade = Object.keys(omitted.byLabel).some(l => l.startsWith('TS'))
99
+ ? ' — likely cascading from the errors above.' : '';
100
+ return `…plus ${omitted.count} more: ${tally}${cascade}`;
101
+ }
102
+
20
103
  /**
21
104
  * Optional, node-only developer-experience service: the live `.d.ts` of the types the loaded tools
22
105
  * expose — what `toolResult(invokeTool(…, name, …))`, or a `function-tools` `await tool.name(…)`, resolves
@@ -38,9 +121,9 @@ export interface ToolTypeIndex {
38
121
  dts(): Promise<string>;
39
122
  /** Type-check a TypeScript `snippet` against exactly the {@link dts} above — the `tool` proxy ({@link
40
123
  * 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
124
+ * params) is in scope. Returns a {@link ToolCheckReport} whose positions are snippet-relative. A
42
125
  * composer uses it to catch bad tool-call code before running/registering it. */
43
- check(snippet: string): Promise<string[]>;
126
+ check(snippet: string): Promise<ToolCheckReport>;
44
127
  /** Per live tool (that has a contract): the `params`/`result` wire text, flattened from the one contract —
45
128
  * a source tool's `ToolContracts` arms (via the source scan) or a source-less tool's `toolContract` string.
46
129
  * 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';