@matatbread/matbot-plugin-api 0.4.7 → 0.4.8
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 +1 -1
- package/src/base64.ts +19 -0
- package/src/bytes.ts +19 -0
- package/src/errors.ts +34 -1
- package/src/host-machine.ts +1 -1
- package/src/index.ts +2 -0
- package/src/plugin.ts +14 -3
- package/src/types/files.ts +14 -0
- package/src/types/messages.ts +5 -3
- package/src/types/session-runner.ts +33 -1
- package/src/types/system-context.ts +11 -0
- package/src/types/tools.ts +10 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/host-machine.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/types/files.ts
CHANGED
|
@@ -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(
|
package/src/types/messages.ts
CHANGED
|
@@ -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)
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/types/tools.ts
CHANGED
|
@@ -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
|
/**
|