@matatbread/matbot-plugin-api 0.3.2 → 0.3.4
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/broadcast.ts +41 -17
- package/src/errors.ts +24 -1
- package/src/plugin.ts +36 -6
- package/src/types.ts +29 -6
package/package.json
CHANGED
package/src/broadcast.ts
CHANGED
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
// A minimal multi-subscriber fan-out for AsyncIterable observation streams (tool/plugin/skill watch,
|
|
2
|
-
// and any future plugin-owned CRUD stream). Each subscriber gets its own unbounded queue,
|
|
3
|
-
// consumer never blocks emit() or its peers. Acceptable because these events are rare and small
|
|
2
|
+
// file changes, and any future plugin-owned CRUD stream). Each subscriber gets its own unbounded queue,
|
|
3
|
+
// so a slow consumer never blocks emit() or its peers. Acceptable because these events are rare and small
|
|
4
4
|
// (registry/document CRUD); this is not a high-throughput data path. emit() is synchronous and never throws.
|
|
5
|
+
//
|
|
6
|
+
// Every event flows as a {@link Routed} envelope carrying an optional `origin` principal — the partition
|
|
7
|
+
// or actor that produced it. This is the one attribution channel shared by every stream (files, skills,
|
|
8
|
+
// future partitioned stores); a global stream (tool/plugin registry) simply never sets it. Per-subscriber
|
|
9
|
+
// filtering (`subscribe(signal, filter)`) is applied at emit time, so a subscriber that only wants events
|
|
10
|
+
// visible to one principal never even queues the rest — the single, common place per-principal visibility
|
|
11
|
+
// lives, whatever the terminal (SSE→frontend, an internal consumer, or a cache).
|
|
12
|
+
|
|
13
|
+
import type { Principal } from './types.js';
|
|
14
|
+
|
|
15
|
+
/** A broadcast event plus its optional origin. `origin` undefined ⇒ global/base — visible to all. */
|
|
16
|
+
export interface Routed<T> {
|
|
17
|
+
readonly value: T;
|
|
18
|
+
readonly origin?: Principal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Per-subscriber predicate: keep only the envelopes it returns true for. */
|
|
22
|
+
export type RoutedFilter<T> = (event: Routed<T>) => boolean;
|
|
5
23
|
|
|
6
24
|
interface Subscriber<T> {
|
|
7
|
-
queue:
|
|
8
|
-
wake:
|
|
9
|
-
done:
|
|
25
|
+
queue: Routed<T>[];
|
|
26
|
+
wake: (() => void) | undefined;
|
|
27
|
+
done: boolean;
|
|
28
|
+
filter: RoutedFilter<T> | undefined;
|
|
10
29
|
}
|
|
11
30
|
|
|
12
31
|
// The consumer facet — what a stream hands out. `emit` is the producer's, kept off this so a
|
|
13
32
|
// surface that only exposes observation (e.g. services.mounted) cannot forge events.
|
|
14
33
|
export interface Subscribable<T> {
|
|
15
|
-
subscribe(signal?: AbortSignal): AsyncIterable<T
|
|
34
|
+
subscribe(signal?: AbortSignal, filter?: RoutedFilter<T>): AsyncIterable<Routed<T>>;
|
|
16
35
|
// Detached observation loop: awaits each handler before pulling the next (no overlapping runs),
|
|
17
36
|
// isolates a throwing handler (logged, never propagated — a bad observer must not kill the stream),
|
|
18
37
|
// and ends when the source ends or `signal` aborts. The fire-and-forget form of the for-await loop
|
|
19
|
-
// every watch call site otherwise hand-writes.
|
|
20
|
-
|
|
38
|
+
// every watch call site otherwise hand-writes. An internal cache is exactly this: a `consume` that
|
|
39
|
+
// folds each `Routed<T>` into per-origin state.
|
|
40
|
+
consume(handler: (event: Routed<T>) => void | Promise<void>, signal?: AbortSignal, filter?: RoutedFilter<T>): void;
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export interface Broadcaster<T> extends Subscribable<T> {
|
|
24
|
-
emit(value: T): void;
|
|
44
|
+
emit(value: T, origin?: Principal): void;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
/**
|
|
@@ -29,11 +49,13 @@ export interface Broadcaster<T> extends Subscribable<T> {
|
|
|
29
49
|
* `consume` loop. Use for a *derived* stream — e.g. a per-plugin scoped view of a shared broadcaster —
|
|
30
50
|
* that owns its own subscribe generator but wants the same consume ergonomics as a real broadcaster.
|
|
31
51
|
*/
|
|
32
|
-
export function subscribable<T>(
|
|
33
|
-
|
|
52
|
+
export function subscribable<T>(
|
|
53
|
+
subscribe: (signal?: AbortSignal, filter?: RoutedFilter<T>) => AsyncIterable<Routed<T>>,
|
|
54
|
+
): Subscribable<T> {
|
|
55
|
+
const consume = (handler: (event: Routed<T>) => void | Promise<void>, signal?: AbortSignal, filter?: RoutedFilter<T>): void => {
|
|
34
56
|
void (async () => {
|
|
35
|
-
for await (const
|
|
36
|
-
try { await handler(
|
|
57
|
+
for await (const event of subscribe(signal, filter)) {
|
|
58
|
+
try { await handler(event); }
|
|
37
59
|
catch (e) { console.error('[matbot] consume handler threw:', e instanceof Error ? e.message : e); }
|
|
38
60
|
}
|
|
39
61
|
})();
|
|
@@ -44,15 +66,17 @@ export function subscribable<T>(subscribe: (signal?: AbortSignal) => AsyncIterab
|
|
|
44
66
|
export function createBroadcaster<T>(): Broadcaster<T> {
|
|
45
67
|
const subs = new Set<Subscriber<T>>();
|
|
46
68
|
|
|
47
|
-
const emit = (value: T): void => {
|
|
69
|
+
const emit = (value: T, origin?: Principal): void => {
|
|
70
|
+
const event: Routed<T> = origin !== undefined ? { value, origin } : { value };
|
|
48
71
|
for (const sub of subs) {
|
|
49
|
-
sub.
|
|
72
|
+
if (sub.filter !== undefined && !sub.filter(event)) continue;
|
|
73
|
+
sub.queue.push(event);
|
|
50
74
|
sub.wake?.();
|
|
51
75
|
}
|
|
52
76
|
};
|
|
53
77
|
|
|
54
|
-
async function* subscribe(signal?: AbortSignal): AsyncIterable<T
|
|
55
|
-
const sub: Subscriber<T> = { queue: [], wake: undefined, done: false };
|
|
78
|
+
async function* subscribe(signal?: AbortSignal, filter?: RoutedFilter<T>): AsyncIterable<Routed<T>> {
|
|
79
|
+
const sub: Subscriber<T> = { queue: [], wake: undefined, done: false, filter };
|
|
56
80
|
subs.add(sub);
|
|
57
81
|
const onAbort = () => { sub.done = true; sub.wake?.(); };
|
|
58
82
|
signal?.addEventListener('abort', onAbort, { once: true });
|
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';
|
|
19
|
+
export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly';
|
|
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;
|
|
@@ -102,3 +102,26 @@ export function promptCancelledError(message = 'User cancelled — cannot procee
|
|
|
102
102
|
export function isPromptCancelledError(e: unknown): e is PromptCancelledError {
|
|
103
103
|
return isKind(e, 'PromptCancelled');
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Thrown by a `Store` write when the target item is not owned by the current principal — e.g. a session
|
|
108
|
+
* shared read-only into another profile's partition (see `@matatbread/matbot-storage-profiles`). This is
|
|
109
|
+
* a per-operation condition, **not** a process fault: the turn pump catches it (via {@link isReadOnlyError},
|
|
110
|
+
* never `instanceof`) and surfaces it as a turn error rather than letting it escape the detached pump and
|
|
111
|
+
* crash the host. `namespace`/`id` name the item; `owner` is the owning principal id (`''` for the shared
|
|
112
|
+
* base/global partition).
|
|
113
|
+
*/
|
|
114
|
+
export interface ReadOnlyError extends Error {
|
|
115
|
+
matbot: 'ReadOnly';
|
|
116
|
+
readonly namespace: string;
|
|
117
|
+
readonly id: string;
|
|
118
|
+
readonly owner: string;
|
|
119
|
+
}
|
|
120
|
+
export function readOnlyError(namespace: string, id: string, owner: string): ReadOnlyError {
|
|
121
|
+
const e = new Error(`${namespace} "${id}" was shared by "${owner || 'global'}" and is read-only.`);
|
|
122
|
+
e.name = 'ReadOnlyError';
|
|
123
|
+
return Object.assign(e, { matbot: 'ReadOnly' as const, namespace, id, owner });
|
|
124
|
+
}
|
|
125
|
+
export function isReadOnlyError(e: unknown): e is ReadOnlyError {
|
|
126
|
+
return isKind(e, 'ReadOnly');
|
|
127
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
FileStore, Vault, Message, ModelParameters,
|
|
2
|
+
FileStore, FileEvent, Principal, Vault, Message, ModelParameters,
|
|
3
3
|
ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
|
|
4
4
|
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
|
|
5
5
|
TypeScriptStripper, ToolTypeIndex, ToolPresenter,
|
|
6
6
|
} from './types.js';
|
|
7
|
+
import type { Routed } from './broadcast.js';
|
|
7
8
|
import type { HookRegistry } from './hooks.js';
|
|
8
9
|
|
|
9
10
|
export const PLUGIN_API_VERSION = '0.1';
|
|
@@ -106,6 +107,10 @@ export interface MatbotServices {
|
|
|
106
107
|
* whole turn snapshot. A plain registered service (not a swap-member); consumed as a member. See
|
|
107
108
|
* {@link ToolPresenter}. */
|
|
108
109
|
readonly ToolPresenter?: ToolPresenter | undefined;
|
|
110
|
+
/** Optional partition-aware file-watch layer (cross-partition observation + per-connection visibility),
|
|
111
|
+
* registered by a partitioning storage backend. Absent ⇒ frontends use the plain `fileStore.watch()`.
|
|
112
|
+
* See {@link WatchVisibility}. */
|
|
113
|
+
readonly WatchVisibility?: WatchVisibility | undefined;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
|
|
@@ -166,10 +171,10 @@ export interface MatbotRuntime {
|
|
|
166
171
|
* per-invocation through its proxy/member instead.
|
|
167
172
|
*
|
|
168
173
|
* // cache the backend's documents; rebuild on every swap (own initial load was in setup())
|
|
169
|
-
* services.mounted.
|
|
174
|
+
* services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
|
|
170
175
|
*
|
|
171
176
|
* // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
|
|
172
|
-
* services.mounted.
|
|
177
|
+
* services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
|
|
173
178
|
*
|
|
174
179
|
* Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
|
|
175
180
|
* says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
|
|
@@ -334,9 +339,11 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
|
|
|
334
339
|
readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
|
|
335
340
|
}
|
|
336
341
|
|
|
337
|
-
/** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}.
|
|
342
|
+
/** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. `observe` (not `consume`)
|
|
343
|
+
* so the verb `consume` means exactly one thing repo-wide — the detached stream drain on
|
|
344
|
+
* {@link Subscribable}; this keyed, replay/onUnmount, edge-batched delivery is its own paradigm. */
|
|
338
345
|
export interface Mounted {
|
|
339
|
-
|
|
346
|
+
observe<K extends keyof MatbotServices>(
|
|
340
347
|
options: MountConsumeOptions<K>,
|
|
341
348
|
handler: (machine: MountedMachine<K>) => void | Promise<void>,
|
|
342
349
|
): void;
|
|
@@ -385,7 +392,7 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
|
|
|
385
392
|
};
|
|
386
393
|
|
|
387
394
|
const mounted: Mounted = {
|
|
388
|
-
|
|
395
|
+
observe(options, handler) {
|
|
389
396
|
const { key, replay, signal, onUnmount } = options;
|
|
390
397
|
if (signal?.aborted === true) return;
|
|
391
398
|
const interest: MountInterest = {
|
|
@@ -478,6 +485,29 @@ export interface StorageBackend {
|
|
|
478
485
|
close?(): Promise<void>;
|
|
479
486
|
}
|
|
480
487
|
|
|
488
|
+
/**
|
|
489
|
+
* The partition-aware file-watch layer, registered by a storage backend that partitions files per
|
|
490
|
+
* principal (the profiles backend). Consumed by a frontend firehose so it can (a) observe file events
|
|
491
|
+
* across *every* partition, each stamped with the {@link Routed} origin that produced it, and (b) decide,
|
|
492
|
+
* per SSE connection, whether a given event is visible to that connection's principal. Absent ⇒ no
|
|
493
|
+
* partitioning: the frontend falls back to the plain single-stream `fileStore.watch()` with no filter.
|
|
494
|
+
* The origin/visibility split is generic (files today; the same shape serves skills/stores later).
|
|
495
|
+
*/
|
|
496
|
+
export interface WatchVisibility {
|
|
497
|
+
/** Every partition's file events, merged into one stream, each tagged with its origin partition. */
|
|
498
|
+
watchFiles(signal?: AbortSignal): AsyncIterable<Routed<FileEvent>>;
|
|
499
|
+
/**
|
|
500
|
+
* The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
|
|
501
|
+
* connection owned by `viewer` see an event in `namespace` produced by `origin`? Defined as
|
|
502
|
+
* `route(viewer, namespace) === route(origin, namespace)` — the two see it iff they route that namespace
|
|
503
|
+
* to the same partition. This yields the intended behavior uniformly: a viewer sees global/base events
|
|
504
|
+
* for namespaces it does NOT isolate, and only its own partition's events for namespaces it does.
|
|
505
|
+
* `origin` may be a partition principal (files, tagged by the merge) or the acting principal (skills,
|
|
506
|
+
* stamped at write) — routing both sides makes either correct. `undefined` origin ⇒ base.
|
|
507
|
+
*/
|
|
508
|
+
visible(viewer: Principal, namespace: string, origin: Principal | undefined): boolean;
|
|
509
|
+
}
|
|
510
|
+
|
|
481
511
|
// ── Plugin interface ──────────────────────────────────────────────────────────
|
|
482
512
|
|
|
483
513
|
/**
|
package/src/types.ts
CHANGED
|
@@ -62,9 +62,30 @@ export interface UsageRecord {
|
|
|
62
62
|
usage: Usage;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Opaque, provider-specific round-trip metadata attached to a tool-call — captured from a completion,
|
|
67
|
+
* persisted, and re-sent verbatim when the call is replayed in history (e.g. a Gemini 3 "thought
|
|
68
|
+
* signature", which every historical `functionCall` must carry or the request 400s). The harness never
|
|
69
|
+
* interprets it: it stores, renders, or elides it. Empty by default — a provider package augments its
|
|
70
|
+
* OWN slice from its own module (namespaced by provider family, so slices from different providers in a
|
|
71
|
+
* mixed-provider session never collide), so adding a provider that needs round-trip state touches NO
|
|
72
|
+
* core code:
|
|
73
|
+
*
|
|
74
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
75
|
+
* interface ProviderMeta { google?: { thoughtSignature?: string } }
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* A single session interleaves tool-calls from many providers, so this must be an open, additive union
|
|
79
|
+
* (interface augmentation) — a generic type parameter can bind only one provider per instantiation and
|
|
80
|
+
* so could not type a heterogeneous transcript.
|
|
81
|
+
*/
|
|
82
|
+
export interface ProviderMeta {}
|
|
83
|
+
|
|
65
84
|
export type CompletionEvent =
|
|
66
85
|
| { type: 'text-delta'; delta: string }
|
|
67
|
-
| { type: 'tool-call'; id: string; name: string; input: unknown
|
|
86
|
+
| { type: 'tool-call'; id: string; name: string; input: unknown;
|
|
87
|
+
/** Provider-specific round-trip metadata for this call, opaque to the harness — see `ProviderMeta`. */
|
|
88
|
+
meta?: ProviderMeta }
|
|
68
89
|
| { type: 'tool-result'; id: string; result: unknown }
|
|
69
90
|
| { type: 'thinking'; delta: string }
|
|
70
91
|
| { type: 'thinking-block'; thinking: string; signature: string }
|
|
@@ -99,7 +120,10 @@ export type MessageContent = (
|
|
|
99
120
|
| { type: 'image-url'; url: string; detail?: 'low' | 'high' | 'auto' }
|
|
100
121
|
| { type: 'document'; data: string; mimeType: MimeType; name?: string }
|
|
101
122
|
| { type: 'audio'; data: string; mimeType: MimeType }
|
|
102
|
-
| { type: 'tool-call'; id: string; name: string; input: unknown
|
|
123
|
+
| { type: 'tool-call'; id: string; name: string; input: unknown;
|
|
124
|
+
/** Provider-specific round-trip metadata, persisted and re-sent verbatim on replay — see
|
|
125
|
+
* `ProviderMeta` and the `tool-call` `CompletionEvent`. */
|
|
126
|
+
meta?: ProviderMeta }
|
|
103
127
|
| { type: 'tool-result'; id: string; result: unknown; isError?: boolean;
|
|
104
128
|
/**
|
|
105
129
|
* Accounting addendum: completions the tool itself ran (e.g. `single_turn`, `dream_time`'s ranker/
|
|
@@ -199,9 +223,9 @@ export type SessionStatus = 'active' | 'archived' | 'pinned';
|
|
|
199
223
|
export interface Session {
|
|
200
224
|
id: string;
|
|
201
225
|
version: string;
|
|
202
|
-
ownerPrincipalId
|
|
203
|
-
|
|
204
|
-
|
|
226
|
+
// Legacy: persisted sessions may still carry `ownerPrincipalId` / `actorPrincipalId` / `persona`.
|
|
227
|
+
// They were never read — ownership-at-rest is structural (the storage partition), resolved via the
|
|
228
|
+
// backend, not a field. Left undeclared; old data keeps them harmlessly as excess properties.
|
|
205
229
|
title?: string;
|
|
206
230
|
status: SessionStatus;
|
|
207
231
|
contexts: string[];
|
|
@@ -230,7 +254,6 @@ export interface SystemContextRegistry {
|
|
|
230
254
|
|
|
231
255
|
export interface RunConfig {
|
|
232
256
|
provider: string;
|
|
233
|
-
persona?: string;
|
|
234
257
|
sessionId?: string;
|
|
235
258
|
traceId?: string;
|
|
236
259
|
}
|