@matatbread/matbot-plugin-api 0.4.7 → 0.4.9

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.7",
7
+ "version": "0.4.9",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/base64.ts ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Base64 over bytes. Not part of the plugin *contract*, but shared by core, workspace and telegram —
3
+ * the AsyncIterable-broadcaster case: a small stable utility earns a home here once a second package
4
+ * needs it, rather than being copy-pasted. No `Buffer`: these run in the browser too.
5
+ */
6
+
7
+ /** Bytes, not text: `atob` alone yields latin-1 code units, which is wrong for anything non-ASCII. */
8
+ export function decodeBase64(data: string): Uint8Array {
9
+ return Uint8Array.from(atob(data), ch => ch.charCodeAt(0));
10
+ }
11
+
12
+ export function encodeBase64(bytes: Uint8Array): string {
13
+ // Chunked: String.fromCharCode(...bytes) blows the argument limit somewhere around a megabyte, and a
14
+ // per-byte loop is an order of magnitude slower on the multi-megabyte files media allows.
15
+ const CHUNK = 0x8000;
16
+ let bin = '';
17
+ for (let i = 0; i < bytes.length; i += CHUNK) bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
18
+ return btoa(bin);
19
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Byte-stream helpers. Like ./base64.js: not part of the plugin *contract*, but shared often enough
3
+ * that a copy per package was already drifting. Nothing node-specific — no `Buffer`.
4
+ */
5
+
6
+ /**
7
+ * Drain a byte stream into one array. `Uint8Array<ArrayBuffer>` rather than the `ArrayBufferLike`
8
+ * default because callers hand the result to APIs that reject a `SharedArrayBuffer`-backed view
9
+ * (an OPFS writable, a Drive upload body), and `new Uint8Array(n)` satisfies it for free.
10
+ */
11
+ export async function collectBytes(data: AsyncIterable<Uint8Array>): Promise<Uint8Array<ArrayBuffer>> {
12
+ const chunks: Uint8Array[] = [];
13
+ let total = 0;
14
+ for await (const chunk of data) { chunks.push(chunk); total += chunk.byteLength; }
15
+ const out = new Uint8Array(total);
16
+ let at = 0;
17
+ for (const chunk of chunks) { out.set(chunk, at); at += chunk.byteLength; }
18
+ return out;
19
+ }
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';
19
+ export type MatbotErrorKind = 'MissingSecret' | '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;
@@ -125,3 +125,36 @@ export function readOnlyError(namespace: string, id: string, owner: string): Rea
125
125
  export function isReadOnlyError(e: unknown): e is ReadOnlyError {
126
126
  return isKind(e, 'ReadOnly');
127
127
  }
128
+
129
+ /**
130
+ * Thrown by `SessionRunner.open()` when media attached to a submission cannot be accepted. Refusing at
131
+ * the boundary is the whole point: the alternative is a provider 400 part-way through a turn the user
132
+ * already believes was sent, with no way back. `reason` says which limit bit, and the message NAMES the
133
+ * offending file and its size — a bare "too large" leaves a person guessing which of five attachments
134
+ * to drop.
135
+ *
136
+ * A frontend catches this (via {@link isMediaRejectedError}, never `instanceof`) and tells the user;
137
+ * nothing has been enqueued, so there is no turn to clean up.
138
+ */
139
+ export interface MediaRejectedError extends Error {
140
+ matbot: 'MediaRejected';
141
+ /** `no-store` — nothing is registered under `MediaStore`, so there is nowhere to put the bytes.
142
+ * `too-large` — one file exceeds the per-file cap. `session-quota` — this session's stored media
143
+ * would exceed its total. `unreadable` — the bytes were malformed: not valid base64, or not the file
144
+ * type they claim (a renamed, truncated or placeholder file, caught by a magic-byte check).
145
+ * `unsupported-type` — a type no provider decodes (an iPhone HEIC, an SVG), which would 400 the
146
+ * request rather than degrade. `unknown-ref` — a submitted `file-ref` does not name media attached
147
+ * to this session; phrased as unknown rather than forbidden, exactly as `GET /media/:id` reports a
148
+ * file it will not serve, so a refusal never confirms that an id exists. */
149
+ readonly reason: 'no-store' | 'too-large' | 'session-quota' | 'unreadable' | 'unsupported-type' | 'unknown-ref';
150
+ /** The attachment's display name, when one file is to blame. */
151
+ readonly file?: string;
152
+ }
153
+ export function mediaRejectedError(reason: MediaRejectedError['reason'], message: string, file?: string): MediaRejectedError {
154
+ const e = new Error(message);
155
+ e.name = 'MediaRejectedError';
156
+ return Object.assign(e, { matbot: 'MediaRejected' as const, reason, ...(file !== undefined ? { file } : {}) });
157
+ }
158
+ export function isMediaRejectedError(e: unknown): e is MediaRejectedError {
159
+ return isKind(e, 'MediaRejected');
160
+ }
@@ -206,7 +206,7 @@ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
206
206
  provider: req.provider,
207
207
  messages: [{
208
208
  id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
209
- content: [{ type: 'text', text: req.prompt }],
209
+ content: typeof req.prompt === 'string' ? [{ type: 'text', text: req.prompt }] : req.prompt,
210
210
  }],
211
211
  ...(req.system !== undefined ? { system: req.system } : {}),
212
212
  ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  export type * from './types.js';
12
12
  export { CONFIRM_YES, CONFIRM_NO, isTruncatedToolResult } from './types.js';
13
13
 
14
+ export * from './base64.js';
15
+ export * from './bytes.js';
14
16
  export * from './store-query.js';
15
17
  export * from './errors.js';
16
18
  export * from './vault.js';
package/src/plugin.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type {
2
- FileStore, Principal, Vault, Message, ModelParameters,
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,
5
+ TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent,
6
6
  } from './types.js';
7
7
  import type { Notifications, Notifier } from './notify.js';
8
8
 
@@ -53,7 +53,11 @@ export interface CompletionResponse {
53
53
 
54
54
  export interface SingleTurnRequest {
55
55
  provider: string;
56
- prompt: string;
56
+ /** The one user message. A plain string is the common case; a {@link UserContent}[] carries media
57
+ * alongside the text, for a one-shot that has to *look* at something. Any `file-ref` arm is passed
58
+ * through as-is — a one-shot has no session and no residency window, so the caller resolves bytes
59
+ * itself (as the `single_turn` tool's `attach` does) rather than the builder guessing a store. */
60
+ prompt: string | UserContent[];
57
61
  system?: string;
58
62
  /** Forwarded verbatim to {@link CompletionRequest.parameters} — same merge, same guidance. */
59
63
  parameters?: Partial<ModelParameters>;
@@ -158,6 +162,13 @@ export interface MatbotServices {
158
162
  * runner uses its own defaults. A plain registered service, consumed by the runner as a member.
159
163
  * See {@link SteeringPolicy}. */
160
164
  readonly SteeringPolicy?: SteeringPolicy | undefined;
165
+ /** Where user-attached session media is written at the submission boundary and read back from on the
166
+ * wire. A host that has a file area registers its own as the boot default, so this is normally
167
+ * present; a plugin may register a different medium (media on S3, sessions on disk). Absent ⇒ a
168
+ * by-value attachment is REFUSED at the boundary naming the reason, and an already-persisted
169
+ * `file-ref` degrades to the converters' `[Attached file: x]` note. A text-only deployment is
170
+ * unaffected either way. See {@link MediaStore}. */
171
+ readonly MediaStore?: MediaStore | undefined;
161
172
  }
162
173
 
163
174
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -30,6 +30,20 @@ export interface FileFilter {
30
30
  createdBefore?: ISODate;
31
31
  }
32
32
 
33
+ /**
34
+ * Where user-supplied session media lives — the bytes a person attached to a message, resolved back
35
+ * onto the wire by the runner for as long as they stay inside the residency budget.
36
+ *
37
+ * Deliberately an ALIAS of {@link FileStore} rather than an interface of its own: `FileMetaData`
38
+ * already carries `sessionId`/`messageId`/`namespace`/`allowed` and `FileFilter` already filters on
39
+ * `sessionId`, so session-scoped lifetime, per-message attribution and a servable flag are in the
40
+ * shape already. The consequence is the point — every existing FileStore (filesystem, SQLite, OPFS,
41
+ * Drive) is a candidate media store unchanged, so putting media on one medium and everything else on
42
+ * another is a *registration*, not a port. Two implementations of one interface get an alias, never an
43
+ * invented role name (cf. `SessionStore`).
44
+ */
45
+ export type MediaStore = FileStore;
46
+
33
47
  export interface FileStore {
34
48
  /** Store a file. When `name` is provided, upserts by (name + namespace); otherwise always creates a new entry. */
35
49
  put(
@@ -15,9 +15,11 @@ export type MessageContent = (
15
15
  | { type: 'refusal'; text: string }
16
16
 
17
17
  // ── inline media: bytes plus a mime type. The first three are also `ModelContent` — the arms a tool
18
- // may hand the model to look at (see the `model-content` ToolEvent). `image-url` and `file-ref`
19
- // are references rather than data, and nothing produces them yet; they are reconsidered together
20
- // when durable user-supplied media is built (a `document` / `document-reference` split). ──
18
+ // may hand the model to look at (see the `model-content` ToolEvent) — AND the boundary form of
19
+ // `UserContent`, the arms a person may attach to a submission. `file-ref` is what such an
20
+ // attachment becomes once `open()` has written it through the `MediaStore`: it is the only media
21
+ // arm that ever persists, and the runner resolves it back to inline bytes on the outgoing copy
22
+ // while it fits the residency budget. `image-url` remains unproduced. ──
21
23
  | { type: 'image'; data: string; mimeType: MimeType }
22
24
  | { type: 'document'; data: string; mimeType: MimeType; name?: string }
23
25
  | { type: 'audio'; data: string; mimeType: MimeType }
@@ -1,5 +1,6 @@
1
1
  import type { PipelineEvent } from './events.js';
2
2
  import type { MessageContent, Session } from './messages.js';
3
+ import type { MimeType } from './primitives.js';
3
4
  import type { Principal } from './principal.js';
4
5
  import type { SteeringMode } from './steering.js';
5
6
  import type { PromptFn } from './tools.js';
@@ -30,10 +31,41 @@ export interface OpenOpts {
30
31
  traceId?: string;
31
32
  }
32
33
 
34
+ /**
35
+ * What a *person* may submit — deliberately a narrow subset of {@link MessageContent}, not all of it.
36
+ * A submission crosses a wire boundary (an HTTP body, a chat platform's update), and widening it to
37
+ * the full union would let a client post a forged `tool-result`, `thinking` block or `marker` straight
38
+ * into persisted history.
39
+ *
40
+ * The three inline media arms are a **boundary form only**: `open()` writes them through the
41
+ * `MediaStore` and replaces each with a `file-ref` before the submission is enqueued, so what persists
42
+ * is always a reference. Bytes are never written into a session document — a 5MB image is ~6.7MB of
43
+ * base64 riding *both* whole-document writes of every subsequent turn, for the rest of the session.
44
+ * A caller that has already uploaded (or, like the web composer, would rather not re-post bytes) may
45
+ * pass the `file-ref` itself and skip the rewrite.
46
+ */
47
+ export type UserContent = (
48
+ | { type: 'text'; text: string }
49
+ | { type: 'image'; data: string; mimeType: MimeType; name?: string }
50
+ | { type: 'document'; data: string; mimeType: MimeType; name?: string }
51
+ | { type: 'audio'; data: string; mimeType: MimeType; name?: string }
52
+ | { type: 'file-ref'; fileId: string; name: string; mimeType: MimeType }
53
+ | { type: 'form-response'; values: Record<string, string> }
54
+ ) & {
55
+ /** Authorship, carried through exactly as on {@link MessageContent} — an in-process caller driving a
56
+ * session (the skills compiler's scratch run) submits machine-authored content and must be able to
57
+ * say so. Unlike the arms above this is presentation, not protocol: a remote client asserting it only
58
+ * makes its own bubble render agent-side, so it costs the boundary nothing to accept. */
59
+ origin?: 'robo';
60
+ };
61
+
33
62
  /** Observe a session AND enqueue a submission. The compiler enforces provider/principal here;
34
63
  * a remote frontend deserializing a request body must still validate the wire input itself. */
35
64
  export interface SubmitOpenOpts extends OpenOpts {
36
- content: MessageContent[];
65
+ /** Text plus, optionally, media. Inline media arms are rewritten to `file-ref`s against the
66
+ * `MediaStore` before enqueue; with no store registered the submission is refused (see
67
+ * {@link MediaRejectedError}) rather than silently dropping what the user attached. */
68
+ content: UserContent[];
37
69
  provider: string;
38
70
  principal: Principal;
39
71
  /** When true, this submission may be merged with others drained in the same batch. Default false
@@ -7,9 +7,20 @@ export type SystemContextContributor = (ctx: {
7
7
  signal: AbortSignal;
8
8
  }) => string | null | Promise<string | null>;
9
9
 
10
+ /** One contributor's contribution to a turn's system prompt, with the plugin that registered it.
11
+ * `plugin` is absent for a contributor registered outside the plugin-scoped facade (the host's own),
12
+ * since `register`'s `pluginName` is optional. */
13
+ export interface SystemContextPart {
14
+ plugin?: string;
15
+ text: string;
16
+ }
17
+
10
18
  export interface SystemContextRegistry {
11
19
  register(contributor: SystemContextContributor, pluginName?: string): void;
12
20
  removeByPlugin(pluginName: string): void;
13
21
  /** Calls all contributors and joins non-null, non-empty results with double newlines. */
14
22
  build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null>;
23
+ /** The same contributions {@link build} joins, kept apart and attributed — what `about_matbot`
24
+ * reports so "why is this in my prompt?" has an answer naming the plugin that put it there. */
25
+ parts(ctx: { session: Session; signal: AbortSignal }): Promise<SystemContextPart[]>;
15
26
  }
@@ -221,6 +221,9 @@ export interface ToolContext {
221
221
  * `files`, `prompt` and plugin (un)loading stay off a surface authored by a model and reachable only
222
222
  * through the tool contracts. A tool with real source takes `ToolContext` and needs none of this.
223
223
  *
224
+ * `progress` is the one method here and does not breach that, because the reason capabilities are kept
225
+ * off this surface is authority: it carries none — write-only, reading nothing and reaching nothing.
226
+ *
224
227
  * Not `ToolCallContext` — that name belongs to the `toolcall` hook's context; this is the composed
225
228
  * caller's own view, not a hook's view of a call about to run.
226
229
  */
@@ -231,6 +234,13 @@ export interface ComposedCallContext {
231
234
  readonly provider?: string;
232
235
  readonly workdir?: string;
233
236
  readonly signal: AbortSignal;
237
+ /**
238
+ * Report how far along the body is — a `progress` {@link ToolEvent}, identical to what a hand-written
239
+ * tool yields: surfaced live by the frontend, and neither persisted nor part of the result. A composed
240
+ * body is a plain async function and so cannot `yield`, which would otherwise leave the event channel
241
+ * its own nested `tool.x(…)` calls already stream on unreachable from the one place a long run happens.
242
+ */
243
+ readonly progress: (pct: number, message?: string) => void;
234
244
  }
235
245
 
236
246
  /**