@myagentroam/node 0.10.19 → 0.10.21

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.
@@ -56,6 +56,7 @@ export function unavailableCapabilities() {
56
56
  openCode: { available: false },
57
57
  marAgent: { available: true, version: nodeAppVersion },
58
58
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
59
+ promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
59
60
  chatgptLogin: { apiVersion: 1, loopback: true },
60
61
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
61
62
  terminalDirect: terminalDirectCapability()
@@ -91,6 +92,7 @@ export function detectCapabilities(commands = {}) {
91
92
  },
92
93
  marAgent: { available: true, version: nodeAppVersion },
93
94
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
95
+ promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
94
96
  chatgptLogin: { apiVersion: 1, loopback: true },
95
97
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
96
98
  terminalDirect: terminalDirectCapability()
@@ -130,6 +132,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
130
132
  },
131
133
  marAgent: { available: true, version: nodeAppVersion },
132
134
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
135
+ promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
133
136
  chatgptLogin: { apiVersion: 1, loopback: true },
134
137
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
135
138
  terminalDirect: terminalDirectCapability()
package/dist/connector.js CHANGED
@@ -102,6 +102,8 @@ import { ComputerUseEnablementService } from './service/computer-use-enablement-
102
102
  import { safeErrorCode } from './util/safe-error.js';
103
103
  import { RuntimeCredentialControlService } from './service/runtime-credential-control-service.js';
104
104
  import { MarAgentRuntimeModelFactory } from './runner/mar-agent/runtime-model-factory.js';
105
+ import { PromptCompletionNodeService } from './service/prompt-completion-node-service.js';
106
+ import { PromptCompletionWorkspaceSource } from './service/prompt-completion-workspace-source.js';
105
107
  import { MarAgentModelTransportTestService } from './service/mar-agent-model-transport-test-service.js';
106
108
  import { ChatgptLoginService } from './service/chatgpt-login-service.js';
107
109
  const HEARTBEAT_MS = 15_000;
