@myagentroam/node 0.10.20 → 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.
@@ -0,0 +1,241 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { PromptCompletionJournal } from './prompt-completion-journal.js';
3
+ import { promptCompletionContextInputSchema, promptCompletionGenerateInputSchema, promptCompletionRuntimeSchema, promptCompletionCancelSchema, modelUsageGrantSchema } from '@myagentroam/protocol';
4
+ import { PromptCompletionCache } from './prompt-completion-cache.js';
5
+ import { projectCompletionContext } from './prompt-completion-context.js';
6
+ import { withPredictionTaskState } from './prompt-completion-task-context.js';
7
+ import { projectPredictionAttention } from './prompt-completion-attention.js';
8
+ import { PromptCompletionService } from './prompt-completion-service.js';
9
+ /** Owns the auxiliary domain; Connector only supplies existing readers and lifecycle hooks. */
10
+ export class PromptCompletionNodeService {
11
+ options;
12
+ cache;
13
+ journal;
14
+ requests;
15
+ contextReaders = new Map();
16
+ disposed = false;
17
+ refreshTimer;
18
+ generations = new Map();
19
+ now;
20
+ constructor(options) {
21
+ this.options = options;
22
+ this.now = options.now ?? Date.now;
23
+ this.cache = new PromptCompletionCache({ now: this.now });
24
+ this.requests = new PromptCompletionService({
25
+ record: async (entry) => {
26
+ await this.journal?.record(entry);
27
+ },
28
+ now: this.now,
29
+ modelFactory: options.modelFactory,
30
+ ...(options.generate ? { generate: options.generate } : {}),
31
+ authorized: (raw) => {
32
+ const authorization = raw;
33
+ return this.options.observed(authorization.input, authorization.actor);
34
+ },
35
+ context: async (raw, request, signal) => {
36
+ const authorization = raw;
37
+ signal.throwIfAborted();
38
+ const context = await this.readContext(authorization.input, authorization.actor, signal);
39
+ const sessionId = authorization.input.sessionId;
40
+ return {
41
+ ...context,
42
+ ...(request.attention?.length && this.options.readAttentionFile
43
+ ? {
44
+ attention: await projectPredictionAttention(request.attention, (event) => this.options.readAttentionFile(authorization.input, authorization.actor, event), signal, this.now())
45
+ }
46
+ : {}),
47
+ history: sessionId === null
48
+ ? context.history
49
+ : this.cache.session(authorization.scope, sessionId, context.identity.historyGeneration, context.history)
50
+ };
51
+ },
52
+ references: async (raw, _request, signal) => {
53
+ const authorization = raw;
54
+ // The refresh closure captures only authorized resource metadata, never the draft.
55
+ const { actor, input, scope, expiresAt } = authorization;
56
+ const lease = { expiresAt, valid: () => this.options.observed(input, actor) };
57
+ const result = await this.cache.references(scope, input.sessionId, lease, (readSignal) => this.options.references(scope.workspaceId, actor, readSignal));
58
+ signal.throwIfAborted();
59
+ return result.messages;
60
+ },
61
+ usage: (raw) => {
62
+ const authorization = raw;
63
+ return this.options.usage.completion(authorization.executionId, authorization.usageGrant, authorization.input.sessionId ?? authorization.executionId, authorization.actor.userId);
64
+ }
65
+ });
66
+ this.refreshTimer = setInterval(() => {
67
+ this.cache.sweep();
68
+ }, 30_000);
69
+ this.refreshTimer.unref?.();
70
+ }
71
+ operations() {
72
+ return {
73
+ 'prompt-completion.context': (raw) => this.context(raw),
74
+ 'prompt-completion.generate': (raw) => this.generate(raw),
75
+ 'prompt-completion.cancel': (raw) => this.cancel(raw)
76
+ };
77
+ }
78
+ async context(raw) {
79
+ if (this.disposed)
80
+ throw new Error('PROMPT_COMPLETION_UNAVAILABLE');
81
+ const { actor, data } = unpack(raw);
82
+ const input = promptCompletionContextInputSchema.parse(data);
83
+ if (this.contextReaders.has(actor.userId) || this.contextReaders.size >= 2)
84
+ throw new Error('PROMPT_COMPLETION_CONTEXT_BUSY');
85
+ const controller = new AbortController();
86
+ this.contextReaders.set(actor.userId, controller);
87
+ const timer = setTimeout(() => controller.abort(), 1_000);
88
+ timer.unref?.();
89
+ const signal = controller.signal;
90
+ const pending = this.readContext(input, actor, signal)
91
+ .then((context) => context.identity)
92
+ .finally(() => {
93
+ clearTimeout(timer);
94
+ this.contextReaders.delete(actor.userId);
95
+ });
96
+ return new Promise((resolve, reject) => {
97
+ const abort = () => reject(new Error('PROMPT_COMPLETION_CONTEXT_TIMEOUT'));
98
+ signal.addEventListener('abort', abort, { once: true });
99
+ pending.then((value) => {
100
+ signal.removeEventListener('abort', abort);
101
+ resolve(value);
102
+ }, (error) => {
103
+ signal.removeEventListener('abort', abort);
104
+ reject(error);
105
+ });
106
+ if (signal.aborted)
107
+ abort();
108
+ });
109
+ }
110
+ generate(raw) {
111
+ const { actor, data, usageGrant } = unpack(raw);
112
+ const { runtime: rawRuntime, ...publicInput } = data;
113
+ const input = promptCompletionGenerateInputSchema.parse(publicInput);
114
+ const runtime = promptCompletionRuntimeSchema.parse(rawRuntime);
115
+ if (runtime.expiresAt > this.now() + 30_000)
116
+ throw new Error('PROMPT_COMPLETION_AUTHORIZATION_INVALID');
117
+ if (input.target.kind === 'WORKSPACE' && input.target.workspaceId !== runtime.workspaceId)
118
+ throw new Error('PROMPT_COMPLETION_AUTHORIZATION_INVALID');
119
+ const descriptor = {
120
+ target: input.target,
121
+ runner: input.request.runner,
122
+ sessionId: input.request.sessionId,
123
+ workbenchConnectionId: input.workbenchConnectionId,
124
+ pageEpoch: input.request.pageEpoch
125
+ };
126
+ const authorization = {
127
+ ...runtime,
128
+ usageGrant: modelUsageGrantSchema.parse(usageGrant),
129
+ userId: String(actor.userId),
130
+ actor,
131
+ input: descriptor,
132
+ runtime,
133
+ scope: {
134
+ workspaceId: runtime.workspaceId,
135
+ generation: this.options.generation(),
136
+ authorizationView: digest([runtime.authorizationView, input.target])
137
+ },
138
+ executionId: randomUUID()
139
+ };
140
+ return this.requests.generate({ ...input.request, pageEpoch: epoch(actor, descriptor.pageEpoch) }, authorization);
141
+ }
142
+ cancel(raw) {
143
+ const { actor, data } = unpack(raw);
144
+ const input = promptCompletionCancelSchema.parse(data);
145
+ this.requests.cancel(String(actor.userId), {
146
+ ...input,
147
+ pageEpoch: epoch(actor, input.pageEpoch)
148
+ });
149
+ return { cancelled: true };
150
+ }
151
+ async readContext(input, actor, signal) {
152
+ if (!this.options.observed(input, actor))
153
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
154
+ const generation = this.historyGeneration(input.target, input.sessionId, input.runner);
155
+ const turns = await this.options.readPage(input, actor, signal);
156
+ signal.throwIfAborted();
157
+ if (!this.options.observed(input, actor) ||
158
+ generation !== this.historyGeneration(input.target, input.sessionId, input.runner))
159
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
160
+ const context = projectCompletionContext(turns, generation);
161
+ return this.options.taskState
162
+ ? withPredictionTaskState(context, this.options.taskState(input, actor))
163
+ : context;
164
+ }
165
+ initializeJournal(dataDirectory) {
166
+ this.journal?.dispose();
167
+ this.journal = new PromptCompletionJournal(dataDirectory, this.now);
168
+ }
169
+ sweep() {
170
+ this.cache.sweep();
171
+ }
172
+ disconnect() {
173
+ for (const controller of this.contextReaders.values())
174
+ controller.abort();
175
+ this.requests.disconnect();
176
+ this.cache.clear();
177
+ this.generations.clear();
178
+ }
179
+ invalidateWorkspace(workspaceId) {
180
+ this.cache.invalidateWorkspace(workspaceId);
181
+ this.requests.invalidateWorkspace(workspaceId);
182
+ for (const [key, entry] of this.generations)
183
+ if (entry.target.kind === 'WORKSPACE' && entry.target.workspaceId === workspaceId)
184
+ this.generations.delete(key);
185
+ }
186
+ invalidateUser(userId) {
187
+ this.requests.invalidateUser(String(userId));
188
+ // Permission changes retire all reference views; they can be rebuilt after fresh authorization.
189
+ this.cache.clear();
190
+ }
191
+ invalidateSession(workspaceId, sessionId) {
192
+ // Retire source material without interrupting an already frozen prediction request.
193
+ this.cache.invalidateSession(workspaceId, sessionId);
194
+ }
195
+ releasePage(actor, pageEpoch) {
196
+ this.requests.releasePage(String(actor.userId), epoch(actor, pageEpoch));
197
+ }
198
+ dispose() {
199
+ this.disposed = true;
200
+ clearInterval(this.refreshTimer);
201
+ for (const controller of this.contextReaders.values())
202
+ controller.abort();
203
+ this.requests.dispose();
204
+ this.journal?.dispose();
205
+ this.cache.dispose();
206
+ this.generations.clear();
207
+ }
208
+ historyGeneration(target, sessionId, runner) {
209
+ const key = digest([this.options.generation(), target, sessionId, runner]);
210
+ let entry = this.generations.get(key);
211
+ if (!entry) {
212
+ entry = { generation: randomUUID(), target };
213
+ this.generations.set(key, entry);
214
+ }
215
+ while (this.generations.size > 256)
216
+ this.generations.delete(this.generations.keys().next().value);
217
+ return entry.generation;
218
+ }
219
+ }
220
+ function unpack(raw) {
221
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
222
+ throw new Error('MESSAGE_INVALID');
223
+ const { __requestUserId, __workspaceWatchSessionHash, __usageGrant, ...data } = raw;
224
+ if (typeof __requestUserId !== 'number' ||
225
+ !Number.isSafeInteger(__requestUserId) ||
226
+ __requestUserId <= 0 ||
227
+ typeof __workspaceWatchSessionHash !== 'string' ||
228
+ !__workspaceWatchSessionHash)
229
+ throw new Error('USER_AUTHORIZATION_REQUIRED');
230
+ return {
231
+ actor: { userId: __requestUserId, sessionHash: __workspaceWatchSessionHash },
232
+ data,
233
+ usageGrant: __usageGrant
234
+ };
235
+ }
236
+ function epoch(actor, pageEpoch) {
237
+ return digest([actor.sessionHash, pageEpoch]);
238
+ }
239
+ function digest(value) {
240
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
241
+ }
@@ -0,0 +1,311 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { generatePromptCompletion } from './prompt-completion-generator.js';
3
+ import { promptCompletionRequestSchema, promptCompletionCancelSchema } from '@myagentroam/protocol';
4
+ /** One manual auxiliary request; it never mutates a Session or enqueues a Run. */
5
+ export class PromptCompletionService {
6
+ options;
7
+ pages = new Map();
8
+ rates = new Map();
9
+ affinities = new Map();
10
+ now;
11
+ disposed = false;
12
+ constructor(options) {
13
+ this.options = options;
14
+ this.now = options.now ?? Date.now;
15
+ }
16
+ async generate(raw, authorization) {
17
+ const request = promptCompletionRequestSchema.parse(raw);
18
+ const startedAt = this.now();
19
+ const analysis = {};
20
+ let result;
21
+ let failed = false;
22
+ let failureCode;
23
+ try {
24
+ result = await this.execute(request, authorization, analysis);
25
+ return result;
26
+ }
27
+ catch (error) {
28
+ failed = true;
29
+ const code = error?.code;
30
+ failureCode =
31
+ typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,95}$/.test(code)
32
+ ? code
33
+ : 'PROMPT_COMPLETION_FAILED';
34
+ throw error;
35
+ }
36
+ finally {
37
+ const context = JSON.stringify(analysis);
38
+ try {
39
+ await this.options.record?.({
40
+ schemaVersion: 1,
41
+ startedAt,
42
+ finishedAt: this.now(),
43
+ durationMs: this.now() - startedAt,
44
+ requestId: request.requestId,
45
+ workspaceId: authorization.scope.workspaceId,
46
+ userId: authorization.userId,
47
+ runner: request.runner,
48
+ sessionId: request.sessionId,
49
+ modelId: authorization.model.id,
50
+ providerModelId: authorization.model.modelId,
51
+ reasoningEffort: authorization.reasoningEffort,
52
+ input: {
53
+ trigger: request.trigger,
54
+ draft: request.draft,
55
+ cursorPosition: request.cursorPosition,
56
+ mode: request.completionMode
57
+ },
58
+ context: context.slice(0, 96_000),
59
+ contextTruncated: context.length > 96_000,
60
+ output: result?.status === 'ready' ? result.suggestion : null,
61
+ status: failed ? 'failed' : result?.status,
62
+ reason: failed ? failureCode : result?.status === 'skipped' ? result.reason : undefined
63
+ });
64
+ }
65
+ catch {
66
+ console.warn('PROMPT_COMPLETION_JOURNAL_WRITE_FAILED');
67
+ }
68
+ }
69
+ }
70
+ async execute(request, authorization, analysis) {
71
+ const identity = responseIdentity(request);
72
+ this.prune();
73
+ if (!this.allowed(authorization))
74
+ return { ...identity, status: 'skipped', reason: 'UNAVAILABLE' };
75
+ const pageKey = JSON.stringify([authorization.userId, request.pageEpoch]);
76
+ const previous = this.pages.get(pageKey);
77
+ if (previous && (previous.closed || request.intentSequence <= previous.sequence))
78
+ return { ...identity, status: 'cancelled' };
79
+ if (previous?.active)
80
+ return { ...identity, status: 'skipped', reason: 'IN_FLIGHT' };
81
+ if (!previous && !this.hasPageCapacity(authorization.userId))
82
+ return { ...identity, status: 'skipped', reason: 'CAPACITY' };
83
+ const active = [...this.pages.values()].filter((page) => page.active);
84
+ if (active.some((page) => page.userId === authorization.userId))
85
+ return { ...identity, status: 'skipped', reason: 'IN_FLIGHT' };
86
+ if (active.length >= 2)
87
+ return { ...identity, status: 'skipped', reason: 'CAPACITY' };
88
+ const controller = new AbortController();
89
+ const page = {
90
+ userId: authorization.userId,
91
+ requestId: request.requestId,
92
+ sequence: request.intentSequence,
93
+ expiresAt: this.now() + 60_000,
94
+ closed: false,
95
+ active: { controller, workspaceId: authorization.scope.workspaceId }
96
+ };
97
+ this.pages.set(pageKey, page);
98
+ const timeout = setTimeout(() => controller.abort(), Math.min(this.options.timeoutMs ?? 12_000, authorization.expiresAt - this.now()));
99
+ const signal = controller.signal;
100
+ // Retain physical capacity until cleanup finishes, even if a slow reader ignores abort.
101
+ const running = (async () => {
102
+ try {
103
+ const context = await this.options.context(authorization, request, signal);
104
+ if (!this.allowed(authorization) || signal.aborted)
105
+ return { ...identity, status: 'cancelled' };
106
+ if (context.identity.historyGeneration !== request.historyGeneration)
107
+ return { ...identity, status: 'cancelled' };
108
+ analysis.context = context;
109
+ const references = await this.options.references(authorization, request, signal);
110
+ analysis.references = references;
111
+ if (!this.allowed(authorization) || signal.aborted)
112
+ return { ...identity, status: 'cancelled' };
113
+ if (!request.draft && context.history.length === 0 && references.length === 0)
114
+ return { ...identity, status: 'empty' };
115
+ const calls = (this.rates.get(authorization.userId) ?? []).filter((time) => time > this.now() - 60_000);
116
+ if (!this.rates.has(authorization.userId) && this.rates.size >= 4096)
117
+ return { ...identity, status: 'skipped', reason: 'CAPACITY' };
118
+ const wait = Math.max(0, 2_000 - (this.now() - (calls.at(-1) ?? -Infinity)), calls.length >= 20 ? 60_000 - (this.now() - calls[0]) : 0);
119
+ if (wait > 0)
120
+ return { ...identity, status: 'skipped', reason: 'RATE_LIMITED', retryAfterMs: wait };
121
+ calls.push(this.now());
122
+ this.rates.set(authorization.userId, calls);
123
+ const [model] = this.options.modelFactory.create([authorization.model], {
124
+ runtimeScopeId: authorization.runtimeScopeId,
125
+ executionId: authorization.executionId
126
+ });
127
+ if (!model)
128
+ throw new Error('PROMPT_COMPLETION_MODEL_UNAVAILABLE');
129
+ let usage;
130
+ let suggestion;
131
+ try {
132
+ usage = this.options.usage(authorization);
133
+ ({ suggestion } = await (this.options.generate ?? generatePromptCompletion)({
134
+ model,
135
+ reasoningEffort: authorization.reasoningEffort,
136
+ history: context.history,
137
+ referenceMessages: references,
138
+ executionSnapshot: context.executionSnapshot,
139
+ onPreparedInput: (value) => {
140
+ analysis.preparedInput = value;
141
+ },
142
+ onPredictionOutput: (value) => {
143
+ analysis.modelOutput = value;
144
+ },
145
+ ...(context.attention ? { attention: context.attention } : {}),
146
+ draft: request.draft,
147
+ cursorPosition: request.cursorPosition,
148
+ completionMode: request.completionMode,
149
+ promptCacheKey: this.affinity(authorization),
150
+ signal,
151
+ ...(usage.onModelUsage ? { onModelUsage: usage.onModelUsage } : {}),
152
+ ...(usage.onModelUsageFailure ? { onModelUsageFailure: usage.onModelUsageFailure } : {})
153
+ }));
154
+ }
155
+ finally {
156
+ try {
157
+ usage?.release();
158
+ }
159
+ finally {
160
+ await model.credentialProvider?.close();
161
+ }
162
+ }
163
+ if (!this.allowed(authorization) || signal.aborted)
164
+ return { ...identity, status: 'cancelled' };
165
+ await this.options.validateAuthorization?.(authorization);
166
+ if (!this.allowed(authorization) || signal.aborted)
167
+ return { ...identity, status: 'cancelled' };
168
+ const expiresAt = Math.min(this.now() + 30_000, authorization.expiresAt);
169
+ return suggestion === null
170
+ ? { ...identity, status: 'empty' }
171
+ : { ...identity, status: 'ready', suggestion, source: 'generated', expiresAt };
172
+ }
173
+ catch (error) {
174
+ if (signal.aborted)
175
+ return { ...identity, status: 'cancelled' };
176
+ throw error;
177
+ }
178
+ finally {
179
+ clearTimeout(timeout);
180
+ delete page.active;
181
+ }
182
+ })();
183
+ return new Promise((resolve, reject) => {
184
+ const aborted = () => {
185
+ cleanup();
186
+ resolve({ ...identity, status: 'cancelled' });
187
+ };
188
+ const cleanup = () => signal.removeEventListener('abort', aborted);
189
+ // Transport errors remain rejections; no response body or draft is logged here.
190
+ void running.then((value) => {
191
+ cleanup();
192
+ resolve(value);
193
+ }, (error) => {
194
+ cleanup();
195
+ reject(error);
196
+ });
197
+ if (signal.aborted)
198
+ aborted();
199
+ else
200
+ signal.addEventListener('abort', aborted, { once: true });
201
+ });
202
+ }
203
+ /** Identity is authenticated separately; cancellation does not require generation rights. */
204
+ cancel(userId, raw) {
205
+ const request = promptCompletionCancelSchema.parse(raw);
206
+ this.prune();
207
+ const key = JSON.stringify([userId, request.pageEpoch]);
208
+ const page = this.pages.get(key);
209
+ if (page && request.intentSequence < page.sequence)
210
+ return;
211
+ if (page && request.intentSequence === page.sequence && page.requestId !== request.requestId)
212
+ throw new Error('PROMPT_COMPLETION_INTENT_CONFLICT');
213
+ if (!page && !this.hasPageCapacity(userId))
214
+ throw new Error('PROMPT_COMPLETION_CAPACITY');
215
+ page?.active?.controller.abort();
216
+ if (page) {
217
+ page.sequence = request.intentSequence;
218
+ page.requestId = request.requestId;
219
+ page.expiresAt = this.now() + 60_000;
220
+ }
221
+ else
222
+ this.pages.set(key, {
223
+ userId,
224
+ requestId: request.requestId,
225
+ sequence: request.intentSequence,
226
+ expiresAt: this.now() + 60_000,
227
+ closed: false
228
+ });
229
+ }
230
+ invalidateWorkspace(workspaceId) {
231
+ for (const page of this.pages.values())
232
+ if (page.active?.workspaceId === workspaceId)
233
+ page.active.controller.abort();
234
+ this.affinities.clear();
235
+ }
236
+ invalidateUser(userId) {
237
+ for (const [key, page] of this.pages)
238
+ if (JSON.parse(key)[0] === userId)
239
+ page.active?.controller.abort();
240
+ this.affinities.clear();
241
+ }
242
+ releasePage(userId, pageEpoch) {
243
+ const page = this.pages.get(JSON.stringify([userId, pageEpoch]));
244
+ if (!page)
245
+ return;
246
+ page.closed = true;
247
+ page.expiresAt = this.now() + 60_000;
248
+ page.active?.controller.abort();
249
+ }
250
+ disconnect() {
251
+ for (const page of this.pages.values())
252
+ page.active?.controller.abort();
253
+ this.affinities.clear();
254
+ }
255
+ dispose() {
256
+ this.disposed = true;
257
+ for (const page of this.pages.values())
258
+ page.active?.controller.abort();
259
+ this.rates.clear();
260
+ this.affinities.clear();
261
+ this.pages.clear();
262
+ }
263
+ allowed(authorization) {
264
+ return (!this.disposed &&
265
+ authorization.expiresAt > this.now() &&
266
+ this.options.authorized(authorization));
267
+ }
268
+ affinity(authorization) {
269
+ const identity = digest([
270
+ authorization.userId,
271
+ authorization.scope,
272
+ authorization.modelRevision,
273
+ authorization.credentialScope
274
+ ]);
275
+ const found = this.affinities.get(identity);
276
+ if (found && found.expiresAt > this.now())
277
+ return found.key;
278
+ const entry = { key: randomUUID(), expiresAt: this.now() + 15 * 60_000 };
279
+ this.affinities.set(identity, entry);
280
+ while (this.affinities.size > 128)
281
+ this.affinities.delete(this.affinities.keys().next().value);
282
+ return entry.key;
283
+ }
284
+ prune() {
285
+ for (const [key, page] of this.pages)
286
+ if (!page.active && page.expiresAt <= this.now())
287
+ this.pages.delete(key);
288
+ for (const [key, value] of this.affinities)
289
+ if (value.expiresAt <= this.now())
290
+ this.affinities.delete(key);
291
+ for (const [key, calls] of this.rates)
292
+ if ((calls.at(-1) ?? 0) <= this.now() - 60_000)
293
+ this.rates.delete(key);
294
+ }
295
+ hasPageCapacity(userId) {
296
+ return (this.pages.size < 4096 &&
297
+ [...this.pages.values()].filter((page) => page.userId === userId).length < 128);
298
+ }
299
+ }
300
+ function responseIdentity(request) {
301
+ return {
302
+ requestId: request.requestId,
303
+ contextVersion: request.contextVersion,
304
+ historyGeneration: request.historyGeneration,
305
+ draftRevision: request.draftRevision,
306
+ cursorPosition: request.cursorPosition
307
+ };
308
+ }
309
+ function digest(value) {
310
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
311
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { composerVisibleText } from '@myagentroam/protocol';
3
+ /** Uses canonical task state only; pending work is the existing Goal/Queue/waiting projection. */
4
+ export function withPredictionTaskState(context, task) {
5
+ const snapshot = JSON.parse(context.executionSnapshot);
6
+ const runStatus = task.runStatus ?? snapshot.status;
7
+ const waiting = snapshot.waiting;
8
+ const phase = waiting.some((item) => item.kind === 'approval')
9
+ ? 'WAITING_APPROVAL'
10
+ : waiting.some((item) => item.kind === 'user_input_request')
11
+ ? 'WAITING_INPUT'
12
+ : ['RUNNING', 'STARTING', 'QUEUED', 'IN_PROGRESS'].includes(runStatus)
13
+ ? 'RUNNING'
14
+ : ['COMPLETED', 'SUCCEEDED'].includes(runStatus)
15
+ ? 'COMPLETED'
16
+ : runStatus === 'FAILED'
17
+ ? 'FAILED'
18
+ : runStatus === 'IDLE'
19
+ ? 'IDLE'
20
+ : 'UNKNOWN';
21
+ const goal = typeof task.goal === 'object' && task.goal !== null
22
+ ? { ...task.goal, objective: composerVisibleText(task.goal.objective).slice(0, 2048) }
23
+ : task.goal;
24
+ const queue = task.queue === 'UNKNOWN'
25
+ ? 'UNKNOWN'
26
+ : {
27
+ version: task.queue.version,
28
+ paused: task.queue.paused,
29
+ total: task.queue.items.length,
30
+ items: task.queue.items.slice(0, 8).map((item) => ({
31
+ id: item.queueItemId,
32
+ text: composerVisibleText(item.content).slice(0, 768),
33
+ status: 'QUEUED_NOT_EXECUTED'
34
+ }))
35
+ };
36
+ const tasks = { phase, goal, queue, pendingWork: { source: 'GOAL_QUEUE_WAITING', waiting } };
37
+ return {
38
+ ...context,
39
+ identity: {
40
+ ...context.identity,
41
+ contextVersion: createHash('sha256')
42
+ .update(JSON.stringify([context.identity.contextVersion, tasks]))
43
+ .digest('hex')
44
+ },
45
+ executionSnapshot: JSON.stringify({
46
+ ...snapshot,
47
+ status: task.runStatus ?? snapshot.status,
48
+ ...tasks
49
+ })
50
+ };
51
+ }
@@ -0,0 +1,62 @@
1
+ import { projectCompletionContext } from './prompt-completion-context.js';
2
+ /** Uses the existing Workspace catalog and normalized history readers; never scans a second index. */
3
+ export class PromptCompletionWorkspaceSource {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ observed(input, actor) {
9
+ if (input.target.kind !== 'WORKSPACE')
10
+ return false;
11
+ return input.sessionId === null
12
+ ? this.options.observesWorkspace(input.target.workspaceId, actor, input.workbenchConnectionId)
13
+ : this.options.observesSession(input.target.workspaceId, input.sessionId, actor, input.workbenchConnectionId);
14
+ }
15
+ async readPage(input, actor, signal) {
16
+ if (input.target.kind !== 'WORKSPACE' || !this.observed(input, actor))
17
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
18
+ this.options.requireWorkspace(input.target.workspaceId);
19
+ signal.throwIfAborted();
20
+ if (input.sessionId === null)
21
+ return [];
22
+ const session = await this.options.resolveSession(input.sessionId);
23
+ if (!session ||
24
+ session.workspaceId !== input.target.workspaceId ||
25
+ session.runner !== input.runner)
26
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
27
+ signal.throwIfAborted();
28
+ if (!this.observed(input, actor))
29
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
30
+ const page = await this.options.readHistory(session, 20);
31
+ signal.throwIfAborted();
32
+ if (!this.observed(input, actor))
33
+ throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
34
+ return page.turns;
35
+ }
36
+ async references(workspaceId, signal) {
37
+ this.options.requireWorkspace(workspaceId);
38
+ signal.throwIfAborted();
39
+ const sessions = this.options
40
+ .cachedSessions(workspaceId)
41
+ .filter((session) => session.workspaceId === workspaceId && session.externalDiscovered !== false)
42
+ .sort((left, right) => right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id))
43
+ .slice(0, 4);
44
+ const messages = [];
45
+ for (const session of sessions) {
46
+ signal.throwIfAborted();
47
+ try {
48
+ const page = await this.options.readHistory(session, 20);
49
+ signal.throwIfAborted();
50
+ const projection = projectCompletionContext(page.turns, 'reference');
51
+ messages.push(...projection.references
52
+ .slice(-20)
53
+ .map((message) => ({ ...message, sessionId: session.id })));
54
+ }
55
+ catch {
56
+ signal.throwIfAborted();
57
+ // Reference history is optional; an unavailable source does not block the current draft.
58
+ }
59
+ }
60
+ return messages;
61
+ }
62
+ }
@@ -142,7 +142,7 @@ export class SessionCatalogService {
142
142
  ? { ...source, lastActivityAt }
143
143
  : this.options.runtime.resetAgentSessionHistory(runtimeSession.id, lastActivityAt);
144
144
  this.options.projection.remember([session]);
145
- this.options.invalidateHistory(session.id);
145
+ this.options.invalidateHistory(session.id, session.workspaceId);
146
146
  this.options.invalidateWorkspaceSessions(session.workspaceId);
147
147
  this.options.emitSession(session);
148
148
  return { session: this.options.present(session) };