@matatbread/matbot-plugin-api 0.3.3 → 0.3.5
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/hooks.ts +19 -10
- package/src/plugin.ts +62 -9
- package/src/types.ts +112 -5
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/hooks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Hook, HookPoint, Message, MessageContent, Session,
|
|
2
|
+
Hook, HookPoint, Message, MessageContent, Session, DeferredScreen,
|
|
3
3
|
ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
|
|
4
4
|
} from './types.js';
|
|
5
5
|
|
|
@@ -88,9 +88,12 @@ export class HookRegistry {
|
|
|
88
88
|
|
|
89
89
|
// screen folds across hooks: each sees the session as shaped so far, accumulates ephemeral, and
|
|
90
90
|
// the first `abort` short-circuits (the partial session is returned so the caller can persist it).
|
|
91
|
-
async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; abort?: string }> {
|
|
91
|
+
async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; abort?: string }> {
|
|
92
92
|
let session = ctx.session;
|
|
93
93
|
const ephemeral: MessageContent[] = [];
|
|
94
|
+
// Raced verdicts handed back by hooks: the runner polls each without gating the turn (see
|
|
95
|
+
// DeferredScreen). Usually zero or one; an array so several racing hooks compose.
|
|
96
|
+
const deferred: DeferredScreen[] = [];
|
|
94
97
|
// Handler-returned markers: appended to the session here (so they persist) and accumulated so the
|
|
95
98
|
// runner can also emit them live — keeping a live draw and a reload identical.
|
|
96
99
|
const handlerMarkers: MessageContent[] = [];
|
|
@@ -122,13 +125,14 @@ export class HookRegistry {
|
|
|
122
125
|
if (r.ephemeral) ephemeral.push(...r.ephemeral);
|
|
123
126
|
if (r.durable && r.durable.length) foldDurable(r.durable);
|
|
124
127
|
if (r.markers && r.markers.length) appendMarkers(r.markers);
|
|
128
|
+
if (r.deferred) deferred.push(r.deferred);
|
|
125
129
|
if (r.abort) {
|
|
126
130
|
const drained = this.drainFailureMarkers(session);
|
|
127
|
-
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], abort: r.abort };
|
|
131
|
+
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, abort: r.abort };
|
|
128
132
|
}
|
|
129
133
|
}
|
|
130
134
|
const drained = this.drainFailureMarkers(session);
|
|
131
|
-
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers] };
|
|
135
|
+
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred };
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// contribute folds the outgoing array through each hook — a pure transform pipeline; the stored
|
|
@@ -169,17 +173,22 @@ export class HookRegistry {
|
|
|
169
173
|
// turn), any durable markers (appended to the committed session by the pump), and any
|
|
170
174
|
// retract-and-rerun requests. A turn can be popped only once, so multiple hooks' retraction
|
|
171
175
|
// contexts merge into a single redo (in practice only one fires); `retract` is omitted when none did.
|
|
172
|
-
async runFollowup(ctx: Omit<FollowupContext, 'removeHook'>): Promise<{ resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } }> {
|
|
173
|
-
const resubmits:
|
|
174
|
-
const markers:
|
|
175
|
-
const
|
|
176
|
+
async runFollowup(ctx: Omit<FollowupContext, 'removeHook'>): Promise<{ resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } }> {
|
|
177
|
+
const resubmits: MessageContent[][] = [];
|
|
178
|
+
const markers: MessageContent[] = [];
|
|
179
|
+
const retractCtx: MessageContent[] = [];
|
|
180
|
+
const retractDur: MessageContent[] = [];
|
|
176
181
|
for (const hook of this.hooks.get('followup') ?? []) {
|
|
177
182
|
if (hook.on !== 'followup') continue;
|
|
178
183
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
179
184
|
if (r?.resubmit) resubmits.push(r.resubmit.content);
|
|
180
|
-
if (r?.retractAndRerun)
|
|
185
|
+
if (r?.retractAndRerun) {
|
|
186
|
+
if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
|
|
187
|
+
if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
|
|
188
|
+
}
|
|
181
189
|
if (r?.markers) markers.push(...r.markers);
|
|
182
190
|
}
|
|
183
|
-
|
|
191
|
+
const retracting = retractCtx.length > 0 || retractDur.length > 0;
|
|
192
|
+
return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
|
|
184
193
|
}
|
|
185
194
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
FileStore, Vault, Message, ModelParameters,
|
|
2
|
+
FileStore, StoreChange, Principal, Vault, Message, ModelParameters,
|
|
3
3
|
ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
|
|
4
4
|
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
|
|
5
|
-
TypeScriptStripper, ToolTypeIndex, ToolPresenter,
|
|
5
|
+
TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
|
|
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';
|
|
@@ -14,6 +15,15 @@ export interface CompletionRequest {
|
|
|
14
15
|
provider: string;
|
|
15
16
|
messages: Message[];
|
|
16
17
|
system?: string;
|
|
18
|
+
/**
|
|
19
|
+
* @deprecated Per-call parameter overrides, shallow-merged over the provider config (request wins).
|
|
20
|
+
* It DOES work — both hosts (CLI + web-bundle) honour it — but it is a speculative affordance whose
|
|
21
|
+
* obvious use is an anti-pattern: a caller clamping tokens / disabling thinking for one call takes
|
|
22
|
+
* control away from the provider config, where parameters belong and stay user-editable. Prefer a
|
|
23
|
+
* dedicated provider profile (e.g. a `-classify` provider) instead of reaching for this. Kept, not
|
|
24
|
+
* removed, only because the surface is tiny and a genuine per-call need may yet appear. If you found
|
|
25
|
+
* this because you set it and it worked — reconsider whether a provider profile is the right home.
|
|
26
|
+
*/
|
|
17
27
|
parameters?: Partial<ModelParameters>;
|
|
18
28
|
signal?: AbortSignal;
|
|
19
29
|
}
|
|
@@ -27,6 +37,9 @@ export interface SingleTurnRequest {
|
|
|
27
37
|
provider: string;
|
|
28
38
|
prompt: string;
|
|
29
39
|
system?: string;
|
|
40
|
+
/** @deprecated Forwarded verbatim to {@link CompletionRequest.parameters}; same speculative
|
|
41
|
+
* anti-pattern caveat — prefer a dedicated provider profile over per-call overrides. */
|
|
42
|
+
parameters?: Partial<ModelParameters>;
|
|
30
43
|
signal?: AbortSignal;
|
|
31
44
|
}
|
|
32
45
|
|
|
@@ -106,6 +119,15 @@ export interface MatbotServices {
|
|
|
106
119
|
* whole turn snapshot. A plain registered service (not a swap-member); consumed as a member. See
|
|
107
120
|
* {@link ToolPresenter}. */
|
|
108
121
|
readonly ToolPresenter?: ToolPresenter | undefined;
|
|
122
|
+
/** Optional partition-aware file-watch layer (cross-partition observation + per-connection visibility),
|
|
123
|
+
* registered by a partitioning storage backend. Absent ⇒ frontends use the plain `fileStore.watch()`.
|
|
124
|
+
* See {@link WatchVisibility}. */
|
|
125
|
+
readonly WatchVisibility?: WatchVisibility | undefined;
|
|
126
|
+
/** Optional steering policy: decides how a mid-turn submission under `mode: 'auto'` is disposed
|
|
127
|
+
* (queue vs interrupt) and supplies the nudge folded onto an interrupt's continuation. Absent ⇒ the
|
|
128
|
+
* runner uses its own defaults. A plain registered service, consumed by the runner as a member.
|
|
129
|
+
* See {@link SteeringPolicy}. */
|
|
130
|
+
readonly SteeringPolicy?: SteeringPolicy | undefined;
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
/** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
|
|
@@ -166,10 +188,10 @@ export interface MatbotRuntime {
|
|
|
166
188
|
* per-invocation through its proxy/member instead.
|
|
167
189
|
*
|
|
168
190
|
* // cache the backend's documents; rebuild on every swap (own initial load was in setup())
|
|
169
|
-
* services.mounted.
|
|
191
|
+
* services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
|
|
170
192
|
*
|
|
171
193
|
* // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
|
|
172
|
-
* services.mounted.
|
|
194
|
+
* services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
|
|
173
195
|
*
|
|
174
196
|
* Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
|
|
175
197
|
* says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
|
|
@@ -334,9 +356,11 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
|
|
|
334
356
|
readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
|
|
335
357
|
}
|
|
336
358
|
|
|
337
|
-
/** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}.
|
|
359
|
+
/** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. `observe` (not `consume`)
|
|
360
|
+
* so the verb `consume` means exactly one thing repo-wide — the detached stream drain on
|
|
361
|
+
* {@link Subscribable}; this keyed, replay/onUnmount, edge-batched delivery is its own paradigm. */
|
|
338
362
|
export interface Mounted {
|
|
339
|
-
|
|
363
|
+
observe<K extends keyof MatbotServices>(
|
|
340
364
|
options: MountConsumeOptions<K>,
|
|
341
365
|
handler: (machine: MountedMachine<K>) => void | Promise<void>,
|
|
342
366
|
): void;
|
|
@@ -385,7 +409,7 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
|
|
|
385
409
|
};
|
|
386
410
|
|
|
387
411
|
const mounted: Mounted = {
|
|
388
|
-
|
|
412
|
+
observe(options, handler) {
|
|
389
413
|
const { key, replay, signal, onUnmount } = options;
|
|
390
414
|
if (signal?.aborted === true) return;
|
|
391
415
|
const interest: MountInterest = {
|
|
@@ -443,8 +467,9 @@ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
|
|
|
443
467
|
id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
|
|
444
468
|
content: [{ type: 'text', text: req.prompt }],
|
|
445
469
|
}],
|
|
446
|
-
...(req.system
|
|
447
|
-
...(req.
|
|
470
|
+
...(req.system !== undefined ? { system: req.system } : {}),
|
|
471
|
+
...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
|
|
472
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
448
473
|
};
|
|
449
474
|
}
|
|
450
475
|
|
|
@@ -478,6 +503,34 @@ export interface StorageBackend {
|
|
|
478
503
|
close?(): Promise<void>;
|
|
479
504
|
}
|
|
480
505
|
|
|
506
|
+
/**
|
|
507
|
+
* The partition-aware file-watch layer, registered by a storage backend that partitions files per
|
|
508
|
+
* principal (the profiles backend). Consumed by a frontend firehose so it can (a) observe file events
|
|
509
|
+
* across *every* partition, each stamped with the {@link Routed} origin that produced it, and (b) decide,
|
|
510
|
+
* per SSE connection, whether a given event is visible to that connection's principal. Absent ⇒ no
|
|
511
|
+
* partitioning: the frontend falls back to the plain single-stream `fileStore.watch()` with no filter.
|
|
512
|
+
* The origin/visibility split is generic (files today; the same shape serves skills/stores later).
|
|
513
|
+
*/
|
|
514
|
+
export interface WatchVisibility {
|
|
515
|
+
/** Every partition's file events, merged into one stream, each a self-describing {@link StoreChange}
|
|
516
|
+
* (routing namespace `'files'`, the file id, and the {@link FileMetaData} in `detail`) tagged with the
|
|
517
|
+
* origin partition — so the firehose filters and routes files through the same predicate as any stream. */
|
|
518
|
+
watchFiles(signal?: AbortSignal): AsyncIterable<Routed<StoreChange>>;
|
|
519
|
+
/**
|
|
520
|
+
* The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
|
|
521
|
+
* connection owned by `viewer` see the change to (`namespace`, `id`) produced by `origin`? True iff the
|
|
522
|
+
* viewer routes that namespace to the same partition as `origin` — `route(viewer, namespace) ===
|
|
523
|
+
* route(origin, namespace)` — OR the item is shared INTO the viewer's partition (the owner edits a
|
|
524
|
+
* shared-in item: origin=owner ≠ the viewer's route, yet the viewer holds a live link to it). This yields
|
|
525
|
+
* the intended behavior uniformly: global/base events for namespaces the viewer has NOT isolated,
|
|
526
|
+
* own-partition events for those it has, plus live updates to items shared in from another partition.
|
|
527
|
+
* `origin` may be a partition principal (files, tagged by the merge) or the acting principal (skills,
|
|
528
|
+
* stamped at write) — routing both sides makes either correct. `undefined` origin ⇒ base. `id` is the
|
|
529
|
+
* store id of the changed item; it drives the shared-in check.
|
|
530
|
+
*/
|
|
531
|
+
visible(viewer: Principal, namespace: string, id: string, origin: Principal | undefined): boolean;
|
|
532
|
+
}
|
|
533
|
+
|
|
481
534
|
// ── Plugin interface ──────────────────────────────────────────────────────────
|
|
482
535
|
|
|
483
536
|
/**
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
}
|
|
@@ -311,8 +310,47 @@ export interface ScreenContext {
|
|
|
311
310
|
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
312
311
|
removeHook(): void;
|
|
313
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* A raced screen verdict the runner folds in WITHOUT gating the turn on it. A `screen` hook that starts
|
|
315
|
+
* expensive work concurrently (e.g. a triggers classifier judging the user message) returns immediately
|
|
316
|
+
* and hands back one of these instead of blocking on it. The runner polls `claim()` — synchronously,
|
|
317
|
+
* never awaiting — at each turn-loop edge (before a provider call, on every stream event, and just
|
|
318
|
+
* before committing). The first time `claim()` returns blocks (the work has settled WITH a correction),
|
|
319
|
+
* the runner DISCARDS the uncommitted in-progress response and re-runs the loop with those blocks
|
|
320
|
+
* tail-folded as ephemeral context — an in-situ redo: no store pop, no retraction marker, cleaner than a
|
|
321
|
+
* post-commit retract. Because the mid-stream poll runs before each event is emitted, a verdict faster
|
|
322
|
+
* than time-to-first-token is caught before any token reaches the frontend (the clean path); a slower
|
|
323
|
+
* one aborts the in-flight provider request, saving the unstreamed remainder. If it never fires before
|
|
324
|
+
* the turn commits, the turn commits normally and any correction is left to a post-commit `followup`.
|
|
325
|
+
*
|
|
326
|
+
* `claim()` MUST be exactly-once: return the correction on the first successful poll and `undefined`
|
|
327
|
+
* forever after (not settled, no correction, or already claimed). The hook uses that single delivery to
|
|
328
|
+
* coordinate with its own post-commit path — a claimed verdict is never also delivered by `followup`.
|
|
329
|
+
*/
|
|
330
|
+
export interface DeferredScreen {
|
|
331
|
+
claim(): DeferredCorrection | undefined;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* A claimed raced-verdict correction. `ephemeral` is tail-folded onto the re-run's outgoing messages
|
|
335
|
+
* and never persisted (the transient "for this answer only" twin); `durable` is folded onto the turn's
|
|
336
|
+
* user message — persisted, visible (the hook marks it `origin: 'robo'`), and carried live as a
|
|
337
|
+
* `robo-user` event — so it updates the conversation rather than informing one answer (a `contextual`
|
|
338
|
+
* trigger's semantics, preserved even though the verdict now lands mid-turn instead of before it). At
|
|
339
|
+
* least one is non-empty when returned.
|
|
340
|
+
*/
|
|
341
|
+
export interface DeferredCorrection {
|
|
342
|
+
ephemeral?: MessageContent[];
|
|
343
|
+
durable?: MessageContent[];
|
|
344
|
+
}
|
|
314
345
|
export interface ScreenResult {
|
|
315
346
|
session?: Session;
|
|
347
|
+
/**
|
|
348
|
+
* A raced verdict the runner folds in without gating on it (see {@link DeferredScreen}). Lets a
|
|
349
|
+
* screen hook race expensive work — a classifier — against generation: return immediately, hand this
|
|
350
|
+
* back, and the runner restarts the turn in-situ if the verdict fires before commit. A hook that
|
|
351
|
+
* would rather gate the turn (block until the verdict) just returns `ephemeral` as usual instead.
|
|
352
|
+
*/
|
|
353
|
+
deferred?: DeferredScreen;
|
|
316
354
|
/** Turn-scoped context appended onto the tail of this turn's outgoing messages (the freshest
|
|
317
355
|
* input the model reads), never persisted. At the tail, not a system prefix, so a directive
|
|
318
356
|
* keeps its salience and the cached system/history prefix stays stable across turns. */
|
|
@@ -402,8 +440,13 @@ export interface FollowupResult {
|
|
|
402
440
|
* defect that the injected context dissolves on the redo, so it won't re-fire; `resubmitDepth` (a
|
|
403
441
|
* redo carries parent+1) caps an ill-formed one. `resubmit` and `retractAndRerun` are independent
|
|
404
442
|
* capabilities — a single turn returning both is not expected, but both head-enqueue if it does.
|
|
443
|
+
*
|
|
444
|
+
* `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
|
|
445
|
+
* onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
|
|
446
|
+
* `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
|
|
447
|
+
* lands post-commit. At least one of the two is present.
|
|
405
448
|
*/
|
|
406
|
-
retractAndRerun?: { context
|
|
449
|
+
retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
|
|
407
450
|
/**
|
|
408
451
|
* Durable `marker` blocks to append to the just-committed session (LLM-invisible; for tracing /
|
|
409
452
|
* cross-references). The second durable-write capability after `screen` — safe here for the same
|
|
@@ -739,6 +782,26 @@ export interface FileHandle extends FileMetaData {
|
|
|
739
782
|
|
|
740
783
|
export type FileEvent = FileMetaData & { changed: Array<keyof FileMetaData> };
|
|
741
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The generic, self-describing change envelope every partitioned CRUD stream emits (files, skills, and
|
|
787
|
+
* future partitioned stores) — the payload half of a {@link Routed} event, whose `origin` carries the
|
|
788
|
+
* acting principal/partition. It supersedes the per-stream bespoke shapes (the old `SkillEvent`, the raw
|
|
789
|
+
* `FileEvent` on the wire) so a frontend firehose can filter and route every stream through ONE predicate.
|
|
790
|
+
*
|
|
791
|
+
* `namespace` is the ROUTING namespace the change lives under — `'files'`, `'skills'`, or a document
|
|
792
|
+
* namespace — NOT a content sub-namespace (a file's own `metadata.namespace`, e.g. `'workspace'`, rides in
|
|
793
|
+
* `detail`). Pairing `namespace` + `id` is exactly what `WatchVisibility.visible` needs to decide, per
|
|
794
|
+
* connection, whether the viewer routes that item to the same partition (or has it shared in). `detail`
|
|
795
|
+
* carries an optional rich payload for in-place UI updates — a file change ships its {@link FileMetaData}
|
|
796
|
+
* so a sidebar row can update its size without a re-fetch; a skill change needs none (a refresh ping).
|
|
797
|
+
*/
|
|
798
|
+
export interface StoreChange {
|
|
799
|
+
readonly operation: 'saved' | 'deleted';
|
|
800
|
+
readonly namespace: string;
|
|
801
|
+
readonly id: string;
|
|
802
|
+
readonly detail?: unknown;
|
|
803
|
+
}
|
|
804
|
+
|
|
742
805
|
export interface FileFilter {
|
|
743
806
|
sessionId?: string;
|
|
744
807
|
mimeType?: string;
|
|
@@ -931,6 +994,13 @@ export type PipelineEvent =
|
|
|
931
994
|
// threw), carried live so a frontend renders them without waiting for a session reload. The blocks
|
|
932
995
|
// are already persisted in the session; this event is purely the live-delivery channel.
|
|
933
996
|
| { type: 'marker'; content: MessageContent[]; traceId: string }
|
|
997
|
+
// A mid-turn steer that INTERRUPTED the running turn (see `SteeringPolicy`). Emitted synchronously
|
|
998
|
+
// when the steer is accepted, so the new user bubble lands in correct stream order and a frontend
|
|
999
|
+
// knows the imminent `aborted` (reason 'steer') on `interruptedTraceId` is a yield, not a dead-end:
|
|
1000
|
+
// keep that turn's partial work rendered and expect a continuation. `traceId`/`rootTraceId` identify
|
|
1001
|
+
// the steer submission itself (the continuation turn). Late subscribers reconstruct the bubble from
|
|
1002
|
+
// the pump's per-turn `queued` replay seed / committed store order — this is purely live delivery.
|
|
1003
|
+
| { type: 'steer'; content: MessageContent[]; interruptedTraceId: string; traceId: string; rootTraceId: string }
|
|
934
1004
|
| { type: 'error'; error: string; traceId: string }
|
|
935
1005
|
| { type: 'system-context'; text: string; traceId: string };
|
|
936
1006
|
|
|
@@ -969,6 +1039,11 @@ export interface SubmitOpenOpts extends OpenOpts {
|
|
|
969
1039
|
/** When true, this submission may be merged with others drained in the same batch. Default false
|
|
970
1040
|
* (queue mode: one turn per submission). */
|
|
971
1041
|
concatQueue?: boolean;
|
|
1042
|
+
/** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
|
|
1043
|
+
* 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
|
|
1044
|
+
* committed partial work — and runs this next, 'auto' defers to the registered SteeringPolicy (else
|
|
1045
|
+
* the host default). Meaningless when nothing is running (degrades to a plain enqueue). */
|
|
1046
|
+
mode?: SteeringMode;
|
|
972
1047
|
/** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
|
|
973
1048
|
* it must target the frontend's per-session client connections, not a single request. */
|
|
974
1049
|
prompt?: PromptFn;
|
|
@@ -992,3 +1067,35 @@ export interface SessionRunner {
|
|
|
992
1067
|
* behind it. `busy` is `running || queued > 0`. */
|
|
993
1068
|
status(sessionId: string): { busy: boolean; running: boolean; queued: number };
|
|
994
1069
|
}
|
|
1070
|
+
|
|
1071
|
+
// ── Steering ────────────────────────────────────────────────────────────────────
|
|
1072
|
+
|
|
1073
|
+
/** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
|
|
1074
|
+
* turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
|
|
1075
|
+
* turn boundary. */
|
|
1076
|
+
export type SteeringDecision = 'queue' | 'interrupt';
|
|
1077
|
+
/** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
|
|
1078
|
+
* {@link SteeringPolicy} (else the host default). */
|
|
1079
|
+
export type SteeringMode = SteeringDecision | 'auto';
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* How the runner disposes of a mid-turn submission under `mode: 'auto'`, and how an interrupt's
|
|
1083
|
+
* continuation is nudged. An optional, registerable service ({@link MatbotServices}); absent ⇒ the
|
|
1084
|
+
* runner uses its own defaults. Both members are optional so a plugin may override one, the other, or
|
|
1085
|
+
* both. `classify` is deliberately NOT assumed to be an LLM — a regex or a semantic classifier is a
|
|
1086
|
+
* first-class implementation (return synchronously); an LLM `singleTurn` returns a promise.
|
|
1087
|
+
*/
|
|
1088
|
+
export interface SteeringPolicy {
|
|
1089
|
+
/**
|
|
1090
|
+
* Consulted only for `mode: 'auto'` while a turn is running. `session` is the COMMITTED session
|
|
1091
|
+
* (history up to the running turn's start — the in-flight partial is not reachable at submit time);
|
|
1092
|
+
* `steer` is the incoming submission. Return synchronously (regex) or async (semantic / singleTurn).
|
|
1093
|
+
* Absent ⇒ the host default disposition.
|
|
1094
|
+
*/
|
|
1095
|
+
classify?(ctx: { session: Session; steer: MessageContent[] }): SteeringDecision | Promise<SteeringDecision>;
|
|
1096
|
+
/**
|
|
1097
|
+
* The ephemeral "keep going, noting the above" context folded onto an interrupt's continuation turn
|
|
1098
|
+
* (never persisted). Absent ⇒ the host default nudge.
|
|
1099
|
+
*/
|
|
1100
|
+
nudge?(ctx: { session: Session; steer: MessageContent[] }): MessageContent[];
|
|
1101
|
+
}
|