@stratusagent/gateway 0.14.2 → 0.14.3

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,675 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createMemoryPassTools, isValidAgentId } from '@stratusagent/agents';
3
+ import { assertMemoryAboutWithinCap, conversationContextFrom, HostRefusalError, isMemoryPassSessionId, MEMORY_ENTRY_MAX_BYTES, MEMORY_PASS_SLICE_MAX_BYTES, MEMORY_SAVE_PASS_INSTRUCTION, MEMORY_SAVE_PASS_REQUEST, MEMORY_TIDY_CHUNK_MAX_BYTES, MEMORY_TIDY_GROUP_MIN, MEMORY_TIDY_PASS_INSTRUCTION, MEMORY_TIDY_PASS_REQUEST, memoryEntryAbout, memoryEntryFields, memoryEntryTrust, memoryPassSessionId, sessionWriteTrust, transcriptEntry, } from '@stratusagent/core';
4
+ import { agentMemoryPassesFileIn, agentMemoryShadowFileIn, createMemoryPassLedger, createMemoryShadowFile, memoryPassActivations, memoryPassEntries, memoryPassMessageCounts, memoryPassOpenClaims, memoryPassSettleRecord, memoryPassSummaries, memoryPassTidyClaimed, memoryPassWatermark, memoryShadowEntriesOf, } from '@stratusagent/state';
5
+ const MODES = ['write', 'shadow'];
6
+ const KINDS = ['direct', 'group', 'private', 'public'];
7
+ const SCHEDULED_METADATA_KEY = 'scheduled';
8
+ /**
9
+ * What a save pass shows the model of each message: whole, apart from a
10
+ * tool call's input, which says what was done rather than what was learned.
11
+ * The slice's byte cap is what bounds it.
12
+ */
13
+ const SAVE_PASS_ENTRY_LIMITS = { message: Number.POSITIVE_INFINITY, toolResult: Number.POSITIVE_INFINITY, toolInput: 500 };
14
+ const utf8 = (text) => Buffer.byteLength(text, 'utf8');
15
+ /** The first `bytes` of `text`, never splitting a character. */
16
+ const firstBytes = (text, bytes) => {
17
+ const cut = Buffer.from(text, 'utf8').subarray(0, Math.max(0, bytes)).toString('utf8');
18
+ return cut.endsWith('�') && !text.startsWith(cut) ? cut.slice(0, -1) : cut;
19
+ };
20
+ /**
21
+ * The messages a save pass reads next (spec 962): after the watermark,
22
+ * written while upkeep was on, oldest first, up to about
23
+ * `MEMORY_PASS_SLICE_MAX_BYTES`. A message alone over the cap goes in a
24
+ * slice of its own, cut to fit, and the slice says how much was left out,
25
+ * so the watermark always moves. Undefined when nothing is new.
26
+ */
27
+ export const takeSaveSlice = (messages, watermark, counts, maxBytes = MEMORY_PASS_SLICE_MAX_BYTES) => {
28
+ // A watermark that names no message in the session (it cannot happen
29
+ // while messages are never deleted) reads from the start: the activation
30
+ // filter still holds, and the rules have the pass recall before saving.
31
+ const after = watermark === undefined ? 0 : messages.findIndex((message) => message.id === watermark) + 1;
32
+ const pending = messages.slice(after).filter((message) => counts(message.createdAt));
33
+ if (pending.length === 0) {
34
+ return undefined;
35
+ }
36
+ const entries = [];
37
+ let bytes = 0;
38
+ let taken = 0;
39
+ let cut;
40
+ for (const message of pending) {
41
+ const entry = transcriptEntry(message, SAVE_PASS_ENTRY_LIMITS);
42
+ const size = utf8(entry) + (entries.length > 0 ? 1 : 0);
43
+ if (taken === 0 && size > maxBytes) {
44
+ const kept = firstBytes(entry, maxBytes);
45
+ const omitted = utf8(entry) - utf8(kept);
46
+ entries.push(`${kept}\n[The runtime left out the last ${omitted} bytes of this message.]`);
47
+ cut = { messageId: message.id, bytesOmitted: omitted };
48
+ taken = 1;
49
+ break;
50
+ }
51
+ if (bytes + size > maxBytes) {
52
+ break;
53
+ }
54
+ entries.push(entry);
55
+ bytes += size;
56
+ taken += 1;
57
+ }
58
+ const last = pending[taken - 1];
59
+ return {
60
+ entries,
61
+ slice: { from: pending[0].id, to: last.id, count: taken, ...(cut !== undefined ? { cut } : {}) },
62
+ more: taken < pending.length,
63
+ };
64
+ };
65
+ /** The save pass's one message: the request, the conversation in tags, the instruction after it. */
66
+ export const saveSlicePrompt = (entries) => `${MEMORY_SAVE_PASS_REQUEST}\n\n<conversation>\n${entries.join('\n')}\n</conversation>\n\n${MEMORY_SAVE_PASS_INSTRUCTION}`;
67
+ /**
68
+ * A fact as a tidy pass reads it: data, not prose. `<` is escaped so no
69
+ * fact's text can close the `<facts>` tag it is sent in; JSON reads it
70
+ * back the same.
71
+ */
72
+ const tidyLine = (entry) => JSON.stringify({
73
+ id: entry.id,
74
+ text: entry.content,
75
+ ...(entry.kind !== undefined ? { kind: entry.kind } : {}),
76
+ created: entry.createdAt,
77
+ ...(entry.validFrom !== undefined ? { validFrom: entry.validFrom } : {}),
78
+ ...(entry.validUntil !== undefined ? { validUntil: entry.validUntil } : {}),
79
+ }).replace(/</g, '\\u003c');
80
+ const chunkHash = (members) => createHash('sha256').update(members.map((entry) => entry.id).sort().join('\n')).digest('hex');
81
+ /** A group's facts in chunks of about `maxBytes`; a fact alone over it (its JSON escapes can grow it) still goes, by itself. */
82
+ const tidyChunks = (members, maxBytes) => {
83
+ const chunks = [];
84
+ let current = { members: [], lines: [] };
85
+ let bytes = 0;
86
+ for (const entry of members) {
87
+ const line = tidyLine(entry);
88
+ if (current.lines.length > 0 && bytes + 1 + utf8(line) > maxBytes) {
89
+ chunks.push(current);
90
+ current = { members: [], lines: [] };
91
+ bytes = 0;
92
+ }
93
+ bytes += utf8(line) + (current.lines.length > 0 ? 1 : 0);
94
+ current.members.push(entry);
95
+ current.lines.push(line);
96
+ }
97
+ if (current.lines.length > 0) {
98
+ chunks.push(current);
99
+ }
100
+ return chunks;
101
+ };
102
+ /**
103
+ * The chunk a tidy pass is handed next (spec 962). A group is at least
104
+ * `MEMORY_TIDY_GROUP_MIN` live facts sharing an about key, a kind and a
105
+ * label, leaving out episodic facts (#961 retires those), pinned ones and
106
+ * operator-asserted ones (no pass may supersede either). A fact with two
107
+ * about keys counts in both groups. The largest group goes first; a group
108
+ * over `MEMORY_TIDY_CHUNK_MAX_BYTES` goes in chunks, oldest facts first.
109
+ * A chunk whose member ids a pass of this mode already looked at
110
+ * (`claimed`) is passed over for the next, so a chunk that merges nothing
111
+ * still moves the tidy on, while a merge changes the members and so the
112
+ * chunks. Undefined when no chunk is left.
113
+ */
114
+ export const takeTidyChunk = (live, pinned, claimed, maxBytes = MEMORY_TIDY_CHUNK_MAX_BYTES) => {
115
+ const groups = new Map();
116
+ for (const entry of live) {
117
+ const trust = memoryEntryTrust(entry);
118
+ if (entry.kind === 'episodic' || trust === 'user' || pinned.has(entry.id)) {
119
+ continue;
120
+ }
121
+ for (const about of memoryEntryAbout(entry)) {
122
+ const key = `${entry.kind ?? 'none'}/${trust}/${about.normalize('NFC').toLowerCase()}`;
123
+ const group = groups.get(key);
124
+ if (group === undefined) {
125
+ groups.set(key, { trust, members: [entry] });
126
+ }
127
+ else {
128
+ group.members.push(entry);
129
+ }
130
+ }
131
+ }
132
+ const ordered = [...groups.entries()]
133
+ .filter(([, group]) => group.members.length >= MEMORY_TIDY_GROUP_MIN)
134
+ .sort(([a, x], [b, y]) => y.members.length - x.members.length || (a < b ? -1 : a > b ? 1 : 0));
135
+ let taken;
136
+ for (const [key, group] of ordered) {
137
+ const oldestFirst = group.members
138
+ .map((entry, index) => ({ entry, index }))
139
+ .sort((a, b) => (a.entry.createdAt < b.entry.createdAt ? -1 : a.entry.createdAt > b.entry.createdAt ? 1 : a.index - b.index))
140
+ .map(({ entry }) => entry);
141
+ for (const chunk of tidyChunks(oldestFirst, maxBytes)) {
142
+ const hash = chunkHash(chunk.members);
143
+ if (claimed.has(hash)) {
144
+ continue;
145
+ }
146
+ if (taken === undefined) {
147
+ taken = { key, trust: group.trust, ...chunk, hash, more: false };
148
+ }
149
+ else if (hash !== taken.hash) {
150
+ taken.more = true;
151
+ return taken;
152
+ }
153
+ }
154
+ }
155
+ return taken;
156
+ };
157
+ /** The tidy pass's one message: the request, the facts in tags, the instruction after them. */
158
+ export const tidyChunkPrompt = (lines) => `${MEMORY_TIDY_PASS_REQUEST}\n\n<facts>\n${lines.join('\n')}\n</facts>\n\n${MEMORY_TIDY_PASS_INSTRUCTION}`;
159
+ class MalformedPassRequest extends Error {
160
+ }
161
+ const malformed = (message) => {
162
+ throw new MalformedPassRequest(message);
163
+ };
164
+ /**
165
+ * The gateway's `PluginContext.memoryPasses` (spec 962). A plugin decides
166
+ * when to ask; everything a pass may read and write is decided here.
167
+ */
168
+ export const createMemoryPassHost = (options) => {
169
+ const now = () => (options.now?.() ?? new Date()).toISOString();
170
+ const newPassId = options.passId ?? (() => randomUUID());
171
+ const fileOptions = options.stateHome !== undefined ? { stateHome: options.stateHome } : {};
172
+ const ledgers = new Map();
173
+ const shadows = new Map();
174
+ const ledgerFor = (agentId) => {
175
+ let ledger = ledgers.get(agentId);
176
+ if (ledger === undefined) {
177
+ ledger = createMemoryPassLedger(agentMemoryPassesFileIn(options.stateDir, agentId), fileOptions);
178
+ ledgers.set(agentId, ledger);
179
+ }
180
+ return ledger;
181
+ };
182
+ const shadowFor = (agentId) => {
183
+ let shadow = shadows.get(agentId);
184
+ if (shadow === undefined) {
185
+ shadow = createMemoryShadowFile(agentMemoryShadowFileIn(options.stateDir, agentId), fileOptions);
186
+ shadows.set(agentId, shadow);
187
+ }
188
+ return shadow;
189
+ };
190
+ /** `agentId\0mode` -> the start, written once per run. */
191
+ const started = new Map();
192
+ /**
193
+ * `agentId\0mode` -> a start's settle of the claims a crash left open,
194
+ * for a start made before `ready`. A pass for that agent and mode waits
195
+ * for it, so it never reads a watermark the settle is about to move.
196
+ */
197
+ const settling = new Map();
198
+ let isReady = false;
199
+ let openReadiness;
200
+ /** True at `ready`; false when the daemon stops first, and nothing settles. */
201
+ const readiness = new Promise((resolve) => {
202
+ openReadiness = resolve;
203
+ });
204
+ /** Before `ready` the roster is not loaded yet, so only the id's form can be checked. */
205
+ const knownAgent = (agentId) => typeof agentId === 'string' && (isReady ? options.hasAgent(agentId) : isValidAgentId(agentId));
206
+ /** Agents with a pass running: one at a time each. */
207
+ const busy = new Set();
208
+ const running = new Set();
209
+ const stopAll = new AbortController();
210
+ let closing = false;
211
+ /** Entries a pass wrote, from the store it wrote to. */
212
+ const writtenBy = async (agentId, mode, passId) => {
213
+ if (mode === 'shadow') {
214
+ return memoryShadowEntriesOf(await shadowFor(agentId).read(), passId);
215
+ }
216
+ return options.memory === undefined ? [] : memoryPassEntries(options.memory, agentId, passId);
217
+ };
218
+ const settle = async (agentId, mode, claim) => {
219
+ const written = await writtenBy(agentId, mode, claim.passId);
220
+ await ledgerFor(agentId).append(memoryPassSettleRecord(claim, written, { at: now(), run: options.run }));
221
+ };
222
+ const start = (agentId, mode) => {
223
+ if (!knownAgent(agentId)) {
224
+ return Promise.reject(new MalformedPassRequest(`memoryPasses.start: there is no agent ${String(agentId)}.`));
225
+ }
226
+ if (!MODES.includes(mode)) {
227
+ return Promise.reject(new MalformedPassRequest(`memoryPasses.start: mode must be "write" or "shadow" (got ${String(mode)}).`));
228
+ }
229
+ if (closing) {
230
+ return Promise.reject(new Error('memoryPasses.start: the daemon is stopping.'));
231
+ }
232
+ const key = `${agentId}\0${mode}`;
233
+ const existing = started.get(key);
234
+ if (existing !== undefined) {
235
+ return existing;
236
+ }
237
+ const begun = (async () => {
238
+ const ledger = ledgerFor(agentId);
239
+ // Open claims first, read before this run's start is written: the
240
+ // settle comes after the start, so it does not stretch the crashed
241
+ // run's span, which still ends at the claim.
242
+ const open = memoryPassOpenClaims(await ledger.read(), mode);
243
+ const startedAt = now();
244
+ await ledger.append({ type: 'start', at: startedAt, run: options.run, mode });
245
+ const settleOpen = async () => {
246
+ for (const claim of open) {
247
+ await settle(agentId, mode, claim);
248
+ options.log(`memory pass ${claim.passId} (${agentId}, ${mode}) was cut off by the last stop and is settled`);
249
+ }
250
+ };
251
+ if (isReady) {
252
+ await settleOpen();
253
+ return { startedAt };
254
+ }
255
+ // From a plugin's setup: settled at `ready`. One that fails there
256
+ // leaves its claims open, which reads as kept, so no slice is read
257
+ // twice; the next start tries again.
258
+ settling.set(key, readiness.then(async (ready) => {
259
+ if (!ready) {
260
+ return;
261
+ }
262
+ if (!options.hasAgent(agentId)) {
263
+ options.log(`memory passes were started for ${agentId}, which this daemon has no agent for; its passes will be refused`);
264
+ }
265
+ await settleOpen().catch((error) => {
266
+ options.log(`could not settle the memory passes a stop cut off for ${agentId} (${mode}): ${error instanceof Error ? error.message : String(error)}`);
267
+ });
268
+ }));
269
+ return { startedAt };
270
+ })();
271
+ started.set(key, begun);
272
+ // A start that failed (a ledger it could not write) can be asked again.
273
+ begun.catch(() => {
274
+ if (started.get(key) === begun) {
275
+ started.delete(key);
276
+ }
277
+ });
278
+ return begun;
279
+ };
280
+ const finish = (agentId, mode, outcome, kind = 'save') => {
281
+ options.emit({
282
+ type: 'memory.pass-completed',
283
+ sessionId: memoryPassSessionId(outcome.passId),
284
+ agentId,
285
+ passId: outcome.passId,
286
+ kind,
287
+ mode,
288
+ status: outcome.status,
289
+ ...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
290
+ saved: outcome.saved,
291
+ superseded: outcome.superseded,
292
+ });
293
+ options.log(`memory pass ${outcome.passId} (${agentId}, ${kind}, ${mode}): ${outcome.status}`
294
+ + `${outcome.reason !== undefined ? ` (${outcome.reason})` : ''}, saved ${outcome.saved}, superseded ${outcome.superseded}`);
295
+ return outcome;
296
+ };
297
+ const skipped = (agentId, mode, passId, reason, kind = 'save') => finish(agentId, mode, { passId, status: 'skipped', reason, saved: 0, superseded: 0, more: false }, kind);
298
+ const validSave = (request) => {
299
+ if (!knownAgent(request.agentId)) {
300
+ malformed(`memoryPasses.run: there is no agent ${String(request.agentId)}.`);
301
+ }
302
+ if (typeof request.sessionId !== 'string' || request.sessionId.length === 0) {
303
+ malformed('memoryPasses.run: a save pass needs a sessionId.');
304
+ }
305
+ if (request.reason !== 'quiet' && request.reason !== 'floor') {
306
+ malformed(`memoryPasses.run: reason must be "quiet" or "floor" (got ${String(request.reason)}).`);
307
+ }
308
+ if (request.from !== undefined && (!Array.isArray(request.from) || request.from.some((kind) => !KINDS.includes(kind)))) {
309
+ malformed(`memoryPasses.run: from may list only ${KINDS.join(', ')}.`);
310
+ }
311
+ if (request.model !== undefined && (typeof request.model !== 'string' || request.model.trim().length === 0)) {
312
+ malformed('memoryPasses.run: model, when given, must name one.');
313
+ }
314
+ const mode = request.mode ?? 'write';
315
+ if (!MODES.includes(mode)) {
316
+ malformed(`memoryPasses.run: mode must be "write" or "shadow" (got ${String(mode)}).`);
317
+ }
318
+ return { from: request.from !== undefined ? [...request.from] : ['public'], mode };
319
+ };
320
+ const shadowWrite = (agentId) => async (_agentId, content, appendOptions) => {
321
+ if (utf8(content) > MEMORY_ENTRY_MAX_BYTES) {
322
+ throw new Error(`A fact is at most ${MEMORY_ENTRY_MAX_BYTES} bytes. Nothing was stored.`);
323
+ }
324
+ // Normalized and capped the way the store's own append does it, so a
325
+ // shadow fact is one a real write would have stored.
326
+ const fields = memoryEntryFields(appendOptions);
327
+ assertMemoryAboutWithinCap(fields.about ?? []);
328
+ const id = `${agentId}:shadow:${randomUUID()}`;
329
+ const trust = appendOptions.provenance?.trust ?? 'agent';
330
+ const { provenance, metadata } = appendOptions;
331
+ await shadowFor(agentId).append({
332
+ id,
333
+ agentId,
334
+ createdAt: now(),
335
+ content,
336
+ trust,
337
+ ...(provenance?.origin !== undefined ? { origin: provenance.origin } : {}),
338
+ ...fields,
339
+ metadata: metadata ?? {},
340
+ });
341
+ return { id, trust };
342
+ };
343
+ /**
344
+ * An end record that cannot be written leaves its claim open, which the
345
+ * next start settles from the entries, so the pass still answers.
346
+ */
347
+ const appendEnd = async (ledger, end) => {
348
+ try {
349
+ await ledger.append(end);
350
+ }
351
+ catch (error) {
352
+ options.log(`memory pass ${end.passId}: could not write its end record (${error instanceof Error ? error.message : String(error)}); the next start settles it`);
353
+ }
354
+ };
355
+ /**
356
+ * A pass from its claim on: the one model run with only the pass's recall
357
+ * and remember, then its end record. Shared by save and tidy.
358
+ */
359
+ const runClaimed = async (pass) => {
360
+ const { agentId, mode, passId, model, claim, more } = pass;
361
+ const kind = pass.tools.kind;
362
+ const ledger = ledgerFor(agentId);
363
+ const tools = createMemoryPassTools(pass.memory, pass.tools, {
364
+ supersedesMany: options.supersedesMany?.() === true,
365
+ ...(mode === 'shadow' ? { write: shadowWrite(agentId) } : {}),
366
+ });
367
+ const signal = pass.signal !== undefined ? AbortSignal.any([stopAll.signal, pass.signal]) : stopAll.signal;
368
+ let failure;
369
+ try {
370
+ if (signal.aborted) {
371
+ throw signal.reason;
372
+ }
373
+ await options.runModel({
374
+ agentId,
375
+ passId,
376
+ sessionId: memoryPassSessionId(passId),
377
+ // The model the claim records, resolved once: the agent's config
378
+ // could change before this, and the ledger must name what ran.
379
+ model: model !== '' ? model : undefined,
380
+ prompt: pass.prompt,
381
+ trust: pass.tools.trust,
382
+ tools: [tools.recall, tools.remember],
383
+ signal,
384
+ });
385
+ }
386
+ catch (error) {
387
+ failure = error;
388
+ }
389
+ if (failure !== undefined && signal.aborted) {
390
+ // Cut off, by a stop or by the plugin's own signal: settled from
391
+ // what it wrote, as a crash is, never from a counter.
392
+ const reason = stopAll.signal.aborted ? 'stopped' : 'cancelled';
393
+ const written = await writtenBy(agentId, mode, passId);
394
+ const end = memoryPassSettleRecord(claim, written, { at: now(), run: options.run });
395
+ await appendEnd(ledger, { ...end, reason, more });
396
+ return finish(agentId, mode, {
397
+ passId, status: 'interrupted', reason, saved: end.saved, superseded: end.superseded, more,
398
+ }, kind);
399
+ }
400
+ const { saved, superseded } = tools.tally();
401
+ if (failure !== undefined) {
402
+ const reason = failure instanceof HostRefusalError ? 'budget' : 'model';
403
+ options.log(`memory pass ${passId} (${agentId}) failed: ${failure instanceof Error ? failure.message : String(failure)}`);
404
+ // Wrote nothing: what it was handed is given back, so it is read again.
405
+ await appendEnd(ledger, {
406
+ type: 'end', at: now(), run: options.run, passId, status: 'failed', reason, saved, superseded,
407
+ more, released: saved === 0,
408
+ });
409
+ return finish(agentId, mode, { passId, status: 'failed', reason, saved, superseded, more }, kind);
410
+ }
411
+ await appendEnd(ledger, {
412
+ type: 'end', at: now(), run: options.run, passId, status: 'done', saved, superseded, more, released: false,
413
+ });
414
+ return finish(agentId, mode, { passId, status: 'done', saved, superseded, more }, kind);
415
+ };
416
+ const runSave = async (request) => {
417
+ const { from, mode } = validSave(request);
418
+ const { agentId, sessionId } = request;
419
+ const passId = newPassId();
420
+ if (closing) {
421
+ return skipped(agentId, mode, passId, 'stopped');
422
+ }
423
+ const begun = started.get(`${agentId}\0${mode}`);
424
+ if (begun === undefined || !isReady) {
425
+ return skipped(agentId, mode, passId, 'not-started');
426
+ }
427
+ // A start still being written counts once it has landed, and one that
428
+ // failed never started anything.
429
+ if (!await begun.then(() => true, () => false)) {
430
+ return skipped(agentId, mode, passId, 'not-started');
431
+ }
432
+ await settling.get(`${agentId}\0${mode}`);
433
+ if (options.memory === undefined) {
434
+ return skipped(agentId, mode, passId, 'store-unsupported');
435
+ }
436
+ // For the claim. Resolved before the chain is checked, so nothing comes
437
+ // between the check and the step; a config that cannot resolve now
438
+ // fails the model run the same way, after the claim.
439
+ const model = request.model ?? await options.defaultModel(agentId).catch(() => undefined) ?? '';
440
+ if (isMemoryPassSessionId(sessionId)) {
441
+ return skipped(agentId, mode, passId, 'sub-agent');
442
+ }
443
+ if (busy.has(agentId)) {
444
+ return skipped(agentId, mode, passId, 'pass-running');
445
+ }
446
+ // The idle check and the queueing in one tick: a message that arrives
447
+ // after this queues behind the step, the way it would behind a turn.
448
+ if (!options.chain.idle(sessionId)) {
449
+ return skipped(agentId, mode, passId, 'turn-running');
450
+ }
451
+ busy.add(agentId);
452
+ const memory = options.memory;
453
+ const ledger = ledgerFor(agentId);
454
+ try {
455
+ const step = options.chain.queue(sessionId, async () => {
456
+ const session = await options.sessions.get(sessionId);
457
+ if (session === undefined) {
458
+ return { refused: 'no-session' };
459
+ }
460
+ if (session.agent.id !== agentId) {
461
+ malformed(`memoryPasses.run: session ${sessionId} is not ${agentId}'s.`);
462
+ }
463
+ if (options.isSubAgentSession(session)) {
464
+ return { refused: 'sub-agent' };
465
+ }
466
+ if (session.metadata?.[SCHEDULED_METADATA_KEY] === true && request.scheduled !== true) {
467
+ return { refused: 'unattended' };
468
+ }
469
+ if (typeof session.metadata?.channel === 'string') {
470
+ const room = conversationContextFrom(session.metadata);
471
+ if (room === undefined || !from.includes(room.kind)) {
472
+ return { refused: 'conversation-kind' };
473
+ }
474
+ }
475
+ const records = await ledger.read();
476
+ const counts = memoryPassMessageCounts(memoryPassActivations(records, mode, options.run));
477
+ const next = takeSaveSlice(session.messages, memoryPassWatermark(records, mode, sessionId), counts);
478
+ if (next === undefined) {
479
+ return { nothingNew: true };
480
+ }
481
+ const claim = {
482
+ type: 'claim',
483
+ at: now(),
484
+ run: options.run,
485
+ passId,
486
+ kind: 'save',
487
+ mode,
488
+ model,
489
+ sessionId,
490
+ slice: next.slice,
491
+ };
492
+ await ledger.append(claim);
493
+ return { claim, prompt: saveSlicePrompt(next.entries), trust: sessionWriteTrust(session), more: next.more };
494
+ });
495
+ let taken;
496
+ try {
497
+ taken = await step;
498
+ }
499
+ catch (error) {
500
+ if (error instanceof MalformedPassRequest) {
501
+ throw error;
502
+ }
503
+ // The session or the ledger could not be read or written: nothing
504
+ // was claimed, or the claim is open and the next start settles it.
505
+ options.log(`memory pass ${passId} (${agentId}) could not take its slice: ${error instanceof Error ? error.message : String(error)}`);
506
+ return finish(agentId, mode, { passId, status: 'failed', reason: 'state', saved: 0, superseded: 0, more: false });
507
+ }
508
+ if ('refused' in taken) {
509
+ return skipped(agentId, mode, passId, taken.refused);
510
+ }
511
+ if ('nothingNew' in taken) {
512
+ return finish(agentId, mode, { passId, status: 'nothing-new', saved: 0, superseded: 0, more: false });
513
+ }
514
+ // The chain is released: a turn may run while the model thinks.
515
+ return await runClaimed({
516
+ agentId,
517
+ mode,
518
+ passId,
519
+ model,
520
+ claim: taken.claim,
521
+ prompt: taken.prompt,
522
+ more: taken.more,
523
+ tools: { passId, kind: 'save', sourceSessionId: sessionId, trust: taken.trust },
524
+ memory,
525
+ ...(request.signal !== undefined ? { signal: request.signal } : {}),
526
+ });
527
+ }
528
+ finally {
529
+ busy.delete(agentId);
530
+ }
531
+ };
532
+ const validTidy = (request) => {
533
+ if (!knownAgent(request.agentId)) {
534
+ malformed(`memoryPasses.run: there is no agent ${String(request.agentId)}.`);
535
+ }
536
+ if (request.model !== undefined && (typeof request.model !== 'string' || request.model.trim().length === 0)) {
537
+ malformed('memoryPasses.run: model, when given, must name one.');
538
+ }
539
+ const mode = request.mode ?? 'write';
540
+ if (!MODES.includes(mode)) {
541
+ malformed(`memoryPasses.run: mode must be "write" or "shadow" (got ${String(mode)}).`);
542
+ }
543
+ return { mode };
544
+ };
545
+ /**
546
+ * One tidy pass over one chunk of one group (spec 962). No session: it
547
+ * starts at its group's label, recalls only that label, and may supersede
548
+ * what it was handed or recalled, so a merge never moves a fact to
549
+ * another label.
550
+ */
551
+ const runTidy = async (request) => {
552
+ const { mode } = validTidy(request);
553
+ const { agentId } = request;
554
+ const passId = newPassId();
555
+ if (closing) {
556
+ return skipped(agentId, mode, passId, 'stopped', 'tidy');
557
+ }
558
+ const begun = started.get(`${agentId}\0${mode}`);
559
+ if (begun === undefined || !isReady || !await begun.then(() => true, () => false)) {
560
+ return skipped(agentId, mode, passId, 'not-started', 'tidy');
561
+ }
562
+ await settling.get(`${agentId}\0${mode}`);
563
+ // A merge is one fact superseding several, which only a store that
564
+ // takes a list can record as one undoable step.
565
+ if (options.memory === undefined || options.supersedesMany?.() !== true) {
566
+ return skipped(agentId, mode, passId, 'store-unsupported', 'tidy');
567
+ }
568
+ const model = request.model ?? await options.defaultModel(agentId).catch(() => undefined) ?? '';
569
+ if (busy.has(agentId)) {
570
+ return skipped(agentId, mode, passId, 'pass-running', 'tidy');
571
+ }
572
+ busy.add(agentId);
573
+ const memory = options.memory;
574
+ const ledger = ledgerFor(agentId);
575
+ try {
576
+ let chunk;
577
+ let claim;
578
+ try {
579
+ const records = await ledger.read();
580
+ const live = (await memory.list(agentId)).entries;
581
+ const pinned = new Set((await memory.pinned?.(agentId, { include: 'allocated' }) ?? []).map((entry) => entry.id));
582
+ chunk = takeTidyChunk(live, pinned, memoryPassTidyClaimed(records, mode));
583
+ if (chunk === undefined) {
584
+ return finish(agentId, mode, { passId, status: 'nothing-new', saved: 0, superseded: 0, more: false }, 'tidy');
585
+ }
586
+ claim = {
587
+ type: 'claim', at: now(), run: options.run, passId, kind: 'tidy', mode, model,
588
+ group: { key: chunk.key, hash: chunk.hash },
589
+ };
590
+ await ledger.append(claim);
591
+ }
592
+ catch (error) {
593
+ options.log(`memory pass ${passId} (${agentId}) could not take its chunk: ${error instanceof Error ? error.message : String(error)}`);
594
+ return finish(agentId, mode, { passId, status: 'failed', reason: 'state', saved: 0, superseded: 0, more: false }, 'tidy');
595
+ }
596
+ const trust = chunk.trust;
597
+ return await runClaimed({
598
+ agentId,
599
+ mode,
600
+ passId,
601
+ model,
602
+ claim,
603
+ prompt: tidyChunkPrompt(chunk.lines),
604
+ more: chunk.more,
605
+ tools: {
606
+ passId,
607
+ kind: 'tidy',
608
+ trust,
609
+ handed: chunk.members.map((entry) => ({ id: entry.id, trust })),
610
+ recallOnly: trust,
611
+ },
612
+ memory,
613
+ ...(request.signal !== undefined ? { signal: request.signal } : {}),
614
+ });
615
+ }
616
+ finally {
617
+ busy.delete(agentId);
618
+ }
619
+ };
620
+ const run = async (request) => {
621
+ if (typeof request !== 'object' || request === null) {
622
+ throw new MalformedPassRequest('memoryPasses.run needs a request.');
623
+ }
624
+ if (request.kind !== 'save' && request.kind !== 'tidy') {
625
+ throw new MalformedPassRequest(`memoryPasses.run: kind must be "save" or "tidy" (got ${String(request.kind)}).`);
626
+ }
627
+ const pass = request.kind === 'tidy' ? runTidy(request) : runSave(request);
628
+ running.add(pass);
629
+ try {
630
+ return await pass;
631
+ }
632
+ finally {
633
+ running.delete(pass);
634
+ }
635
+ };
636
+ const close = async () => {
637
+ if (closing) {
638
+ return;
639
+ }
640
+ closing = true;
641
+ // Never ready: the settles stay for the next start. Ready: the stop
642
+ // record comes after them.
643
+ openReadiness(false);
644
+ stopAll.abort(new Error('The daemon is stopping.'));
645
+ await Promise.allSettled([...running, ...settling.values()]);
646
+ const at = now();
647
+ for (const [key, begun] of started) {
648
+ const outcome = await begun.then(() => true, () => false);
649
+ if (!outcome) {
650
+ continue;
651
+ }
652
+ const [agentId, mode] = key.split('\0');
653
+ try {
654
+ await ledgerFor(agentId).append({ type: 'stop', at, run: options.run, mode });
655
+ }
656
+ catch (error) {
657
+ options.log(`could not write the memory pass stop record for ${agentId} (${mode}): ${error instanceof Error ? error.message : String(error)}`);
658
+ }
659
+ }
660
+ };
661
+ const passes = async (agentId) => memoryPassSummaries(await ledgerFor(agentId).read());
662
+ const pass = async (agentId, passId) => {
663
+ const found = (await passes(agentId)).find((summary) => summary.passId === passId);
664
+ return found === undefined ? undefined : { pass: found, entries: await writtenBy(agentId, found.mode, passId) };
665
+ };
666
+ const ready = async () => {
667
+ if (!closing) {
668
+ isReady = true;
669
+ openReadiness(true);
670
+ }
671
+ await Promise.allSettled([...settling.values()]);
672
+ };
673
+ return { handle: { start, run }, passes, pass, ready, close };
674
+ };
675
+ //# sourceMappingURL=memory-passes.js.map