@matatbread/matbot-plugin-api 0.4.11 → 0.4.13

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.11",
7
+ "version": "0.4.13",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/errors.ts CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  const BRAND = 'matbot';
18
18
 
19
- export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly' | 'MediaRejected';
19
+ export type MatbotErrorKind = 'MissingSecret' | 'InvalidSecretName' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly' | 'MediaRejected';
20
20
 
21
21
  function isKind(e: unknown, kind: MatbotErrorKind): boolean {
22
22
  return typeof e === 'object' && e !== null && (e as Record<string, unknown>)[BRAND] === kind;
@@ -40,6 +40,33 @@ export function isMissingSecretError(e: unknown): e is MissingSecretError {
40
40
  return isKind(e, 'MissingSecret');
41
41
  }
42
42
 
43
+ /**
44
+ * Thrown by a vault write when the backend cannot store a secret under the requested name — the
45
+ * name is the caller's, and most callers are an LLM inventing one, so it is regularly unstorable
46
+ * (a `.env`-backed vault takes environment-variable names; `email:abc:password` is silently
47
+ * dropped by systemd on the next boot). Refusing at the write is the only point that still knows
48
+ * which name was asked for.
49
+ *
50
+ * `rule` states what IS storable, not what went wrong: it is written for whoever must pick another
51
+ * name, and the caller that surfaces this — a prompt, a tool error — has no other source for it.
52
+ * Which names qualify is the backend's own business (`VaultSpec.unstorableKey`), so `rule` comes
53
+ * from there verbatim.
54
+ */
55
+ export interface InvalidSecretNameError extends Error {
56
+ matbot: 'InvalidSecretName';
57
+ /** The rejected name. `key`, not `name` — `Error.name` is already the error's own type name. */
58
+ readonly key: string;
59
+ readonly rule: string;
60
+ }
61
+ export function invalidSecretNameError(key: string, rule: string): InvalidSecretNameError {
62
+ const e = new Error(`Vault: cannot store a secret named "${key}" — ${rule}`);
63
+ e.name = 'InvalidSecretNameError';
64
+ return Object.assign(e, { matbot: 'InvalidSecretName' as const, key, rule });
65
+ }
66
+ export function isInvalidSecretNameError(e: unknown): e is InvalidSecretNameError {
67
+ return isKind(e, 'InvalidSecretName');
68
+ }
69
+
43
70
  /**
44
71
  * Thrown by the loader when an explicit, single, user-initiated load (the `plugin`/`provider`
45
72
  * tools via `services.loadPlugin`) targets a plugin whose `matbotRuntime` excludes this host.
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ 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
 
package/src/notify.ts CHANGED
@@ -54,6 +54,21 @@ export interface NotificationBase {
54
54
  export interface ItemChange extends NotificationBase {
55
55
  readonly namespace: string;
56
56
  readonly id: string;
57
+ /**
58
+ * The **caller's** key for this item, where `id` is derived from it rather than equal to it.
59
+ *
60
+ * `id` is the medium's address, and a medium is entitled to shape it: settings namespaces are plugin
61
+ * package names slugged to satisfy the filesystem store's `/^[\w-]+$/`, and another backend could
62
+ * legitimately hash or truncate. A consumer routing on identity — "is this MY document?" — otherwise
63
+ * has to reproduce a transformation it does not own, in a copy that keeps compiling after the rule
64
+ * changes and simply stops matching. That failure is silent and indistinguishable from "nothing
65
+ * changed", which is the opposite of what an invalidation is for.
66
+ *
67
+ * So the producer states the key it was addressed by, and a consumer compares names. Present only
68
+ * where the two genuinely differ — `sessions` and `files` address items by the id they were given,
69
+ * and an absent `key` means exactly that.
70
+ */
71
+ readonly key?: string;
57
72
  readonly operation: 'saved' | 'deleted';
58
73
  readonly detail?: unknown;
59
74
  }
@@ -221,14 +236,22 @@ export function scopedNotifier(notifier: Notifier, plugin: string): Notifier {
221
236
  * partition for visibility filtering). It observes writes made THROUGH it — a second process, or a
222
237
  * writer that bypassed this store, is not seen; that is what a backend's own watch, where one exists,
223
238
  * is for.
239
+ *
240
+ * `keyOf` supplies {@link ItemChange.key} for a namespace whose document ids are DERIVED from the
241
+ * caller's key (settings: a plugin name slugged to a legal id) — a function of the id rather than a
242
+ * constant, because this wraps a whole store and a per-document answer is the only honest shape.
243
+ * Omit it where the id IS the key, which is every other namespace today.
224
244
  */