@@ -219,6 +221,7 @@ export class NodeConnector {
219
221
  sessionIdentityService;
220
222
  nativeSessionProjectionService;
221
223
  conversationHistoryService;
224
+ promptCompletion;
222
225
  conversationSegmentService;
223
226
  runnerInteractionService;
224
227
  runnerChannelMessages;
@@ -1191,13 +1194,17 @@ export class NodeConnector {
1191
1194
  nativeIds: (session) => this.nativeSessionProjectionService.nativeIds(session),
1192
1195
  forget: (session, nativeIds) => {
1193
1196
  this.nativeSessionProjectionService.forget(session, nativeIds);
1197
+ this.promptCompletion.invalidateSession(session.workspaceId, session.id);
1194
1198
  void this.runAttachmentService.releaseSession(session.id);
1195
1199
  },
1196
1200
  refreshActivity: (session) => this.conversationHistoryService.refreshExternalActivity(session),
1197
1201
  externalActivity: (sessionId) => this.sessionState.externalActivity.get(sessionId),
1198
1202
  isWatching: (sessionId) => this.nativeSessionWatchService.isWatching(sessionId),
1199
1203
  history: (session, input) => this.conversationHistoryService.read(session, input),
1200
- invalidateHistory: (sessionId) => this.nativeSessionWatchService.invalidate(sessionId),
1204
+ invalidateHistory: (sessionId, workspaceId) => {
1205
+ this.nativeSessionWatchService.invalidate(sessionId);
1206
+ this.promptCompletion.invalidateSession(workspaceId, sessionId);
1207
+ },
1201
1208
  invalidateWorkspaceSessions: (workspaceId) => {
1202
1209
  this.workspaceSessionService.invalidateCache(workspaceId);
1203
1210
  void this.workspaceWorkbenchService?.invalidateTopic(workspaceId, 'sessions');
@@ -1297,6 +1304,7 @@ export class NodeConnector {
1297
1304
  onReconnectScheduled: () => this.metrics.reconnectScheduled(),
1298
1305
  onMessage: (raw) => this.handleMessage(raw),
1299
1306
  onClose: (code, reason) => {
1307
+ this.promptCompletion?.disconnect();
1300
1308
  this.usageOutbox?.disconnected();
1301
1309
  this.directTransferService?.close();
1302
1310
  this.runtimeCredentials.disconnected();
@@ -1477,6 +1485,7 @@ export class NodeConnector {
1477
1485
  this.workspaceCoordinator.invalidateChanges(workspaceId);
1478
1486
  this.workspaceSessionService.invalidate(workspaceId);
1479
1487
  this.workspaceCoordinator.clearWatches(workspaceId);
1488
+ this.promptCompletion?.invalidateWorkspace(workspaceId);
1480
1489
  });
1481
1490
  this.workspaceWorkbenchService = new WorkspaceWorkbenchService({
1482
1491
  requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
@@ -1496,6 +1505,53 @@ export class NodeConnector {
1496
1505
  nodeGeneration: () => this.nodeGeneration,
1497
1506
  emitWatchEvent: (payload) => this.send('watch.event', payload)
1498
1507
  });
1508
+ const completionWorkspace = new PromptCompletionWorkspaceSource({
1509
+ requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
1510
+ observesWorkspace: (workspaceId, actor, connectionId) => this.workspaceWatchService.observes(workspaceId, actor.sessionHash, connectionId),
1511
+ observesSession: (workspaceId, sessionId, actor, connectionId) => this.nativeSessionWatchService.observes(actor.sessionHash, connectionId, workspaceId, sessionId),
1512
+ resolveSession: (sessionId) => this.nativeSessionProjectionService.resolve(sessionId),
1513
+ cachedSessions: (workspaceId) => this.workspaceState.sessionCache.get(workspaceId)?.sessions ?? [],
1514
+ readHistory: (session, limit) => this.conversationHistoryService.read(session, { limit })
1515
+ });
1516
+ this.promptCompletion = new PromptCompletionNodeService({
1517
+ generation: () => this.nodeGeneration,
1518
+ readAttentionFile: (input, _actor, event) => {
1519
+ return this.workspaceWorkbenchService.readPredictionFile(input.target.workspaceId, event.path, event.worktreePath);
1520
+ },
1521
+ taskState: (input) => {
1522
+ const id = input.sessionId;
1523
+ if (!id || !this.runtime.getAgentSession(id))
1524
+ return { goal: 'UNKNOWN', queue: 'UNKNOWN' };
1525
+ const queue = this.runtime.sessionQueue(id);
1526
+ const run = this.runtime.activeSessionRun(id) ?? this.runtime.listRunsForSession(id)[0];
1527
+ const goal = this.goalRuntime.get(id);
1528
+ return {
1529
+ ...(run ? { runStatus: run.status } : {}),
1530
+ goal: goal
1531
+ ? {
1532
+ id: goal.id,
1533
+ objective: goal.objective,
1534
+ status: goal.status,
1535
+ revision: goal.revision
1536
+ }
1537
+ : null,
1538
+ queue: {
1539
+ version: queue.version,
1540
+ paused: queue.paused,
1541
+ items: queue.itemIds.flatMap((itemId) => {
1542
+ const item = this.runtime.getQueueItem(itemId);
1543
+ return item ? [{ queueItemId: item.id, content: item.content }] : [];
1544
+ })
1545
+ }
1546
+ };
1547
+ },
1548
+ modelFactory: this.marAgentRuntimeModels,
1549
+ usage: this.modelUsage,
1550
+ observed: (input, actor) => completionWorkspace.observed(input, actor),
1551
+ readPage: (input, actor, signal) => completionWorkspace.readPage(input, actor, signal),
1552
+ references: (workspaceId, _actor, signal) => completionWorkspace.references(workspaceId, signal)
1553
+ });
1554
+ this.operationRouter.registerAll(this.promptCompletion.operations());
1499
1555
  for (const operation of CONVERSATION_TEMPLATE_OPERATIONS)
1500
1556
  this.operationRouter.register(operation, (data) => {
1501
1557
  const handler = this.conversationTemplateOperations?.[operation];
@@ -1726,6 +1782,7 @@ export class NodeConnector {
1726
1782
  this.stopping = undefined;
1727
1783
  this.stopped = false;
1728
1784
  this.config ??= await loadNodeConfig(this.configPath);
1785
+ this.promptCompletion.initializeJournal(nodeDataDirectory(this.config, this.configPath));
1729
1786
  await this.upgradeCoordinator.loadResult(resolve(dirname(nodeDatabasePath(this.config, this.configPath)), 'upgrade-result.json'));
1730
1787
  await assertLocalSqlitePath(nodeDatabasePath(this.config, this.configPath));
1731
1788
  this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
@@ -2083,6 +2140,7 @@ export class NodeConnector {
2083
2140
  }
2084
2141
  this.claudeClient.execution.userInputs.clear();
2085
2142
  this.nativeSessionWatchService.clear();
2143
+ this.promptCompletion.dispose();
2086
2144
  this.sessionIdentityService.dispose();
2087
2145
  this.sessionLifecycleService.clear();
2088
2146
  this.continuousRunCoordinator.clear();
@@ -1,6 +1,7 @@
1
1
  import { modelUsageGrantSchema, modelUsageGrantClaimsSchema, modelUsageRecordSchema } from '@myagentroam/protocol';
2
2
  import { nodeLog } from '../operational.js';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { normalizeMarAgentUsage } from '../runner/mar-agent/conversation-normalizer.js';
4
5
  /** Execution-private attribution never enters Run projections or browser Watch events. */
5
6
  export class NodeModelUsageService {
6
7
  outbox;
@@ -27,7 +28,13 @@ export class NodeModelUsageService {
27
28
  const claims = modelUsageGrantClaimsSchema.parse(JSON.parse(Buffer.from(parsedGrant.token.split('.')[0], 'base64url').toString()));
28
29
  if (claims.nodeId !== this.nodeId() || claims.scopeId !== parsedGrant.scopeId)
29
30
  return;
30
- this.bindings.set(runId, { grant: parsedGrant, scopeId: claims.scopeId, sessionId, runner });
31
+ this.bindings.set(runId, {
32
+ userId: claims.userId,
33
+ grant: parsedGrant,
34
+ scopeId: claims.scopeId,
35
+ sessionId,
36
+ runner
37
+ });
31
38
  }
32
39
  catch {
33
40
  nodeLog('model.usage.invalid', { code: 'USAGE_GRANT_INVALID' });
@@ -36,6 +43,27 @@ export class NodeModelUsageService {
36
43
  release(runId) {
37
44
  this.bindings.delete(runId);
38
45
  }
46
+ completion(executionId, grant, sessionId, expectedUserId) {
47
+ this.bind(executionId, grant, sessionId, 'mar-agent');
48
+ if (expectedUserId !== undefined && this.bindings.get(executionId)?.userId !== expectedUserId)
49
+ this.release(executionId);
50
+ if (!this.bindings.has(executionId))
51
+ throw new Error('PROMPT_COMPLETION_USAGE_GRANT_REQUIRED');
52
+ return {
53
+ onModelUsage: (usage) => this.record(executionId, {
54
+ ...normalizeMarAgentUsage({
55
+ ...usage,
56
+ sessionId,
57
+ executionId,
58
+ purpose: 'other',
59
+ agentRole: 'root'
60
+ }),
61
+ purpose: 'prompt_completion'
62
+ }),
63
+ onModelUsageFailure: () => nodeLog('model.usage.invalid', { code: 'PROMPT_COMPLETION_USAGE_FAILED' }),
64
+ release: () => this.release(executionId)
65
+ };
66
+ }
39
67
  promoteSession(previousId, nextId) {
40
68
  for (const [runId, binding] of this.bindings)
41
69
  if (binding.sessionId === previousId)
@@ -199,6 +199,16 @@ export class NativeSessionWatchService {
199
199
  has(sessionId) {
200
200
  return this.watches.has(sessionId);
201
201
  }
202
+ observes(sessionHash, connectionId, workspaceId, sessionId) {
203
+ const ownerKey = `${sessionHash}:${connectionId}`;
204
+ const lease = this.leases.get(ownerKey);
205
+ return (lease !== undefined &&
206
+ lease.ready &&
207
+ lease.expiresAt > Date.now() &&
208
+ lease.sessionId === sessionId &&
209
+ lease.workspaceId === workspaceId &&
210
+ this.sessionWatchSlots.owns(ownerKey, lease.slotToken));
211
+ }
202
212
  isWatching(sessionId) {
203
213
  return this.has(sessionId);
204
214
  }
@@ -0,0 +1,66 @@
1
+ import { isAbsolute, win32 } from 'node:path';
2
+ /** Browser observations are hints, not authority to read paths or execute instructions. */
3
+ export async function projectPredictionAttention(events, read, signal, now = Date.now()) {
4
+ const result = [];
5
+ let remaining = 4096;
6
+ for (const event of events.slice(-20).reverse()) {
7
+ signal.throwIfAborted();
8
+ if (event.observedAt > now ||
9
+ now - event.observedAt >= 180_000 ||
10
+ isAbsolute(event.path) ||
11
+ win32.isAbsolute(event.path) ||
12
+ event.path.split(/[\\/]/u).includes('..') ||
13
+ event.path.includes('\0') ||
14
+ event.path.startsWith('mar-attachment:'))
15
+ continue;
16
+ const metadata = {
17
+ kind: event.kind,
18
+ path: event.path,
19
+ worktreePath: event.worktreePath,
20
+ source: event.source,
21
+ observedAt: event.observedAt
22
+ };
23
+ // Diff blocks without a verified content identity are metadata-only, never read in full.
24
+ if (event.kind === 'DIFF' || !event.revision || event.startLine === undefined) {
25
+ result.push(metadata);
26
+ if (result.length >= 8)
27
+ break;
28
+ continue;
29
+ }
30
+ try {
31
+ const value = (await read(event));
32
+ signal.throwIfAborted();
33
+ if (value.revision !== event.revision ||
34
+ value.encoding !== 'utf-8' ||
35
+ typeof value.content !== 'string')
36
+ continue;
37
+ const lines = value.content.split('\n');
38
+ // A bounded prefix can end mid-line. Never present that tail as a verified full line.
39
+ if (value.truncated)
40
+ lines.pop();
41
+ const endLine = Math.min(event.endLine, event.startLine + 79, lines.length);
42
+ if (event.startLine > endLine)
43
+ continue;
44
+ const text = lines
45
+ .slice(event.startLine - 1, endLine)
46
+ .join('\n')
47
+ .slice(0, Math.min(2048, remaining));
48
+ remaining -= text.length;
49
+ result.push({
50
+ ...metadata,
51
+ revision: value.revision,
52
+ startLine: event.startLine,
53
+ endLine,
54
+ text
55
+ });
56
+ }
57
+ catch (error) {
58
+ if (signal.aborted)
59
+ throw error;
60
+ // Missing, moved, changed, binary or inaccessible optional references are omitted.
61
+ }
62
+ if (result.length >= 8 || remaining <= 0)
63
+ break;
64
+ }
65
+ return JSON.stringify(result);
66
+ }
@@ -0,0 +1,312 @@
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
+ /** Node-only material cache. Drafts, generated suggestions and credentials never enter it. */
8
+ export class PromptCompletionCache {
9
+ scopes = new Map();
10
+ now;
11
+ refreshMs;
12
+ hardTtlMs;
13
+ disposed = false;
14
+ reads = 0;
15
+ constructor(options = {}) {
16
+ this.now = options.now ?? Date.now;
17
+ this.refreshMs = options.refreshMs ?? 5 * 60_000;
18
+ this.hardTtlMs = options.hardTtlMs ?? 15 * 60_000;
19
+ }
20
+ async references(scope, sessionId, lease, load) {
21
+ if (this.disposed || !this.authorized(lease))
22
+ return EMPTY_REFERENCES;
23
+ const entry = this.entry(scope);
24
+ entry.lease = lease;
25
+ entry.load = load;
26
+ const latest = entry.snapshots.at(-1);
27
+ if (!latest || this.now() - latest.verifiedAt >= this.refreshMs)
28
+ await this.build(entry);
29
+ if (!this.retained(entry) || !this.authorized(lease))
30
+ return EMPTY_REFERENCES;
31
+ const pin = sessionId === null ? undefined : entry.pins.get(sessionId);
32
+ const pinned = pin && this.now() - pin.pinnedAt < this.hardTtlMs
33
+ ? entry.snapshots.find((value) => value.epoch === pin.epoch)
34
+ : undefined;
35
+ const snapshot = pinned && this.now() - pinned.verifiedAt < this.hardTtlMs ? pinned : entry.snapshots.at(-1);
36
+ if (!snapshot || this.now() - snapshot.verifiedAt >= this.hardTtlMs)
37
+ return EMPTY_REFERENCES;
38
+ if (sessionId !== null && snapshot !== pinned) {
39
+ entry.pins.delete(sessionId);
40
+ entry.pins.set(sessionId, { epoch: snapshot.epoch, pinnedAt: this.now() });
41
+ trimMap(entry.pins, MAX_SESSIONS);
42
+ }
43
+ return {
44
+ epoch: snapshot.epoch,
45
+ messages: snapshot.messages
46
+ .filter((value) => value.sessionId !== sessionId)
47
+ .map((value) => value.text)
48
+ };
49
+ }
50
+ /** The caller supplies an authoritative bounded window, not an unverified delta. */
51
+ session(scope, sessionId, generation, messages) {
52
+ if (this.disposed)
53
+ return [];
54
+ const entry = this.entry(scope);
55
+ const previous = entry.sessions.get(sessionId);
56
+ const start = previous?.generation === generation && previous.messages[0]
57
+ ? messages.findIndex((value) => value.id === previous.messages[0].id)
58
+ : -1;
59
+ const bounded = boundMessages(start >= 0 ? messages.slice(start) : messages);
60
+ const unchangedPrefix = previous?.generation === generation &&
61
+ previous.messages.length <= bounded.length &&
62
+ previous.messages.every((old, index) => {
63
+ const next = bounded[index];
64
+ return (next?.id === old.id &&
65
+ next.revision === old.revision &&
66
+ next.role === old.role &&
67
+ next.text === old.text);
68
+ });
69
+ const result = unchangedPrefix
70
+ ? [...previous.messages, ...bounded.slice(previous.messages.length)]
71
+ : bounded;
72
+ if (!unchangedPrefix)
73
+ entry.pins.delete(sessionId);
74
+ entry.sessions.delete(sessionId);
75
+ entry.lastUsed = this.now();
76
+ entry.sessions.set(sessionId, { generation, messages: result, refreshedAt: this.now() });
77
+ trimMap(entry.sessions, MAX_SESSIONS);
78
+ this.enforceLimits();
79
+ return result;
80
+ }
81
+ /** Maintenance only reclaims idle material; history reads require a manual request. */
82
+ sweep() {
83
+ for (const entry of this.scopes.values()) {
84
+ if (this.now() - entry.lastUsed >= this.hardTtlMs) {
85
+ this.evict(entry);
86
+ continue;
87
+ }
88
+ for (const [id, window] of entry.sessions)
89
+ if (this.now() - window.refreshedAt >= this.hardTtlMs) {
90
+ entry.sessions.delete(id);
91
+ entry.pins.delete(id);
92
+ }
93
+ if (!entry.lease || !this.authorized(entry.lease)) {
94
+ entry.build?.controller.abort();
95
+ delete entry.load;
96
+ delete entry.lease;
97
+ continue;
98
+ }
99
+ }
100
+ }
101
+ invalidateWorkspace(workspaceId) {
102
+ for (const entry of this.scopes.values())
103
+ if (entry.scope.workspaceId === workspaceId)
104
+ this.evict(entry);
105
+ }
106
+ invalidateSession(workspaceId, sessionId) {
107
+ for (const entry of this.scopes.values()) {
108
+ if (entry.scope.workspaceId !== workspaceId)
109
+ continue;
110
+ // A source removal also invalidates every pinned reference containing that source.
111
+ if (entry.build ||
112
+ entry.snapshots.some((snapshot) => snapshot.messages.some((message) => message.sessionId === sessionId)))
113
+ this.evict(entry);
114
+ else {
115
+ entry.sessions.delete(sessionId);
116
+ entry.pins.delete(sessionId);
117
+ }
118
+ }
119
+ }
120
+ invalidateView(authorizationView) {
121
+ for (const entry of this.scopes.values())
122
+ if (entry.scope.authorizationView === authorizationView)
123
+ this.evict(entry);
124
+ }
125
+ dispose() {
126
+ this.disposed = true;
127
+ this.clear();
128
+ }
129
+ clear() {
130
+ for (const entry of this.scopes.values())
131
+ this.evict(entry);
132
+ }
133
+ statistics() {
134
+ return {
135
+ scopes: this.scopes.size,
136
+ bytes: [...this.scopes.values()].reduce((total, entry) => total + bytes(entry), 0)
137
+ };
138
+ }
139
+ authorized(lease) {
140
+ return lease.expiresAt > this.now() && lease.valid();
141
+ }
142
+ entry(scope) {
143
+ const key = scopeKey(scope);
144
+ let entry = this.scopes.get(key);
145
+ if (entry && this.now() - entry.lastUsed >= this.hardTtlMs) {
146
+ this.evict(entry);
147
+ entry = undefined;
148
+ }
149
+ if (!entry) {
150
+ entry = {
151
+ scope: { ...scope },
152
+ snapshots: [],
153
+ pins: new Map(),
154
+ sessions: new Map(),
155
+ lastUsed: this.now()
156
+ };
157
+ this.scopes.set(key, entry);
158
+ }
159
+ entry.lastUsed = this.now();
160
+ // Map order is LRU across views as well as Workspaces.
161
+ this.scopes.delete(key);
162
+ this.scopes.set(key, entry);
163
+ this.enforceLimits();
164
+ return entry;
165
+ }
166
+ retained(entry) {
167
+ return !this.disposed && this.scopes.get(scopeKey(entry.scope)) === entry;
168
+ }
169
+ build(entry) {
170
+ if (entry.build)
171
+ return entry.build.promise;
172
+ const { lease, load } = entry;
173
+ if (this.reads >= 2 || !lease || !load || !this.authorized(lease) || !this.retained(entry))
174
+ return Promise.resolve();
175
+ const controller = new AbortController();
176
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(1_000)]);
177
+ const promise = (async () => {
178
+ try {
179
+ this.reads++;
180
+ const loading = Promise.resolve().then(() => {
181
+ signal.throwIfAborted();
182
+ if (!this.retained(entry) || !this.authorized(lease))
183
+ throw new Error('PROMPT_COMPLETION_UNAVAILABLE');
184
+ return load(signal);
185
+ });
186
+ void loading
187
+ .finally(() => {
188
+ this.reads--;
189
+ })
190
+ .catch(() => undefined);
191
+ const values = await abortable(loading, signal);
192
+ if (signal.aborted || !this.retained(entry) || !this.authorized(lease))
193
+ return;
194
+ const messages = boundReferences(values);
195
+ const hash = createHash('sha256').update(JSON.stringify(messages)).digest('hex');
196
+ const latest = entry.snapshots.at(-1);
197
+ if (latest?.hash === hash)
198
+ latest.verifiedAt = this.now();
199
+ else {
200
+ entry.snapshots.push({ epoch: randomUUID(), hash, messages, verifiedAt: this.now() });
201
+ if (entry.snapshots.length > 2)
202
+ entry.snapshots.shift();
203
+ for (const [id, pin] of entry.pins)
204
+ if (!entry.snapshots.some((value) => value.epoch === pin.epoch))
205
+ entry.pins.delete(id);
206
+ }
207
+ this.enforceLimits();
208
+ }
209
+ catch {
210
+ // Optional references may be omitted; failure never renews freshness.
211
+ }
212
+ finally {
213
+ if (entry.build?.controller === controller)
214
+ delete entry.build;
215
+ }
216
+ })();
217
+ entry.build = { controller, promise };
218
+ return promise;
219
+ }
220
+ evict(entry) {
221
+ entry.build?.controller.abort();
222
+ this.scopes.delete(scopeKey(entry.scope));
223
+ delete entry.load;
224
+ delete entry.lease;
225
+ entry.snapshots.length = 0;
226
+ entry.sessions.clear();
227
+ entry.pins.clear();
228
+ }
229
+ enforceLimits() {
230
+ let total = 0;
231
+ const workspaceBytes = new Map();
232
+ for (const entry of [...this.scopes.values()].reverse()) {
233
+ const size = bytes(entry);
234
+ const workspaceSize = (workspaceBytes.get(entry.scope.workspaceId) ?? 0) + size;
235
+ if (total + size > MAX_BYTES || workspaceSize > MAX_WORKSPACE_BYTES)
236
+ this.evict(entry);
237
+ else {
238
+ total += size;
239
+ workspaceBytes.set(entry.scope.workspaceId, workspaceSize);
240
+ }
241
+ }
242
+ while (this.scopes.size > MAX_SCOPES)
243
+ this.evict(this.scopes.values().next().value);
244
+ }
245
+ }
246
+ function scopeKey(scope) {
247
+ return JSON.stringify([scope.workspaceId, scope.generation, scope.authorizationView]);
248
+ }
249
+ function bytes(entry) {
250
+ return Buffer.byteLength(JSON.stringify([entry.snapshots, [...entry.sessions], [...entry.pins]]));
251
+ }
252
+ function trimMap(map, size) {
253
+ while (map.size > size)
254
+ map.delete(map.keys().next().value);
255
+ }
256
+ function boundReferences(values) {
257
+ const seen = new Set();
258
+ const result = [];
259
+ let size = 0;
260
+ for (const value of values.slice(0, 100)) {
261
+ const key = JSON.stringify([value.sessionId, value.id]);
262
+ if (seen.has(key) || !value.text.trim())
263
+ continue;
264
+ seen.add(key);
265
+ const text = clip(value.text, 1_024);
266
+ if (size + Buffer.byteLength(text) > 8_192 || result.length >= 20)
267
+ break;
268
+ size += Buffer.byteLength(text);
269
+ result.push(Object.freeze({ sessionId: value.sessionId, id: value.id, revision: value.revision, text }));
270
+ }
271
+ return Object.freeze(result);
272
+ }
273
+ function boundMessages(values) {
274
+ const selected = values.slice(-64).map((value) => Object.freeze({
275
+ id: value.id,
276
+ revision: value.revision,
277
+ role: value.role,
278
+ text: clip(value.text, 4_096)
279
+ }));
280
+ let size = selected.reduce((sum, value) => sum + Buffer.byteLength(value.text), 0);
281
+ if (values.length > 64 || size > 32_768)
282
+ while (selected.length > 1 && (selected.length > 32 || size > 16_384))
283
+ size -= Buffer.byteLength(selected.shift().text);
284
+ return Object.freeze(selected);
285
+ }
286
+ function clip(text, max) {
287
+ if (text.length <= max)
288
+ return text;
289
+ const end = /[\uD800-\uDBFF]/u.test(text[max - 1]) ? max - 1 : max;
290
+ return `${text.slice(0, end)}\n[truncated]`;
291
+ }
292
+ function abortable(pending, signal) {
293
+ return new Promise((resolve, reject) => {
294
+ const aborted = () => {
295
+ cleanup();
296
+ reject(signal.reason);
297
+ };
298
+ const cleanup = () => signal.removeEventListener('abort', aborted);
299
+ if (signal.aborted) {
300
+ reject(signal.reason);
301
+ return;
302
+ }
303
+ signal.addEventListener('abort', aborted, { once: true });
304
+ pending.then((value) => {
305
+ cleanup();
306
+ resolve(value);
307
+ }, (error) => {
308
+ cleanup();
309
+ reject(error);
310
+ });
311
+ });
312
+ }
@@ -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
+ }