@mono-agent/web 0.12.0

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.
Files changed (53) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +169 -0
  3. package/dist/contracts.d.ts +151 -0
  4. package/dist/contracts.d.ts.map +1 -0
  5. package/dist/contracts.js +11 -0
  6. package/dist/contracts.js.map +1 -0
  7. package/dist/discovery.d.ts +17 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +137 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/errors.d.ts +9 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +25 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +10 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/operator-client.d.ts +42 -0
  20. package/dist/operator-client.d.ts.map +1 -0
  21. package/dist/operator-client.js +257 -0
  22. package/dist/operator-client.js.map +1 -0
  23. package/dist/server.d.ts +22 -0
  24. package/dist/server.d.ts.map +1 -0
  25. package/dist/server.js +566 -0
  26. package/dist/server.js.map +1 -0
  27. package/dist/service.d.ts +89 -0
  28. package/dist/service.d.ts.map +1 -0
  29. package/dist/service.js +644 -0
  30. package/dist/service.js.map +1 -0
  31. package/dist/state-paths.d.ts +25 -0
  32. package/dist/state-paths.d.ts.map +1 -0
  33. package/dist/state-paths.js +226 -0
  34. package/dist/state-paths.js.map +1 -0
  35. package/dist/store.d.ts +112 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/store.js +953 -0
  38. package/dist/store.js.map +1 -0
  39. package/package.json +48 -0
  40. package/webapp/dist/apple-touch-icon.png +0 -0
  41. package/webapp/dist/assets/assistant-ui-CU4gqU0g.js +70 -0
  42. package/webapp/dist/assets/index-Ck3BY0Ti.js +47 -0
  43. package/webapp/dist/assets/index-CoB6Xwh-.css +1 -0
  44. package/webapp/dist/assets/markdown-qFW9VWmB.js +18 -0
  45. package/webapp/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
  46. package/webapp/dist/favicon.ico +0 -0
  47. package/webapp/dist/icon-192.png +0 -0
  48. package/webapp/dist/icon-512.png +0 -0
  49. package/webapp/dist/icon.svg +22 -0
  50. package/webapp/dist/index.html +20 -0
  51. package/webapp/dist/manifest.webmanifest +1 -0
  52. package/webapp/dist/sw.js +1 -0
  53. package/webapp/dist/workbox-9c191d2f.js +1 -0