225
245
  export function notifyingStore<T extends { id: string; version: string }>(
226
246
  store: Store<T>, notifier: Notifier, namespace: string, source: string,
247
+ keyOf?: (id: string) => string | undefined,
227
248
  ): Store<T> {
228
249
  const announce = (operation: 'saved' | 'deleted', id: string): void => {
229
250
  const principal = tryCurrentPrincipal();
251
+ const key = keyOf?.(id);
230
252
  notifier.notify({
231
253
  kind: ItemChangeKind, source, operation, namespace, id,
254
+ ...(key !== undefined ? { key } : {}),
232
255
  ...(principal !== undefined ? { principal } : {}),
233
256
  });
234
257
  };
@@ -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 }>;
@@ -105,6 +105,17 @@ export interface ToolContract<Result, Args = unknown> {
105
105
  readonly [ARGS]: Args;
106
106
  }
107
107
 
108
+ /**
109
+ * The params of a tool that takes none — an object with no properties.
110
+ *
111
+ * Spelled out because both obvious alternatives are wrong. **`{}` is not the empty object type**: in
112
+ * TypeScript it means "any non-nullish value", so `ToolContract<R, {}>` accepts `'hello'` and `42` and
113
+ * describes nothing. And **omitting `Args` is looser still**: it defaults to `unknown`, which a
114
+ * validator can only pass, so a caller sending `null` — or anything at all — is accepted. Both read as
115
+ * "no parameters" and neither says it.
116
+ */
117
+ export type NoParams = Record<string, never>;
118
+
108
119
  type ToolResultArmed<E> = E extends ToolContract<unknown, unknown> ? true : false;
109
120
  type ToolResultUnion<E> = E extends ToolContract<infer R, unknown> ? R : never;
110
121
  type ToolResultMatched<E, P> = E extends ToolContract<infer R, infer A> ? (P extends A ? R : never) : never;
@@ -22,6 +22,18 @@ export interface VaultSpec {
22
22
  * reverse-index omit it, and `createSecret`'s dedup step is skipped.
23
23
  */
24
24
  findByValue?(value: string): string | undefined;
25
+ /**
26
+ * Why this backend cannot store a secret under `name` — `undefined` when it can. What is storable
27
+ * is the *backend's* business: an in-memory map takes anything referenceable, a `.env`-backed one
28
+ * takes environment-variable names, a header-based one may exclude `:`.
29
+ *
30
+ * The returned string is the RULE, phrased for whoever must now choose another name (often an LLM
31
+ * that invented the rejected one), not a restatement of the failure — it is the only place that
32
+ * knowledge exists, and it is what rides on the thrown {@link InvalidSecretNameError}. Enforced by
33
+ * `assertStorableKey`, which every backend's `writeSecret` calls before storing; a backend omitting
34
+ * this method accepts any name.
35
+ */
36
+ unstorableKey?(name: string): string | undefined;
25
37
  }
26
38
 
27
39
  export interface Vault extends VaultSpec {
package/src/vault.ts CHANGED
@@ -1,4 +1,34 @@
1
1
  import type { VaultSpec } from './types.js';
2
+ import { invalidSecretNameError } from './errors.js';
3
+
4
+ /**
5
+ * The rule every vault shares, whatever else it adds: a secret is only reachable through a
6
+ * `${NAME}` placeholder, so a name carrying whitespace, `$`, `{` or `}` — or no characters at all —
7
+ * can be written and never read back. Backends narrow this (see `EnvFileVault`); none widen it.
8
+ */
9
+ export const REFERENCEABLE_KEY_RULE =
10
+ 'a secret name must be non-empty and contain no whitespace, `$`, `{` or `}`, so that it can be referenced as ${NAME}';
11
+
12
+ /** The shared base policy, for a backend whose `unstorableKey` adds nothing of its own. */
13
+ export function unreferenceableKey(name: string): string | undefined {
14
+ return /^[^\s${}]+$/.test(name) ? undefined : REFERENCEABLE_KEY_RULE;
15
+ }
16
+
17
+ /**
18
+ * Refuse a name the backend cannot store, rather than writing something that silently disappears —
19
+ * the `.env` line systemd discards, the header a proxy strips. Called by every backend's
20
+ * `writeSecret` before it stores, which is the one point that still knows the requested name:
21
+ * `createSecret` may canonicalise it away, and by the time a reader misses the secret there is
22
+ * nothing left to blame.
23
+ *
24
+ * Removal (`writeSecret(name, '')`) deliberately does not go through here — a name that became
25
+ * unstorable, or arrived from an environment snapshot that never passed the policy, must still be
26
+ * deletable.
27
+ */
28
+ export function assertStorableKey(spec: VaultSpec, name: string): void {
29
+ const rule = spec.unstorableKey?.(name);
30
+ if (rule !== undefined) throw invalidSecretNameError(name, rule);
31
+ }
2
32
 
3
33
  /**
4
34
  * The `createSecret` policy, written once over the spec primitives so every backend behaves