@myagentroam/node 0.10.20 → 0.10.22

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,368 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ const EMPTY_REFERENCES = Object.freeze({ epoch: 'empty', messages: [] });
3
+ const MAX_SCOPES = 32;
4
+ const MAX_SESSIONS = 16;
5
+ const MAX_WORKSPACE_BYTES = 2 * 1024 * 1024;
6
+ const MAX_BYTES = 32 * 1024 * 1024;
7
+ const MAX_REFERENCE_SESSIONS = 4;
8
+ export const PROMPT_COMPLETION_REFERENCE_POOL_SESSIONS = MAX_REFERENCE_SESSIONS + 1;
9
+ const MAX_REFERENCE_MESSAGES = 20;
10
+ const MAX_REFERENCE_BYTES = 8_192;
11
+ const MAX_REFERENCE_TEXT_BYTES = 1_024;
12
+ /** Node-only material cache. Drafts, generated suggestions and credentials never enter it. */
13
+ export class PromptCompletionCache {
14
+ scopes = new Map();
15
+ now;
16
+ refreshMs;
17
+ hardTtlMs;
18
+ disposed = false;
19
+ reads = 0;
20
+ constructor(options = {}) {
21
+ this.now = options.now ?? Date.now;
22
+ this.refreshMs = options.refreshMs ?? 5 * 60_000;
23
+ this.hardTtlMs = options.hardTtlMs ?? 15 * 60_000;
24
+ }
25
+ async references(scope, sessionId, lease, load) {
26
+ if (this.disposed || !this.authorized(lease))
27
+ return EMPTY_REFERENCES;
28
+ const entry = this.entry(scope);
29
+ entry.lease = lease;
30
+ entry.load = load;
31
+ const latest = entry.snapshots.at(-1);
32
+ if (!latest || this.now() - latest.verifiedAt >= this.refreshMs)
33
+ await this.build(entry);
34
+ if (!this.retained(entry) || !this.authorized(lease))
35
+ return EMPTY_REFERENCES;
36
+ const pin = sessionId === null ? undefined : entry.pins.get(sessionId);
37
+ const pinned = pin && this.now() - pin.pinnedAt < this.hardTtlMs
38
+ ? entry.snapshots.find((value) => value.epoch === pin.epoch)
39
+ : undefined;
40
+ const snapshot = pinned && this.now() - pinned.verifiedAt < this.hardTtlMs ? pinned : entry.snapshots.at(-1);
41
+ if (!snapshot || this.now() - snapshot.verifiedAt >= this.hardTtlMs)
42
+ return EMPTY_REFERENCES;
43
+ if (sessionId !== null && snapshot !== pinned) {
44
+ entry.pins.delete(sessionId);
45
+ entry.pins.set(sessionId, { epoch: snapshot.epoch, pinnedAt: this.now() });
46
+ trimMap(entry.pins, MAX_SESSIONS);
47
+ }
48
+ return {
49
+ epoch: snapshot.epoch,
50
+ messages: selectReferences(snapshot.messages, sessionId)
51
+ };
52
+ }
53
+ /** The caller supplies an authoritative bounded window, not an unverified delta. */
54
+ session(scope, sessionId, generation, messages) {
55
+ if (this.disposed)
56
+ return [];
57
+ const entry = this.entry(scope);
58
+ const previous = entry.sessions.get(sessionId);
59
+ const start = previous?.generation === generation && previous.messages[0]
60
+ ? messages.findIndex((value) => value.id === previous.messages[0].id)
61
+ : -1;
62
+ const bounded = boundMessages(start >= 0 ? messages.slice(start) : messages);
63
+ const unchangedPrefix = previous?.generation === generation &&
64
+ previous.messages.length <= bounded.length &&
65
+ previous.messages.every((old, index) => {
66
+ const next = bounded[index];
67
+ return (next?.id === old.id &&
68
+ next.revision === old.revision &&
69
+ next.role === old.role &&
70
+ next.text === old.text);
71
+ });
72
+ const result = unchangedPrefix
73
+ ? [...previous.messages, ...bounded.slice(previous.messages.length)]
74
+ : bounded;
75
+ if (!unchangedPrefix)
76
+ entry.pins.delete(sessionId);
77
+ entry.sessions.delete(sessionId);
78
+ entry.lastUsed = this.now();
79
+ entry.sessions.set(sessionId, { generation, messages: result, refreshedAt: this.now() });
80
+ trimMap(entry.sessions, MAX_SESSIONS);
81
+ this.enforceLimits();
82
+ return result;
83
+ }
84
+ /** Maintenance only reclaims idle material; history reads require a manual request. */
85
+ sweep() {
86
+ for (const entry of this.scopes.values()) {
87
+ if (this.now() - entry.lastUsed >= this.hardTtlMs) {
88
+ this.evict(entry);
89
+ continue;
90
+ }
91
+ for (const [id, window] of entry.sessions)
92
+ if (this.now() - window.refreshedAt >= this.hardTtlMs) {
93
+ entry.sessions.delete(id);
94
+ entry.pins.delete(id);
95
+ }
96
+ if (!entry.lease || !this.authorized(entry.lease)) {
97
+ entry.build?.controller.abort();
98
+ delete entry.load;
99
+ delete entry.lease;
100
+ continue;
101
+ }
102
+ }
103
+ }
104
+ invalidateWorkspace(workspaceId) {
105
+ for (const entry of this.scopes.values())
106
+ if (entry.scope.workspaceId === workspaceId)
107
+ this.evict(entry);
108
+ }
109
+ invalidateSession(workspaceId, sessionId) {
110
+ for (const entry of this.scopes.values()) {
111
+ if (entry.scope.workspaceId !== workspaceId)
112
+ continue;
113
+ // A source removal also invalidates every pinned reference containing that source.
114
+ if (entry.build ||
115
+ entry.snapshots.some((snapshot) => snapshot.messages.some((message) => message.sessionId === sessionId)))
116
+ this.evict(entry);
117
+ else {
118
+ entry.sessions.delete(sessionId);
119
+ entry.pins.delete(sessionId);
120
+ }
121
+ }
122
+ }
123
+ invalidateView(authorizationView) {
124
+ for (const entry of this.scopes.values())
125
+ if (entry.scope.authorizationView === authorizationView)
126
+ this.evict(entry);
127
+ }
128
+ dispose() {
129
+ this.disposed = true;
130
+ this.clear();
131
+ }
132
+ clear() {
133
+ for (const entry of this.scopes.values())
134
+ this.evict(entry);
135
+ }
136
+ statistics() {
137
+ return {
138
+ scopes: this.scopes.size,
139
+ bytes: [...this.scopes.values()].reduce((total, entry) => total + bytes(entry), 0)
140
+ };
141
+ }
142
+ authorized(lease) {
143
+ return lease.expiresAt > this.now() && lease.valid();
144
+ }
145
+ entry(scope) {
146
+ const key = scopeKey(scope);
147
+ let entry = this.scopes.get(key);
148
+ if (entry && this.now() - entry.lastUsed >= this.hardTtlMs) {
149
+ this.evict(entry);
150
+ entry = undefined;
151
+ }
152
+ if (!entry) {
153
+ entry = {
154
+ scope: { ...scope },
155
+ snapshots: [],
156
+ pins: new Map(),
157
+ sessions: new Map(),
158
+ lastUsed: this.now()
159
+ };
160
+ this.scopes.set(key, entry);
161
+ }
162
+ entry.lastUsed = this.now();
163
+ // Map order is LRU across views as well as Workspaces.
164
+ this.scopes.delete(key);
165
+ this.scopes.set(key, entry);
166
+ this.enforceLimits();
167
+ return entry;
168
+ }
169
+ retained(entry) {
170
+ return !this.disposed && this.scopes.get(scopeKey(entry.scope)) === entry;
171
+ }
172
+ build(entry) {
173
+ if (entry.build)
174
+ return entry.build.promise;
175
+ const { lease, load } = entry;
176
+ if (this.reads >= 2 || !lease || !load || !this.authorized(lease) || !this.retained(entry))
177
+ return Promise.resolve();
178
+ const controller = new AbortController();
179
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(1_000)]);
180
+ const promise = (async () => {
181
+ try {
182
+ this.reads++;
183
+ const loading = Promise.resolve().then(() => {
184
+ signal.throwIfAborted();
185
+ if (!this.retained(entry) || !this.authorized(lease))
186
+ throw new Error('PROMPT_COMPLETION_UNAVAILABLE');
187
+ return load(signal);
188
+ });
189
+ void loading
190
+ .finally(() => {
191
+ this.reads--;
192
+ })
193
+ .catch(() => undefined);
194
+ const values = await abortable(loading, signal);
195
+ if (signal.aborted || !this.retained(entry) || !this.authorized(lease))
196
+ return;
197
+ const messages = boundReferences(values);
198
+ const hash = createHash('sha256').update(JSON.stringify(messages)).digest('hex');
199
+ const latest = entry.snapshots.at(-1);
200
+ if (latest?.hash === hash)
201
+ latest.verifiedAt = this.now();
202
+ else {
203
+ entry.snapshots.push({ epoch: randomUUID(), hash, messages, verifiedAt: this.now() });
204
+ if (entry.snapshots.length > 2)
205
+ entry.snapshots.shift();
206
+ for (const [id, pin] of entry.pins)
207
+ if (!entry.snapshots.some((value) => value.epoch === pin.epoch))
208
+ entry.pins.delete(id);
209
+ }
210
+ this.enforceLimits();
211
+ }
212
+ catch {
213
+ // Optional references may be omitted; failure never renews freshness.
214
+ }
215
+ finally {
216
+ if (entry.build?.controller === controller)
217
+ delete entry.build;
218
+ }
219
+ })();
220
+ entry.build = { controller, promise };
221
+ return promise;
222
+ }
223
+ evict(entry) {
224
+ entry.build?.controller.abort();
225
+ this.scopes.delete(scopeKey(entry.scope));
226
+ delete entry.load;
227
+ delete entry.lease;
228
+ entry.snapshots.length = 0;
229
+ entry.sessions.clear();
230
+ entry.pins.clear();
231
+ }
232
+ enforceLimits() {
233
+ let total = 0;
234
+ const workspaceBytes = new Map();
235
+ for (const entry of [...this.scopes.values()].reverse()) {
236
+ const size = bytes(entry);
237
+ const workspaceSize = (workspaceBytes.get(entry.scope.workspaceId) ?? 0) + size;
238
+ if (total + size > MAX_BYTES || workspaceSize > MAX_WORKSPACE_BYTES)
239
+ this.evict(entry);
240
+ else {
241
+ total += size;
242
+ workspaceBytes.set(entry.scope.workspaceId, workspaceSize);
243
+ }
244
+ }
245
+ while (this.scopes.size > MAX_SCOPES)
246
+ this.evict(this.scopes.values().next().value);
247
+ }
248
+ }
249
+ function scopeKey(scope) {
250
+ return JSON.stringify([scope.workspaceId, scope.generation, scope.authorizationView]);
251
+ }
252
+ function bytes(entry) {
253
+ return Buffer.byteLength(JSON.stringify([entry.snapshots, [...entry.sessions], [...entry.pins]]));
254
+ }
255
+ function trimMap(map, size) {
256
+ while (map.size > size)
257
+ map.delete(map.keys().next().value);
258
+ }
259
+ function boundReferences(values) {
260
+ const seen = new Set();
261
+ const result = [];
262
+ for (const group of groupReferences(values.slice(0, 100), PROMPT_COMPLETION_REFERENCE_POOL_SESSIONS, null)) {
263
+ const selected = [];
264
+ let size = 0;
265
+ for (const value of group.slice(-MAX_REFERENCE_MESSAGES).reverse()) {
266
+ const key = JSON.stringify([value.sessionId, value.id]);
267
+ if (seen.has(key) || !value.text.trim())
268
+ continue;
269
+ seen.add(key);
270
+ const text = clipReferenceText(value.text);
271
+ if (size + Buffer.byteLength(text) > MAX_REFERENCE_BYTES)
272
+ continue;
273
+ size += Buffer.byteLength(text);
274
+ selected.push(Object.freeze({ sessionId: value.sessionId, id: value.id, revision: value.revision, text }));
275
+ }
276
+ result.push(...selected.reverse());
277
+ }
278
+ return Object.freeze(result);
279
+ }
280
+ function groupReferences(values, maximumSessions, excludedSessionId) {
281
+ const groups = new Map();
282
+ for (const value of values) {
283
+ if (value.sessionId === excludedSessionId)
284
+ continue;
285
+ let group = groups.get(value.sessionId);
286
+ if (!group) {
287
+ if (groups.size >= maximumSessions)
288
+ continue;
289
+ group = [];
290
+ groups.set(value.sessionId, group);
291
+ }
292
+ group.push(value);
293
+ }
294
+ return [...groups.values()];
295
+ }
296
+ function selectReferences(values, sessionId) {
297
+ const groups = groupReferences(values, MAX_REFERENCE_SESSIONS, sessionId);
298
+ const selected = [];
299
+ let size = 0;
300
+ for (let offset = 1; offset <= MAX_REFERENCE_MESSAGES && selected.length < MAX_REFERENCE_MESSAGES; offset++) {
301
+ for (const group of groups) {
302
+ const value = group.at(-offset);
303
+ if (!value || size + Buffer.byteLength(value.text) > MAX_REFERENCE_BYTES)
304
+ continue;
305
+ selected.push(value.text);
306
+ size += Buffer.byteLength(value.text);
307
+ if (selected.length >= MAX_REFERENCE_MESSAGES)
308
+ break;
309
+ }
310
+ }
311
+ // Generation reads this sequence from the end, so the newest round stays last.
312
+ return selected.reverse();
313
+ }
314
+ function clipReferenceText(text) {
315
+ if (Buffer.byteLength(text) <= MAX_REFERENCE_TEXT_BYTES)
316
+ return text;
317
+ const suffix = '\n[truncated]';
318
+ let size = Buffer.byteLength(suffix);
319
+ let prefix = '';
320
+ for (const character of text) {
321
+ const length = Buffer.byteLength(character);
322
+ if (size + length > MAX_REFERENCE_TEXT_BYTES)
323
+ break;
324
+ prefix += character;
325
+ size += length;
326
+ }
327
+ return prefix + suffix;
328
+ }
329
+ function boundMessages(values) {
330
+ const selected = values.slice(-64).map((value) => Object.freeze({
331
+ id: value.id,
332
+ revision: value.revision,
333
+ role: value.role,
334
+ text: clip(value.text, 4_096)
335
+ }));
336
+ let size = selected.reduce((sum, value) => sum + Buffer.byteLength(value.text), 0);
337
+ if (values.length > 64 || size > 32_768)
338
+ while (selected.length > 1 && (selected.length > 32 || size > 16_384))
339
+ size -= Buffer.byteLength(selected.shift().text);
340
+ return Object.freeze(selected);
341
+ }
342
+ function clip(text, max) {
343
+ if (text.length <= max)
344
+ return text;
345
+ const end = /[\uD800-\uDBFF]/u.test(text[max - 1]) ? max - 1 : max;
346
+ return `${text.slice(0, end)}\n[truncated]`;
347
+ }
348
+ function abortable(pending, signal) {
349
+ return new Promise((resolve, reject) => {
350
+ const aborted = () => {
351
+ cleanup();
352
+ reject(signal.reason);
353
+ };
354
+ const cleanup = () => signal.removeEventListener('abort', aborted);
355
+ if (signal.aborted) {
356
+ reject(signal.reason);
357
+ return;
358
+ }
359
+ signal.addEventListener('abort', aborted, { once: true });
360
+ pending.then((value) => {
361
+ cleanup();
362
+ resolve(value);
363
+ }, (error) => {
364
+ cleanup();
365
+ reject(error);
366
+ });
367
+ });
368
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { composerVisibleText } from '@myagentroam/protocol';
3
+ /** Consumes normalized conversation facts; never parses vendor logs or raw tool payloads. */
4
+ export function projectCompletionContext(turns, historyGeneration) {
5
+ const history = [];
6
+ const references = [];
7
+ const seen = new Set();
8
+ const semantic = [];
9
+ const tools = [];
10
+ const waiting = [];
11
+ const partial = [];
12
+ let progressRevision = 0;
13
+ for (const turn of turns.slice(-12)) {
14
+ semantic.push([turn.id, turn.runId, turn.status]);
15
+ for (const item of turn.items.slice(-128)) {
16
+ progressRevision += item.revision;
17
+ const payload = record(item.payload);
18
+ if (item.kind === 'user_message') {
19
+ // Missing delivery is permitted only because the canonical history reader owns it.
20
+ if (payload.source !== undefined ||
21
+ (payload.delivery !== undefined &&
22
+ payload.delivery !== 'ACCEPTED' &&
23
+ payload.delivery !== 'STEERED'))
24
+ continue;
25
+ const text = typeof payload.text === 'string' ? composerVisibleText(payload.text) : '';
26
+ const id = typeof payload.clientMessageId === 'string' ? payload.clientMessageId : item.id;
27
+ if (!text.trim() || seen.has(id))
28
+ continue;
29
+ seen.add(id);
30
+ const bounded = clip(text, 4_096);
31
+ history.push({ id, revision: item.revision, role: 'user', text: bounded });
32
+ references.push({ sessionId: item.sessionId, id, revision: item.revision, text: bounded });
33
+ semantic.push([id, item.revision, bounded]);
34
+ }
35
+ else if (item.kind === 'assistant_message' && typeof payload.text === 'string') {
36
+ if (item.status === 'COMPLETED') {
37
+ if (seen.has(item.id))
38
+ continue;
39
+ seen.add(item.id);
40
+ const text = clip(payload.text, 4_096);
41
+ history.push({ id: item.id, revision: item.revision, role: 'assistant', text });
42
+ semantic.push([item.id, item.revision, text]);
43
+ }
44
+ else if (item.status === 'IN_PROGRESS' && turn.id === turns.at(-1)?.id)
45
+ partial.push(clip(payload.text, 1_024));
46
+ }
47
+ else if (['approval', 'user_input_request'].includes(item.kind)) {
48
+ semantic.push([item.id, item.revision, item.status]);
49
+ if (turn.id === turns.at(-1)?.id &&
50
+ (item.status === 'PENDING' || item.status === 'IN_PROGRESS'))
51
+ waiting.push({ kind: item.kind, status: item.status });
52
+ }
53
+ else if (['tool_call', 'command_execution', 'file_change'].includes(item.kind)) {
54
+ // Tool names, arguments, titles, paths, output and error bodies are not copied.
55
+ if (turn.id === turns.at(-1)?.id)
56
+ tools.push({ category: item.kind, status: item.status });
57
+ }
58
+ }
59
+ }
60
+ const contextVersion = createHash('sha256')
61
+ .update(JSON.stringify([historyGeneration, semantic]))
62
+ .digest('hex');
63
+ return {
64
+ identity: { historyGeneration, contextVersion, progressRevision },
65
+ history: history.slice(-64),
66
+ references: references.slice(-20),
67
+ executionSnapshot: JSON.stringify({
68
+ status: turns.at(-1)?.status ?? 'IDLE',
69
+ tools: tools.slice(-6),
70
+ waiting: waiting.slice(-6),
71
+ partialAssistant: partial.slice(-3)
72
+ })
73
+ };
74
+ }
75
+ function record(value) {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
77
+ ? value
78
+ : {};
79
+ }
80
+ function clip(text, max) {
81
+ if (text.length <= max)
82
+ return text;
83
+ const half = Math.floor((max - 32) / 2);
84
+ const head = text.slice(0, half).replace(/[\uD800-\uDBFF]$/u, '');
85
+ const tail = text.slice(-half).replace(/^[\uDC00-\uDFFF]/u, '');
86
+ return `${head}\n[excerpt omitted]\n${tail}`;
87
+ }