@@ -0,0 +1,644 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, } from "@mono-agent/agent-contracts";
3
+ import { EFFORT_LEVELS } from "@mono-agent/config";
4
+ import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
5
+ import { discoverOperatorAgents, } from "./discovery.js";
6
+ import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
7
+ import { OperatorClient } from "./operator-client.js";
8
+ import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
9
+ import { toWebAttachment, WebStore } from "./store.js";
10
+ const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
11
+ const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
12
+ const INFO_TIMEOUT_MS = 2_500;
13
+ export class WebService {
14
+ store;
15
+ options;
16
+ lease;
17
+ subscribers = new Set();
18
+ activeTurns = new Map();
19
+ activeUploads = new Map();
20
+ allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
21
+ attachmentTurnBudget;
22
+ connections = new Map();
23
+ discoveryTimer;
24
+ purgeTimer;
25
+ purgePromise;
26
+ refreshPromise;
27
+ refreshController;
28
+ eventSequence = 0;
29
+ stopped = false;
30
+ constructor(store, lease, options) {
31
+ this.store = store;
32
+ this.lease = lease;
33
+ this.options = options;
34
+ this.attachmentTurnBudget = new WeightedTurnBudget(options.maxActiveAttachmentTurnBytes ?? WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, options.maxQueuedAttachmentTurns ?? WEB_MAX_QUEUED_ATTACHMENT_TURNS);
35
+ }
36
+ static async create(options = {}) {
37
+ const paths = await prepareWebStatePaths(options);
38
+ let lease;
39
+ try {
40
+ lease = await acquireWebStateLease(paths);
41
+ }
42
+ catch (error) {
43
+ throw error;
44
+ }
45
+ let store;
46
+ try {
47
+ // Recovery mutates active rows, so it must happen only after singleton
48
+ // ownership is established. A losing second process never opens the DB.
49
+ store = await WebStore.openPrepared(paths, options);
50
+ }
51
+ catch (error) {
52
+ await lease.release();
53
+ throw error;
54
+ }
55
+ const service = new WebService(store, lease, options);
56
+ try {
57
+ await store.purgePartialUploadFiles();
58
+ await service.purgeOrphans();
59
+ await service.refreshAgents();
60
+ service.startTimers();
61
+ return service;
62
+ }
63
+ catch (error) {
64
+ await service.stop();
65
+ throw error;
66
+ }
67
+ }
68
+ async bootstrap() {
69
+ const currentThreadId = this.store.currentThreadId();
70
+ return {
71
+ version: WEB_API_VERSION,
72
+ agents: this.store.listAgents(),
73
+ threads: this.store.listThreads(),
74
+ ...(currentThreadId === undefined ? {} : { currentThreadId }),
75
+ limits: {
76
+ maxFileBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
77
+ maxFilesPerTurn: WEB_MAX_FILES_PER_TURN,
78
+ maxTurnBytes: WEB_MAX_TURN_ATTACHMENT_BYTES,
79
+ accept: DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST,
80
+ },
81
+ };
82
+ }
83
+ createThread(sourceId) {
84
+ const thread = this.store.createThread(sourceId);
85
+ this.emit("threads.changed", thread.id, { thread });
86
+ return thread;
87
+ }
88
+ thread(id) {
89
+ const detail = this.store.getThreadDetail(id);
90
+ if (detail === undefined)
91
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
92
+ return detail;
93
+ }
94
+ patchThread(id, patch) {
95
+ const thread = this.store.patchThread(id, patch);
96
+ this.emit("thread.changed", id, { thread });
97
+ this.emit("threads.changed", id);
98
+ return thread;
99
+ }
100
+ patchAgent(sourceId, patch) {
101
+ const agent = this.store.setAgentPinned(sourceId, patch.pinned);
102
+ this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
103
+ return agent;
104
+ }
105
+ async startTurn(threadId, input) {
106
+ const text = input.text ?? "";
107
+ const attachmentIds = input.attachmentIds ?? [];
108
+ const thread = this.store.getThread(threadId);
109
+ if (thread === undefined)
110
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
111
+ const agent = this.store.getAgent(thread.sourceId);
112
+ const connection = this.connections.get(thread.sourceId);
113
+ if (agent === undefined || connection === undefined || !thread.canSend) {
114
+ throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
115
+ }
116
+ validateModelAndEffort(agent, input.model, input.effort);
117
+ const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.model === undefined ? {} : { model: input.model }), ...(input.effort === undefined ? {} : { effort: input.effort }) });
118
+ const controller = new AbortController();
119
+ const completion = this.runTurn(started, connection.client, controller).finally(() => {
120
+ this.activeTurns.delete(threadId);
121
+ });
122
+ this.activeTurns.set(threadId, { turnId: started.turnId, controller, client: connection.client, completion });
123
+ this.emit("turn.changed", threadId, { turn: started.thread.runState });
124
+ this.emit("threads.changed", threadId);
125
+ return { thread: started.thread, turn: started.thread.runState };
126
+ }
127
+ async cancelTurn(threadId) {
128
+ const active = this.activeTurns.get(threadId);
129
+ const stored = this.store.activeTurn(threadId);
130
+ if (stored === undefined)
131
+ throw new WebConsoleError("no_active_turn", "This conversation has no active turn.", 409);
132
+ if (active !== undefined) {
133
+ void active.client.cancel(stored.conversationId).catch((error) => {
134
+ this.options.logger?.debug?.("Web turn cancel request failed.", { error: errorMessage(error) });
135
+ });
136
+ active.controller.abort(new WebTurnCancellation("user", "Cancelled from the web console."));
137
+ }
138
+ const thread = this.store.getThread(threadId);
139
+ if (thread === undefined)
140
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
141
+ return thread;
142
+ }
143
+ createUpload(input) {
144
+ const name = normalizeFilename(input.name);
145
+ const contentType = normalizeMime(input.contentType);
146
+ if (!this.allowlist.has(contentType)) {
147
+ throw new WebConsoleError("unsupported_attachment_type", `Attachments of type ${contentType} are not allowed.`, 415);
148
+ }
149
+ if (input.sizeBytes !== undefined && (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0)) {
150
+ throw new WebConsoleError("invalid_attachment_size", "Attachment size must be a non-negative integer.", 400);
151
+ }
152
+ if ((input.sizeBytes ?? 0) > DEFAULT_AGENT_ATTACHMENT_MAX_BYTES) {
153
+ throw new WebConsoleError("attachment_too_large", "Attachment exceeds the 20 MiB file limit.", 413);
154
+ }
155
+ const usage = this.store.stagedUploadUsage();
156
+ if (usage.count >= WEB_MAX_STAGED_UPLOADS
157
+ || usage.bytes + this.activeReservedUploadBytes() + (input.sizeBytes ?? 0) > WEB_MAX_STAGED_UPLOAD_BYTES) {
158
+ throw new WebConsoleError("staged_upload_quota", "The staged upload quota is full. Remove an upload or try later.", 429);
159
+ }
160
+ const attachment = this.store.createUpload({
161
+ name,
162
+ contentType,
163
+ kind: contentType.startsWith("image/") ? "image" : "document",
164
+ ...(input.sizeBytes === undefined ? {} : { declaredSize: input.sizeBytes }),
165
+ });
166
+ const web = toWebAttachment(attachment);
167
+ this.emit("attachment.changed", undefined, { attachment: web });
168
+ return web;
169
+ }
170
+ reserveUpload(id) {
171
+ const attachment = this.store.getStoredAttachment(id);
172
+ if (attachment === undefined)
173
+ throw new WebConsoleError("attachment_not_found", "Attachment not found.", 404);
174
+ if (attachment.status !== "staged" || attachment.uploaded) {
175
+ throw new WebConsoleError("attachment_unavailable", "This attachment is not available for upload.", 409);
176
+ }
177
+ if (this.activeUploads.size >= WEB_MAX_CONCURRENT_UPLOADS || this.activeUploads.has(id)) {
178
+ throw new WebConsoleError("upload_concurrency_limit", "Too many uploads are already in progress.", 429);
179
+ }
180
+ const usage = this.store.stagedUploadUsage();
181
+ const reservationBytes = DEFAULT_AGENT_ATTACHMENT_MAX_BYTES - attachment.sizeBytes;
182
+ const worstCaseBytes = usage.bytes + this.activeReservedUploadBytes() + reservationBytes;
183
+ if (worstCaseBytes > WEB_MAX_STAGED_UPLOAD_BYTES) {
184
+ throw new WebConsoleError("staged_upload_quota", "The staged upload byte quota is full.", 429);
185
+ }
186
+ this.activeUploads.set(id, reservationBytes);
187
+ let released = false;
188
+ return {
189
+ attachment,
190
+ maxBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
191
+ release: () => {
192
+ if (released)
193
+ return;
194
+ released = true;
195
+ this.activeUploads.delete(id);
196
+ },
197
+ };
198
+ }
199
+ completeUpload(id, sizeBytes) {
200
+ const web = toWebAttachment(this.store.markUploadComplete(id, sizeBytes));
201
+ this.emit("attachment.changed", undefined, { attachment: web });
202
+ return web;
203
+ }
204
+ storedAttachment(id) {
205
+ const attachment = this.store.getStoredAttachment(id);
206
+ if (attachment === undefined)
207
+ throw new WebConsoleError("attachment_not_found", "Attachment not found.", 404);
208
+ return attachment;
209
+ }
210
+ async removeUpload(id) {
211
+ if (this.activeUploads.has(id))
212
+ throw new WebConsoleError("upload_active", "This upload is still in progress.", 409);
213
+ await this.store.removeStagedAttachment(id);
214
+ this.emit("attachment.changed", undefined, { attachmentId: id, removed: true });
215
+ }
216
+ subscribe(callback) {
217
+ this.subscribers.add(callback);
218
+ return () => this.subscribers.delete(callback);
219
+ }
220
+ readyEvent() {
221
+ return this.createEvent("ready", undefined, { version: WEB_API_VERSION });
222
+ }
223
+ refreshAgents() {
224
+ if (this.stopped)
225
+ return Promise.resolve();
226
+ if (this.refreshPromise !== undefined)
227
+ return this.refreshPromise;
228
+ const controller = new AbortController();
229
+ this.refreshController = controller;
230
+ this.refreshPromise = this.refreshAgentsOnce(controller.signal).finally(() => {
231
+ this.refreshPromise = undefined;
232
+ if (this.refreshController === controller)
233
+ this.refreshController = undefined;
234
+ });
235
+ return this.refreshPromise;
236
+ }
237
+ async stop() {
238
+ if (this.stopped)
239
+ return;
240
+ this.stopped = true;
241
+ if (this.discoveryTimer !== undefined)
242
+ clearInterval(this.discoveryTimer);
243
+ if (this.purgeTimer !== undefined)
244
+ clearInterval(this.purgeTimer);
245
+ const pendingRefresh = this.refreshPromise;
246
+ const pendingPurge = this.purgePromise;
247
+ this.refreshController?.abort(new Error("Web service is stopping."));
248
+ const active = [...this.activeTurns.values()];
249
+ const trackedIds = new Set(active.map((turn) => turn.turnId));
250
+ for (const turnId of this.store.listActiveTurnIds()) {
251
+ if (!trackedIds.has(turnId))
252
+ this.store.interruptTurn(turnId);
253
+ }
254
+ for (const turn of active) {
255
+ this.store.interruptTurn(turn.turnId);
256
+ turn.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
257
+ }
258
+ await Promise.allSettled(active.map((turn) => turn.completion));
259
+ if (pendingRefresh !== undefined)
260
+ await pendingRefresh.catch(() => undefined);
261
+ if (pendingPurge !== undefined)
262
+ await pendingPurge.catch(() => undefined);
263
+ this.subscribers.clear();
264
+ this.store.close();
265
+ await this.lease.release();
266
+ }
267
+ async runTurn(started, client, controller) {
268
+ const coalescer = new StreamFrameCoalescer(async (frames) => {
269
+ const message = this.store.applyStreamFrames(started.turnId, frames);
270
+ this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
271
+ }, (error) => controller.abort(error));
272
+ let releaseAttachmentBudget;
273
+ try {
274
+ const attachmentBytes = started.attachments.reduce((total, attachment) => total + attachment.sizeBytes, 0);
275
+ releaseAttachmentBudget = await this.attachmentTurnBudget.acquire(attachmentBytes, controller.signal);
276
+ const attachments = await Promise.all(started.attachments.map(async (attachment) => this.toAgentAttachment(attachment)));
277
+ const modelMetadata = {
278
+ ...(started.thread.runState.model === undefined ? {} : { model: started.thread.runState.model }),
279
+ ...(started.thread.runState.effort === undefined ? {} : { effort: started.thread.runState.effort }),
280
+ };
281
+ const response = await client.turn({
282
+ conversationId: started.conversationId,
283
+ text: started.text,
284
+ attachments,
285
+ signal: controller.signal,
286
+ metadata: {
287
+ web: { threadId: started.thread.id, turnId: started.turnId, ...modelMetadata },
288
+ tui: modelMetadata,
289
+ },
290
+ onFrame: (frame) => coalescer.push(frame),
291
+ });
292
+ await coalescer.flush();
293
+ const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata);
294
+ this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
295
+ this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
296
+ this.emit("threads.changed", started.thread.id);
297
+ }
298
+ catch (error) {
299
+ let failure = error;
300
+ try {
301
+ await coalescer.flush();
302
+ }
303
+ catch (flushError) {
304
+ failure = flushError;
305
+ }
306
+ const cancelled = controller.signal.reason instanceof WebTurnCancellation
307
+ || error.cancelled === true;
308
+ const code = errorCode(failure);
309
+ const detail = this.store.failTurn(started.turnId, {
310
+ message: cancelled ? "Turn cancelled." : errorMessage(failure),
311
+ ...(code === undefined ? {} : { code }),
312
+ cancelled,
313
+ });
314
+ this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
315
+ this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
316
+ this.emit("threads.changed", started.thread.id);
317
+ }
318
+ finally {
319
+ releaseAttachmentBudget?.();
320
+ coalescer.close();
321
+ }
322
+ }
323
+ async toAgentAttachment(attachment) {
324
+ const bytes = await readFile(this.store.attachmentPath(attachment));
325
+ if (bytes.byteLength !== attachment.sizeBytes || bytes.byteLength > DEFAULT_AGENT_ATTACHMENT_MAX_BYTES) {
326
+ throw new WebConsoleError("attachment_integrity", `Attachment ${attachment.name} failed its size check.`, 409);
327
+ }
328
+ return {
329
+ kind: attachment.kind,
330
+ mimeType: attachment.contentType,
331
+ data: bytes.toString("base64"),
332
+ name: attachment.name,
333
+ sizeBytes: bytes.byteLength,
334
+ };
335
+ }
336
+ async refreshAgentsOnce(signal) {
337
+ const discover = this.options.discoverImpl ?? discoverOperatorAgents;
338
+ let discovered;
339
+ try {
340
+ discovered = await discover({
341
+ ...(this.options.registryDirs === undefined ? {} : { registryDirs: this.options.registryDirs }),
342
+ ...(this.options.staleAfterMs === undefined ? {} : { staleAfterMs: this.options.staleAfterMs }),
343
+ ...(this.options.env === undefined ? {} : { env: this.options.env }),
344
+ });
345
+ }
346
+ catch (error) {
347
+ this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
348
+ this.store.replaceAgents([]);
349
+ this.connections = new Map();
350
+ this.emit("agents.changed");
351
+ return;
352
+ }
353
+ const nextConnections = new Map();
354
+ const summaries = await Promise.all(discovered.map(async (agent) => {
355
+ if (agent.baseUrl === undefined)
356
+ return offlineSummary(agent);
357
+ const client = new OperatorClient({
358
+ baseUrl: agent.baseUrl,
359
+ ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
360
+ ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
361
+ });
362
+ try {
363
+ const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
364
+ nextConnections.set(agent.source.sourceId, { client, info });
365
+ const efforts = collectEfforts(info);
366
+ return {
367
+ sourceId: agent.source.sourceId,
368
+ label: info.label ?? agent.source.label,
369
+ status: agent.source.health === "running" ? "online" : "degraded",
370
+ pinned: false,
371
+ health: agent.source.health,
372
+ supportsAttachments: info.supportsAttachments,
373
+ ...(info.models === undefined ? {} : { models: info.models }),
374
+ ...(info.model === undefined ? {} : { defaultModel: info.model }),
375
+ ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
376
+ ...(efforts.length === 0 ? {} : { efforts }),
377
+ ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
378
+ updatedAt: agent.source.updatedAt,
379
+ };
380
+ }
381
+ catch (error) {
382
+ this.options.logger?.debug?.("Discovered agent operator probe failed.", {
383
+ sourceId: agent.source.sourceId,
384
+ error: errorMessage(error),
385
+ });
386
+ return offlineSummary(agent);
387
+ }
388
+ }));
389
+ this.connections = nextConnections;
390
+ this.store.replaceAgents(summaries);
391
+ this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
392
+ this.emit("threads.changed");
393
+ }
394
+ startTimers() {
395
+ const discoveryInterval = this.options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
396
+ const purgeInterval = this.options.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
397
+ if (discoveryInterval > 0) {
398
+ this.discoveryTimer = setInterval(() => {
399
+ void this.refreshAgents().catch((error) => {
400
+ this.options.logger?.warn?.("Scheduled web agent discovery failed.", { error: errorMessage(error) });
401
+ });
402
+ }, discoveryInterval);
403
+ this.discoveryTimer.unref();
404
+ }
405
+ if (purgeInterval > 0) {
406
+ this.purgeTimer = setInterval(() => {
407
+ void this.purgeOrphans().catch((error) => {
408
+ this.options.logger?.warn?.("Scheduled web upload purge failed.", { error: errorMessage(error) });
409
+ });
410
+ }, purgeInterval);
411
+ this.purgeTimer.unref();
412
+ }
413
+ }
414
+ purgeOrphans() {
415
+ if (this.purgePromise !== undefined)
416
+ return this.purgePromise;
417
+ this.purgePromise = this.purgeOrphansOnce().finally(() => {
418
+ this.purgePromise = undefined;
419
+ });
420
+ return this.purgePromise;
421
+ }
422
+ async purgeOrphansOnce() {
423
+ const before = new Date((this.options.clock?.() ?? new Date()).getTime() - WEB_STAGED_UPLOAD_TTL_MS).toISOString();
424
+ const partialCount = await this.store.purgePartialUploadFiles(before);
425
+ const count = await this.store.purgeStagedAttachments(before);
426
+ if (count > 0 || partialCount > 0) {
427
+ this.options.logger?.info?.("Purged orphaned web uploads.", { count, partialCount });
428
+ }
429
+ }
430
+ emit(type, threadId, payload) {
431
+ if (this.stopped)
432
+ return;
433
+ const event = this.createEvent(type, threadId, payload);
434
+ for (const subscriber of [...this.subscribers]) {
435
+ try {
436
+ if (subscriber(event) === false)
437
+ this.subscribers.delete(subscriber);
438
+ }
439
+ catch {
440
+ this.subscribers.delete(subscriber);
441
+ }
442
+ }
443
+ }
444
+ createEvent(type, threadId, payload) {
445
+ this.eventSequence += 1;
446
+ return {
447
+ id: `${Date.now()}-${this.eventSequence}`,
448
+ version: WEB_API_VERSION,
449
+ type,
450
+ at: (this.options.clock?.() ?? new Date()).toISOString(),
451
+ ...(threadId === undefined ? {} : { threadId }),
452
+ ...(payload === undefined ? {} : { payload }),
453
+ };
454
+ }
455
+ activeReservedUploadBytes() {
456
+ let total = 0;
457
+ for (const bytes of this.activeUploads.values())
458
+ total += bytes;
459
+ return total;
460
+ }
461
+ }
462
+ const STREAM_FLUSH_INTERVAL_MS = 50;
463
+ class WebTurnCancellation extends Error {
464
+ kind;
465
+ constructor(kind, message) {
466
+ super(message);
467
+ this.kind = kind;
468
+ this.name = "WebTurnCancellation";
469
+ }
470
+ }
471
+ class StreamFrameCoalescer {
472
+ persist;
473
+ onFailure;
474
+ pending = [];
475
+ timer;
476
+ tail = Promise.resolve();
477
+ failure;
478
+ closed = false;
479
+ constructor(persist, onFailure) {
480
+ this.persist = persist;
481
+ this.onFailure = onFailure;
482
+ }
483
+ push(frame) {
484
+ if (this.failure !== undefined)
485
+ throw this.failure;
486
+ if (this.closed)
487
+ return;
488
+ this.pending.push(frame);
489
+ if (this.timer === undefined) {
490
+ this.timer = setTimeout(() => {
491
+ this.timer = undefined;
492
+ void this.flush().catch((error) => {
493
+ this.failure = error;
494
+ this.onFailure(error);
495
+ });
496
+ }, STREAM_FLUSH_INTERVAL_MS);
497
+ this.timer.unref();
498
+ }
499
+ }
500
+ async flush() {
501
+ if (this.timer !== undefined) {
502
+ clearTimeout(this.timer);
503
+ this.timer = undefined;
504
+ }
505
+ const frames = this.pending;
506
+ this.pending = [];
507
+ if (frames.length > 0) {
508
+ this.tail = this.tail.then(async () => this.persist(frames));
509
+ }
510
+ await this.tail;
511
+ if (this.failure !== undefined)
512
+ throw this.failure;
513
+ }
514
+ close() {
515
+ this.closed = true;
516
+ if (this.timer !== undefined)
517
+ clearTimeout(this.timer);
518
+ this.timer = undefined;
519
+ }
520
+ }
521
+ /** FIFO weighted semaphore: zero-weight text turns bypass attachment memory pressure. */
522
+ export class WeightedTurnBudget {
523
+ capacity;
524
+ maxQueue;
525
+ used = 0;
526
+ queue = [];
527
+ constructor(capacity, maxQueue) {
528
+ this.capacity = capacity;
529
+ this.maxQueue = maxQueue;
530
+ if (!Number.isSafeInteger(capacity) || capacity <= 0 || !Number.isSafeInteger(maxQueue) || maxQueue < 0) {
531
+ throw new TypeError("Weighted turn budget requires positive capacity and a non-negative queue bound.");
532
+ }
533
+ }
534
+ acquire(weight, signal) {
535
+ if (!Number.isSafeInteger(weight) || weight < 0 || weight > this.capacity) {
536
+ return Promise.reject(new WebConsoleError("attachment_turn_capacity", "Attachment turn exceeds the active memory budget.", 429));
537
+ }
538
+ if (signal.aborted)
539
+ return Promise.reject(signal.reason ?? new Error("Turn cancelled."));
540
+ if (weight === 0)
541
+ return Promise.resolve(() => undefined);
542
+ if (this.queue.length === 0 && this.used + weight <= this.capacity) {
543
+ return Promise.resolve(this.grant(weight));
544
+ }
545
+ if (this.queue.length >= this.maxQueue) {
546
+ return Promise.reject(new WebConsoleError("attachment_turn_queue_full", "Too many attachment turns are waiting.", 429));
547
+ }
548
+ return new Promise((resolvePromise, reject) => {
549
+ const waiter = {
550
+ weight,
551
+ resolve: resolvePromise,
552
+ reject,
553
+ signal,
554
+ onAbort: () => {
555
+ const index = this.queue.indexOf(waiter);
556
+ if (index >= 0)
557
+ this.queue.splice(index, 1);
558
+ reject(signal.reason ?? new Error("Turn cancelled."));
559
+ this.drain();
560
+ },
561
+ };
562
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
563
+ this.queue.push(waiter);
564
+ });
565
+ }
566
+ grant(weight) {
567
+ this.used += weight;
568
+ let released = false;
569
+ return () => {
570
+ if (released)
571
+ return;
572
+ released = true;
573
+ this.used -= weight;
574
+ this.drain();
575
+ };
576
+ }
577
+ drain() {
578
+ for (;;) {
579
+ const next = this.queue[0];
580
+ if (next === undefined || this.used + next.weight > this.capacity)
581
+ return;
582
+ this.queue.shift();
583
+ next.signal.removeEventListener("abort", next.onAbort);
584
+ next.resolve(this.grant(next.weight));
585
+ }
586
+ }
587
+ }
588
+ function offlineSummary(agent) {
589
+ return {
590
+ sourceId: agent.source.sourceId,
591
+ label: agent.source.label,
592
+ status: "offline",
593
+ pinned: false,
594
+ health: agent.source.health,
595
+ supportsAttachments: false,
596
+ updatedAt: agent.source.updatedAt,
597
+ };
598
+ }
599
+ function collectEfforts(info) {
600
+ // Older operator schemas do not advertise per-model metadata. Match the TUI
601
+ // picker in that case: cloud/unknown models use the canonical global effort
602
+ // ladder rather than treating only the current default as selectable.
603
+ if (info.modelOptions === undefined)
604
+ return EFFORT_LEVELS;
605
+ const models = info.models ?? (info.model === undefined ? [] : [info.model]);
606
+ return [...new Set(models.flatMap((model) => effortLevelsForOption(info.modelOptions?.[model])))];
607
+ }
608
+ function validateModelAndEffort(agent, model, effort) {
609
+ if (model !== undefined
610
+ && (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
611
+ throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
612
+ }
613
+ const effectiveModel = model ?? agent.defaultModel;
614
+ const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
615
+ const allowedEfforts = option === undefined ? agent.efforts : effortLevelsForOption(option);
616
+ if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
617
+ throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
618
+ }
619
+ }
620
+ function effortLevelsForOption(option) {
621
+ if (option !== undefined
622
+ && (option.reasoning === false || option.reasoningMode === "none" || option.effortLevels?.length === 0)) {
623
+ return [];
624
+ }
625
+ if (option?.reasoningMode === "toggle")
626
+ return ["high", "none"];
627
+ return option?.effortLevels ?? EFFORT_LEVELS;
628
+ }
629
+ function normalizeFilename(value) {
630
+ const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";
631
+ const normalized = withoutPath.replace(/[\u0000-\u001f\u007f]/gu, "").slice(0, 255);
632
+ if (normalized.length === 0 || normalized === "." || normalized === "..") {
633
+ throw new WebConsoleError("invalid_attachment_name", "Attachment filename is invalid.", 400);
634
+ }
635
+ return normalized;
636
+ }
637
+ function normalizeMime(value) {
638
+ const normalized = value.trim().toLowerCase();
639
+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(normalized)) {
640
+ throw new WebConsoleError("invalid_attachment_type", "Attachment MIME type is invalid.", 400);
641
+ }
642
+ return normalized;
643
+ }
644
+ //# sourceMappingURL=service.js.map