@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.
- package/dist/capabilities.js +3 -0
- package/dist/connector.js +59 -1
- package/dist/service/model-usage-service.js +29 -1
- package/dist/service/native-session-watch-service.js +10 -0
- package/dist/service/prompt-completion-attention.js +66 -0
- package/dist/service/prompt-completion-cache.js +312 -0
- package/dist/service/prompt-completion-context.js +87 -0
- package/dist/service/prompt-completion-generator.js +404 -0
- package/dist/service/prompt-completion-journal.js +65 -0
- package/dist/service/prompt-completion-node-service.js +241 -0
- package/dist/service/prompt-completion-service.js +311 -0
- package/dist/service/prompt-completion-task-context.js +51 -0
- package/dist/service/prompt-completion-workspace-source.js +62 -0
- package/dist/service/session-catalog-service.js +1 -1
- package/dist/service/workspace-watch-service.js +4 -0
- package/dist/service/workspace-workbench-service.js +6 -0
- package/package.json +4 -4
package/dist/capabilities.js
CHANGED
|
@@ -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) =>
|
|
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, {
|
|
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
|
+
}
|