@matatbread/matbot-core 0.1.0

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.
@@ -0,0 +1,2 @@
1
+ export { VaultImpl } from './vault.js';
2
+ export { systemPrincipal } from './grants.js';
@@ -0,0 +1,76 @@
1
+ import type { Vault } from '@matatbread/matbot-plugin-api';
2
+ import { MissingSecretError, applyCreateSecret } from '@matatbread/matbot-plugin-api';
3
+
4
+ const REF_RE = /\$\{([^}]+)\}/g;
5
+
6
+ /**
7
+ * Default vault implementation: a single flat namespace of secrets keyed by name.
8
+ *
9
+ * Resolves ${NAME} placeholders by looking up `NAME`. There is no env/secret distinction —
10
+ * an env var is simply a secret whose name happens to be its env-var name. A backend may
11
+ * privately treat some prefixes specially, but the placeholder syntax and this interface do not.
12
+ *
13
+ * The caller loads any backing store (e.g. a .env file) into the snapshot passed to the
14
+ * constructor; persistence beyond the current process is a subclass concern (see EnvFileVault).
15
+ */
16
+ export class VaultImpl implements Vault {
17
+ private readonly store = new Map<string, string>();
18
+
19
+ constructor(
20
+ secrets?: Record<string, string>,
21
+ env?: Record<string, string | undefined>,
22
+ ) {
23
+ if (secrets) {
24
+ for (const [k, v] of Object.entries(secrets)) {
25
+ this.store.set(k, v);
26
+ }
27
+ }
28
+ if (env) {
29
+ for (const [k, v] of Object.entries(env)) {
30
+ if (v !== undefined && !this.store.has(k)) this.store.set(k, v);
31
+ }
32
+ }
33
+ }
34
+
35
+ createSecret(name: string, value: string): Promise<string> {
36
+ return applyCreateSecret(this, name, value);
37
+ }
38
+
39
+ async writeSecret(name: string, value: string): Promise<void> {
40
+ this.store.set(name, value);
41
+ }
42
+
43
+ hasKey(name: string): boolean {
44
+ return this.store.has(name);
45
+ }
46
+
47
+ findByValue(value: string): string | undefined {
48
+ for (const [k, v] of this.store) if (v === value) return k;
49
+ return undefined;
50
+ }
51
+
52
+ async resolve(ref: string): Promise<string> {
53
+ const errors: string[] = [];
54
+
55
+ const result = ref.replace(REF_RE, (_, name: string) => {
56
+ const value = this.store.get(name);
57
+ if (value === undefined) { errors.push(name); return ''; }
58
+ return value;
59
+ });
60
+
61
+ if (errors.length > 0) {
62
+ throw new MissingSecretError(errors);
63
+ }
64
+ return result;
65
+ }
66
+
67
+ scrub(text: string): string {
68
+ let result = text;
69
+ for (const value of this.store.values()) {
70
+ if (value.length >= 4) {
71
+ result = result.split(value).join('[REDACTED]');
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ }
@@ -0,0 +1,455 @@
1
+ import type {
2
+ Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
3
+ Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
4
+ ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
5
+ } from './types.js';
6
+ import type { MatbotPlugin } from './plugin.js';
7
+ import type { HookRegistry } from './hooks.js';
8
+ import { appendMessage, createMessage } from './session.js';
9
+ import { contextSwitch } from '@matatbread/matbot-plugin-api';
10
+ import { runSession } from './runner.js';
11
+
12
+ export interface SessionRunnerDeps {
13
+ store: Store<Session>;
14
+ resolveProvider: (name: string) => Promise<{ adapter: ProviderAdapter; config: ProviderConfig } | null>;
15
+ tools?: ToolRegistry;
16
+ hooks?: HookRegistry;
17
+ systemContext?: SystemContextRegistry;
18
+ vault?: Vault;
19
+ files?: FileStore;
20
+ workdir?: string;
21
+ configPath?: string;
22
+ loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
23
+ unloadPlugin: (specifier: string) => Promise<boolean>;
24
+ }
25
+
26
+ interface QueuedItem {
27
+ traceId: string;
28
+ // The originating human turn's traceId, carried down a resubmission chain (a human submit is its
29
+ // own root). Lets a per-turn frontend (e.g. telegram) adopt the followups *its* turn spawned and
30
+ // ignore unrelated turns — lineage the bare per-turn traceId can't express.
31
+ rootTraceId: string;
32
+ content: MessageContent[];
33
+ provider: string;
34
+ principal: Principal;
35
+ concatQueue: boolean;
36
+ // 0 for an external submission; a `react` resubmission carries its parent's depth + 1. The reactor
37
+ // budgets against it, and pump hard-caps it (MAX_RESUBMIT_DEPTH) so a misbehaving reactor can't loop.
38
+ resubmitDepth: number;
39
+ // Present ⇒ this item RE-RUNS an already-committed user turn (an agent-phase retract-redo) rather
40
+ // than introducing a new user message: the pump skips the persist-at-turn-start append (the user
41
+ // message already exists) and hands `ephemeral` (the trigger tool's output) to runSession, which
42
+ // tail-folds it for this run only. `content` is empty for such an item — there is no new bubble.
43
+ redo?: { ephemeral: MessageContent[] };
44
+ prompt?: PromptFn;
45
+ }
46
+
47
+ const MAX_RESUBMIT_DEPTH = 8;
48
+
49
+ // Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
50
+ // messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
51
+ // because the pop is a pump operation, not a plugin's.
52
+ const RETRACTION_CREATOR = 'matbot-retraction';
53
+
54
+ interface SessionState {
55
+ // The queue is deliberately a plain array, drained FIFO. concatQueue is per-submission: a non-concat
56
+ // submission is its own turn — faithful, and correct when one submission's tools/state are a
57
+ // precondition for the next (e.g. "add plugin X" then "use X": tools are rebuilt per turn, so X is
58
+ // only visible to a *later* turn). A run of consecutive concat submissions merges into one turn —
59
+ // cheaper, and the common "let me add more context now" case — without collapsing the queued/robo
60
+ // submissions interleaved among them (see the batch-building loop in pump). Concat and queued mix freely.
61
+ queue: QueuedItem[];
62
+ running: boolean;
63
+ ac: AbortController | undefined;
64
+ subscribers: Set<Sink>;
65
+ // Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
66
+ // turn boundary — committed history comes from the store, not from here.
67
+ replay: PipelineEvent[];
68
+ }
69
+
70
+ interface Sink {
71
+ push(ev: PipelineEvent): void;
72
+ close(): void;
73
+ iterable: AsyncIterable<PipelineEvent>;
74
+ }
75
+
76
+ // A single-consumer push channel surfaced as an AsyncIterable. `dispose` runs when the consumer
77
+ // stops (break/return) or the channel closes, so an abandoned subscriber unregisters itself.
78
+ function createSink(dispose: () => void): Sink {
79
+ const buffer: PipelineEvent[] = [];
80
+ let waiting: ((r: IteratorResult<PipelineEvent>) => void) | null = null;
81
+ let closed = false;
82
+
83
+ const finish = (): void => {
84
+ if (closed) return;
85
+ closed = true;
86
+ if (waiting) { const w = waiting; waiting = null; w({ value: undefined as never, done: true }); }
87
+ };
88
+
89
+ return {
90
+ push(ev) {
91
+ if (closed) return;
92
+ if (waiting) { const w = waiting; waiting = null; w({ value: ev, done: false }); }
93
+ else buffer.push(ev);
94
+ },
95
+ close: finish,
96
+ iterable: {
97
+ [Symbol.asyncIterator](): AsyncIterator<PipelineEvent> {
98
+ return {
99
+ next() {
100
+ const head = buffer.shift();
101
+ if (head !== undefined) return Promise.resolve({ value: head, done: false });
102
+ if (closed) return Promise.resolve({ value: undefined as never, done: true });
103
+ return new Promise(res => { waiting = res; });
104
+ },
105
+ return() {
106
+ finish();
107
+ dispose();
108
+ return Promise.resolve({ value: undefined as never, done: true });
109
+ },
110
+ };
111
+ },
112
+ },
113
+ };
114
+ }
115
+
116
+ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
117
+ const states = new Map<string, SessionState>();
118
+
119
+ const stateFor = (id: string): SessionState => {
120
+ let s = states.get(id);
121
+ if (s === undefined) {
122
+ s = { queue: [], running: false, ac: undefined, subscribers: new Set(), replay: [] };
123
+ states.set(id, s);
124
+ }
125
+ return s;
126
+ };
127
+
128
+ const maybeCleanup = (id: string, s: SessionState): void => {
129
+ if (!s.running && s.queue.length === 0 && s.subscribers.size === 0) states.delete(id);
130
+ };
131
+
132
+ // Turn events go to the replay buffer (so a mid-flight subscriber sees the in-progress turn) and
133
+ // all live subscribers.
134
+ const emit = (s: SessionState, ev: PipelineEvent): void => {
135
+ s.replay.push(ev);
136
+ for (const sink of s.subscribers) sink.push(ev);
137
+ };
138
+
139
+ // Subscribers-only (no replay buffer): used for the `queued` events that describe the pending
140
+ // queue. Pending is NOT kept in `replay` (which is per-turn) — it's reconstructed from `s.queue`
141
+ // when a subscriber joins, so it survives turn boundaries correctly.
142
+ const notify = (s: SessionState, ev: PipelineEvent): void => {
143
+ for (const sink of s.subscribers) sink.push(ev);
144
+ };
145
+
146
+ // How many submissions sit ahead of the item at queue index `i` (the running turn counts as one).
147
+ const aheadOf = (s: SessionState, i: number): number => (s.running ? 1 : 0) + i;
148
+
149
+ const pump = async (id: string, s: SessionState): Promise<void> => {
150
+ if (s.running) return;
151
+ s.running = true;
152
+ try {
153
+ while (s.queue.length > 0) {
154
+ // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
155
+ // the following submissions while they too are concat, stopping at the first non-concat (a turn
156
+ // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
157
+ // while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
158
+ const head = s.queue.shift()!;
159
+ const batch = [head];
160
+ if (head.concatQueue) {
161
+ while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
162
+ }
163
+ const content = batch.flatMap(i => i.content);
164
+ s.replay = [];
165
+ // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
166
+ // message persisted just below. notify() (in open()) reaches only subscribers that are live at
167
+ // enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
168
+ // common case when the submit POST wins the race against the events stream — would otherwise
169
+ // find an empty queue and a cleared replay, and never render the user bubble. One merged event
170
+ // (not one per batch item) matches both stored history on reload and the live fold, so a late
171
+ // subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
172
+ // content), so it seeds nothing — its retraction marker already went out at enqueue time.
173
+ if (head.redo === undefined) {
174
+ s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
175
+ }
176
+
177
+ let session = await deps.store.get(id);
178
+ if (session === null) {
179
+ emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
180
+ continue;
181
+ }
182
+
183
+ // A redo re-runs the existing committed user turn — no title derivation, no new user message.
184
+ if (head.redo === undefined) {
185
+ if (!session.title && !session.messages.some(m => m.role === 'user')) {
186
+ const text = content
187
+ .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
188
+ .map(c => c.text).join(' ').trim();
189
+ if (text) {
190
+ const words = text.split(/\s+/).slice(0, 8).join(' ');
191
+ session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
192
+ }
193
+ }
194
+
195
+ // Persist-at-turn-start: the user message only hits the store when its turn begins, never
196
+ // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
197
+ // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
198
+ session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
199
+ await deps.store.set(session.id, session);
200
+ }
201
+
202
+ const resolved = await deps.resolveProvider(head.provider);
203
+ if (resolved === null) {
204
+ emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
205
+ continue;
206
+ }
207
+
208
+ const ac = new AbortController();
209
+ s.ac = ac;
210
+ const toolMap = deps.tools !== undefined
211
+ ? new Map<string, Tool>(deps.tools.list().map(t => [t.name, t]))
212
+ : undefined;
213
+
214
+ try {
215
+ // Establish the submitter's principal for the whole turn here, not inside runSession:
216
+ // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
217
+ // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
218
+ // access they trigger) reads it via currentPrincipal(). The consumption MUST happen inside
219
+ // the callback: an async iterator returned out of the scope would lose it before it pulls.
220
+ // contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
221
+ // deferred during it lands at this scope's quiescent edge — never mid-CAS.
222
+ await contextSwitch(head.principal, async () => {
223
+ for await (const ev of runSession({
224
+ session,
225
+ config: { provider: head.provider, traceId: head.traceId },
226
+ provider: resolved.adapter,
227
+ providerConfig: resolved.config,
228
+ store: deps.store,
229
+ signal: ac.signal,
230
+ loadPlugin: deps.loadPlugin,
231
+ unloadPlugin: deps.unloadPlugin,
232
+ ...(toolMap !== undefined ? { tools: toolMap } : {}),
233
+ ...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
234
+ ...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
235
+ ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
236
+ ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
237
+ ...(deps.files !== undefined ? { files: deps.files } : {}),
238
+ ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
239
+ ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
240
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
241
+ ...(head.redo !== undefined ? { injectedEphemeral: head.redo.ephemeral } : {}),
242
+ })) {
243
+ emit(s, ev);
244
+ }
245
+ });
246
+
247
+ // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
248
+ // head-enqueue a robo follow-up (its own real turn, running next). Skipped on abort; runs
249
+ // under the submitter's principal because a reactor may itself call complete() (a classifier).
250
+ const hooks = deps.hooks;
251
+ if (!ac.signal.aborted && hooks) {
252
+ const committed = await deps.store.get(id);
253
+ if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
254
+ let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } } = { resubmits: [], markers: [] };
255
+ await contextSwitch(head.principal, async () => {
256
+ followup = await hooks.runFollowup({
257
+ session: committed,
258
+ resubmitDepth: head.resubmitDepth,
259
+ config: { provider: head.provider, traceId: head.traceId },
260
+ signal: ac.signal,
261
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
262
+ });
263
+ });
264
+ const resubmits = followup.resubmits;
265
+
266
+ // Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
267
+ // what it did). Append-only to the just-committed session; the pump is the sole writer
268
+ // post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
269
+ // `queued` event) so a live draw matches a reload — the engine surfaces everything it
270
+ // persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
271
+ // the message tail, so these markers are folded into that single write instead (below)
272
+ // to keep ordering sane (a separate append here would be sliced into the popped region).
273
+ if (followup.markers.length > 0 && !followup.retract) {
274
+ await deps.store.set(id, {
275
+ ...committed,
276
+ messages: [...committed.messages, {
277
+ id: crypto.randomUUID(),
278
+ role: 'marker',
279
+ createdAt: new Date().toISOString(),
280
+ traceId: head.traceId,
281
+ content: followup.markers,
282
+ }],
283
+ });
284
+ notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
285
+ }
286
+ // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
287
+ // every block `origin: 'robo'` here — once — so it rides into both the live `queued`
288
+ // event and the persisted user message the pump builds from this item.
289
+ for (const raw of resubmits.reverse()) {
290
+ const rt = crypto.randomUUID();
291
+ const content = raw.map(c => (c.type === 'text' ? { ...c, origin: 'robo' as const } : c));
292
+ s.queue.unshift({
293
+ traceId: rt,
294
+ rootTraceId: head.rootTraceId,
295
+ content,
296
+ provider: head.provider,
297
+ principal: head.principal,
298
+ concatQueue: false,
299
+ resubmitDepth: head.resubmitDepth + 1,
300
+ });
301
+ notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
302
+ }
303
+
304
+ // retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
305
+ // message, stash the popped messages in a durable retraction marker (LLM-elided like any
306
+ // marker — so the model never re-reads its superseded answer — but carried in `data` for
307
+ // a strike-through render and audit), then re-enqueue a redo of that same user turn at the
308
+ // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
309
+ // sits at the very head (runs next) even if a resubmit was also queued above.
310
+ if (followup.retract) {
311
+ const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
312
+ const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
313
+ const kept = lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages;
314
+ const retractionMsg: Message = {
315
+ id: crypto.randomUUID(),
316
+ role: 'marker',
317
+ createdAt: new Date().toISOString(),
318
+ traceId: head.traceId,
319
+ // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
320
+ // context fed to the redo — neither is otherwise persisted, so the pair fully traces
321
+ // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
322
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context, traceId: head.traceId } }],
323
+ };
324
+ // Any followup markers ride along as a trailing marker message (not folded into the
325
+ // retraction marker) so each creator's trace stays its own block.
326
+ const trailing: Message[] = followup.markers.length > 0
327
+ ? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
328
+ : [];
329
+ await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
330
+ notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
331
+ if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
332
+
333
+ const rt = crypto.randomUUID();
334
+ s.queue.unshift({
335
+ traceId: rt,
336
+ rootTraceId: head.rootTraceId,
337
+ content: [],
338
+ provider: head.provider,
339
+ principal: head.principal,
340
+ concatQueue: false,
341
+ resubmitDepth: head.resubmitDepth + 1,
342
+ redo: { ephemeral: followup.retract.context },
343
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
344
+ });
345
+ }
346
+ }
347
+ }
348
+ } catch (e) {
349
+ emit(s, { type: 'error', error: String(e), traceId: head.traceId });
350
+ } finally {
351
+ s.ac = undefined;
352
+ }
353
+ }
354
+ } finally {
355
+ s.running = false;
356
+ s.replay = [];
357
+ // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
358
+ // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
359
+ // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
360
+ notify(s, { type: 'idle', sessionId: id });
361
+ maybeCleanup(id, s);
362
+ }
363
+ };
364
+
365
+ return {
366
+ async open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView> {
367
+ // For a pure read (no content, events never tapped) we must not materialise a state entry —
368
+ // otherwise every session_action `get` would leak one. State is created only when there is
369
+ // something to queue or someone to subscribe.
370
+ let s = states.get(opts.sessionId);
371
+
372
+ let traceId: string | undefined;
373
+ if ('content' in opts) {
374
+ // Belt-and-braces: the union enforces this for TS callers, but a frontend deserialising a
375
+ // request body can still hand us a malformed object — fail loudly at the boundary.
376
+ if (opts.provider === undefined || opts.principal === undefined) {
377
+ throw new Error('open(): submitting content requires both provider and principal');
378
+ }
379
+ s = stateFor(opts.sessionId);
380
+ traceId = opts.traceId ?? crypto.randomUUID();
381
+ const concatQueue = opts.concatQueue ?? false;
382
+ s.queue.push({
383
+ traceId,
384
+ rootTraceId: traceId,
385
+ content: opts.content,
386
+ provider: opts.provider,
387
+ principal: opts.principal,
388
+ concatQueue,
389
+ resubmitDepth: 0,
390
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
391
+ });
392
+ // Announce the submission on the stream as part of the live delta, before its turn's events.
393
+ // If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
394
+ // tells a frontend whether this submission will merge into the running batch (so it can fold the
395
+ // bubble) or run as its own turn.
396
+ notify(s, { type: 'queued', content: opts.content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
397
+ void pump(opts.sessionId, s);
398
+ }
399
+
400
+ // Stored state is pure committed history (ends at the running turn's user message). The pending
401
+ // queue and the in-progress response are the "delta", delivered over `events` — never overlaid
402
+ // here — so `stored ++ delta` concatenates in a single, refresh-stable order.
403
+ const base = await deps.store.get(opts.sessionId);
404
+ if (base === null) throw new Error(`Session "${opts.sessionId}" not found`);
405
+
406
+ let cached: AsyncIterable<PipelineEvent> | undefined;
407
+ const view: SessionView = {
408
+ session: base,
409
+ queued: s !== undefined ? s.queue.length : 0,
410
+ ...(traceId !== undefined ? { traceId } : {}),
411
+ get events(): AsyncIterable<PipelineEvent> {
412
+ if (cached === undefined) {
413
+ const st = stateFor(opts.sessionId);
414
+ const remove = (): void => { st.subscribers.delete(sink); maybeCleanup(opts.sessionId, st); };
415
+ const sink = createSink(remove);
416
+ st.subscribers.add(sink);
417
+ // Replay the in-progress turn, then the pending queue — the delta region, in order.
418
+ for (const ev of st.replay) sink.push(ev);
419
+ st.queue.forEach((item, i) =>
420
+ sink.push({ type: 'queued', content: item.content, queued: aheadOf(st, i), concatQueue: item.concatQueue, traceId: item.traceId, rootTraceId: item.rootTraceId }));
421
+ if (opts.signal.aborted) { sink.close(); remove(); }
422
+ else opts.signal.addEventListener('abort', () => { sink.close(); remove(); }, { once: true });
423
+ cached = sink.iterable;
424
+ }
425
+ return cached;
426
+ },
427
+ };
428
+ return view;
429
+ },
430
+
431
+ abort(sessionId: string): void {
432
+ const s = states.get(sessionId);
433
+ if (s === undefined) return;
434
+ for (const item of s.queue.splice(0)) {
435
+ emit(s, { type: 'cancelled', sessionId, traceId: item.traceId });
436
+ }
437
+ s.ac?.abort('user-abort');
438
+ },
439
+
440
+ cancelTurn(sessionId: string): void {
441
+ const s = states.get(sessionId);
442
+ if (s === undefined) return;
443
+ // Deliberately no queue splice: cancel abandons only the running turn; queued submissions are
444
+ // separate operations the user lined up and survive into the next `pump` iteration.
445
+ s.ac?.abort('user-cancel');
446
+ },
447
+
448
+ status(sessionId: string): { busy: boolean; running: boolean; queued: number } {
449
+ const s = states.get(sessionId);
450
+ const running = s?.running ?? false;
451
+ const queued = s?.queue.length ?? 0;
452
+ return { busy: running || queued > 0, running, queued };
453
+ },
454
+ };
455
+ }
package/src/session.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Session, Message, MessageContent, MessageRole, Principal } from './types.js';
2
+
3
+ export interface CreateSessionOpts {
4
+ ownerPrincipal: Principal;
5
+ actorPrincipal?: Principal;
6
+ persona?: string;
7
+ title?: string;
8
+ contexts?: string[];
9
+ parentSessionId?: string;
10
+ branchPointMessageId?: string;
11
+ }
12
+
13
+ export function createSession(opts: CreateSessionOpts): Session {
14
+ const now = new Date().toISOString();
15
+ return {
16
+ id: crypto.randomUUID(),
17
+ version: crypto.randomUUID(),
18
+ ownerPrincipalId: opts.ownerPrincipal.id,
19
+ ...(opts.actorPrincipal && opts.actorPrincipal.id !== opts.ownerPrincipal.id
20
+ ? { actorPrincipalId: opts.actorPrincipal.id }
21
+ : {}),
22
+ ...(opts.persona !== undefined ? { persona: opts.persona } : {}),
23
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
24
+ ...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
25
+ ...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
26
+ status: 'active',
27
+ contexts: opts.contexts ?? [],
28
+ messages: [],
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ };
32
+ }
33
+
34
+ export function appendMessage(session: Session, message: Message): Session {
35
+ return {
36
+ ...session,
37
+ messages: [...session.messages, message],
38
+ updatedAt: new Date().toISOString(),
39
+ version: crypto.randomUUID(),
40
+ };
41
+ }
42
+
43
+ export function createMessage(opts: {
44
+ role: MessageRole;
45
+ content: MessageContent[];
46
+ traceId: string;
47
+ providerName?: string;
48
+ metadata?: Record<string, unknown>;
49
+ }): Message {
50
+ return {
51
+ id: crypto.randomUUID(),
52
+ role: opts.role,
53
+ content: opts.content,
54
+ createdAt: new Date().toISOString(),
55
+ traceId: opts.traceId,
56
+ ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
57
+ ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
58
+ };
59
+ }
@@ -0,0 +1,66 @@
1
+ import type { PluginSettings, Store } from '@matatbread/matbot-plugin-api';
2
+
3
+ export interface SettingsDoc {
4
+ id: string;
5
+ version: string;
6
+ data: Record<string, unknown>;
7
+ }
8
+
9
+ export function isSettingsDoc(v: unknown): v is SettingsDoc {
10
+ return typeof v === 'object' && v !== null &&
11
+ typeof (v as SettingsDoc).id === 'string' &&
12
+ typeof (v as SettingsDoc).version === 'string' &&
13
+ typeof (v as SettingsDoc).data === 'object' && (v as SettingsDoc).data !== null;
14
+ }
15
+
16
+ /**
17
+ * Settings namespaces double as Store document ids, which the filesystem store restricts to
18
+ * /^[\w-]+$/. Plugin names are now loader-derived package names (`@scope/pkg`), so slug them to a
19
+ * safe id. Collisions are theoretically possible but irrelevant for this install scale.
20
+ */
21
+ export function slugSettingsNamespace(name: string): string {
22
+ return name.replace(/[^\w-]+/g, '_');
23
+ }
24
+
25
+ /** Build a PluginSettings facade over the shared settings store, scoped to one document id. */
26
+ export function makePluginSettings(store: Store<SettingsDoc>, namespace: string): PluginSettings {
27
+ const id = slugSettingsNamespace(namespace);
28
+
29
+ // Handles migration from the old flat-object format (pre-Store).
30
+ const getDoc = async (): Promise<SettingsDoc | null> => {
31
+ const raw = await store.get(id);
32
+ if (raw === null) return null;
33
+ if (isSettingsDoc(raw)) return raw;
34
+ // Old format: flat { key: value } — wrap it so subsequent writes upgrade the file.
35
+ return { id, version: '0', data: raw as unknown as Record<string, unknown> };
36
+ };
37
+
38
+ return {
39
+ async get<T>(key: string): Promise<T | undefined> {
40
+ return (await getDoc())?.data[key] as T | undefined;
41
+ },
42
+ async set<T>(key: string, value: T): Promise<void> {
43
+ for (;;) {
44
+ const doc = await getDoc();
45
+ const data = { ...(doc?.data ?? {}), [key]: value as unknown };
46
+ const next: SettingsDoc = { id, version: Date.now().toString(), data };
47
+ // version '0' means migrated-but-not-yet-written — use set to upgrade the file.
48
+ if (doc === null || doc.version === '0') { await store.set(id, next); return; }
49
+ const r = await store.cas(id, doc.version, next);
50
+ if (r.ok) return;
51
+ }
52
+ },
53
+ async delete(key: string): Promise<void> {
54
+ for (;;) {
55
+ const doc = await getDoc();
56
+ if (doc === null) return;
57
+ const data = { ...doc.data };
58
+ delete data[key];
59
+ const next: SettingsDoc = { id, version: Date.now().toString(), data };
60
+ if (doc.version === '0') { await store.set(id, next); return; }
61
+ const r = await store.cas(id, doc.version, next);
62
+ if (r.ok) return;
63
+ }
64
+ },
65
+ };
66
+ }