@matatbread/matbot-plugin-api 0.3.3 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.3",
7
+ "version": "0.3.4",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
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, so a slow
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: T[];
8
- wake: (() => void) | undefined;
9
- done: boolean;
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
- consume(handler: (value: T) => void | Promise<void>, signal?: AbortSignal): void;
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>(subscribe: (signal?: AbortSignal) => AsyncIterable<T>): Subscribable<T> {
33
- const consume = (handler: (value: T) => void | Promise<void>, signal?: AbortSignal): void => {
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 v of subscribe(signal)) {
36
- try { await handler(v); }
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.queue.push(value);
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.consume({ key: 'StorageBackend', signal }, () => this.load());
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.consume({ key: 'SkillManager', replay: true, signal }, m => seed(m));
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
- consume<K extends keyof MatbotServices>(
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
- consume(options, handler) {
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
@@ -223,9 +223,9 @@ export type SessionStatus = 'active' | 'archived' | 'pinned';
223
223
  export interface Session {
224
224
  id: string;
225
225
  version: string;
226
- ownerPrincipalId: string;
227
- actorPrincipalId?: string;
228
- persona?: string;
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.
229
229
  title?: string;
230
230
  status: SessionStatus;
231
231
  contexts: string[];
@@ -254,7 +254,6 @@ export interface SystemContextRegistry {
254
254
 
255
255
  export interface RunConfig {
256
256
  provider: string;
257
- persona?: string;
258
257
  sessionId?: string;
259
258
  traceId?: string;
260
259
  }