@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.
- package/LICENSE +201 -0
- package/README.md +23 -0
- package/package.json +39 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.ts +123 -0
- package/src/config/yaml.ts +127 -0
- package/src/hooks.ts +1 -0
- package/src/index.ts +32 -0
- package/src/knowledge/index.ts +1 -0
- package/src/knowledge/lookup-knowledge-index.ts +58 -0
- package/src/loader.ts +340 -0
- package/src/plugin.ts +1 -0
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/sse.ts +28 -0
- package/src/registry.ts +348 -0
- package/src/runner.ts +324 -0
- package/src/security/grants.ts +6 -0
- package/src/security/index.ts +2 -0
- package/src/security/vault.ts +76 -0
- package/src/session-runner.ts +455 -0
- package/src/session.ts +59 -0
- package/src/settings.ts +66 -0
- package/src/single-turn.ts +64 -0
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/query/access.ts +16 -0
- package/src/storage-base/query/compile.ts +42 -0
- package/src/storage-base/query/execute.ts +49 -0
- package/src/storage-base/query/index.ts +6 -0
- package/src/storage-base/query/paginate.ts +26 -0
- package/src/storage-base/query/sort.ts +29 -0
- package/src/storage-base/query/validate.ts +113 -0
- package/src/system-context.ts +26 -0
- package/src/tool-registry.ts +38 -0
- package/src/types.ts +13 -0
|
@@ -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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|