@love-moon/chat-web 0.17.1 → 0.18.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 (98) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +176 -0
  3. package/dist/cli.js +8 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/login.js +11 -2
  6. package/dist/commands/login.js.map +1 -1
  7. package/dist/core/broker-protocol.d.ts +13 -0
  8. package/dist/core/broker-protocol.js +29 -0
  9. package/dist/core/broker-protocol.js.map +1 -0
  10. package/dist/core/broker-worker.d.ts +1 -0
  11. package/dist/core/broker-worker.js +105 -0
  12. package/dist/core/broker-worker.js.map +1 -0
  13. package/dist/core/errors.d.ts +1 -1
  14. package/dist/core/errors.js.map +1 -1
  15. package/dist/core/provider.d.ts +4 -0
  16. package/dist/core/provider.js.map +1 -1
  17. package/dist/core/turn-broker.d.ts +12 -0
  18. package/dist/core/turn-broker.js +331 -0
  19. package/dist/core/turn-broker.js.map +1 -0
  20. package/dist/core/turn-queue.d.ts +12 -0
  21. package/dist/core/turn-queue.js +54 -0
  22. package/dist/core/turn-queue.js.map +1 -0
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/providers/chatgpt-sse-collector.d.ts +2 -0
  27. package/dist/providers/chatgpt-sse-collector.js +4 -0
  28. package/dist/providers/chatgpt-sse-collector.js.map +1 -1
  29. package/dist/providers/chatgpt.d.ts +1 -0
  30. package/dist/providers/chatgpt.js +6 -0
  31. package/dist/providers/chatgpt.js.map +1 -1
  32. package/dist/providers/gemini.d.ts +1 -0
  33. package/dist/providers/gemini.js +5 -0
  34. package/dist/providers/gemini.js.map +1 -1
  35. package/dist/providers/index.d.ts +1 -0
  36. package/dist/providers/index.js +3 -0
  37. package/dist/providers/index.js.map +1 -1
  38. package/dist/providers/kimi.d.ts +38 -0
  39. package/dist/providers/kimi.js +270 -0
  40. package/dist/providers/kimi.js.map +1 -0
  41. package/dist/queued-session.d.ts +31 -0
  42. package/dist/queued-session.js +224 -0
  43. package/dist/queued-session.js.map +1 -0
  44. package/dist/runtime/accounts.d.ts +13 -0
  45. package/dist/runtime/accounts.js +86 -0
  46. package/dist/runtime/accounts.js.map +1 -0
  47. package/dist/runtime/archive.d.ts +9 -0
  48. package/dist/runtime/archive.js +54 -0
  49. package/dist/runtime/archive.js.map +1 -0
  50. package/dist/runtime/browser-worker.d.ts +14 -0
  51. package/dist/runtime/browser-worker.js +87 -0
  52. package/dist/runtime/browser-worker.js.map +1 -0
  53. package/dist/runtime/cli.d.ts +1 -0
  54. package/dist/runtime/cli.js +141 -0
  55. package/dist/runtime/cli.js.map +1 -0
  56. package/dist/runtime/client.d.ts +84 -0
  57. package/dist/runtime/client.js +258 -0
  58. package/dist/runtime/client.js.map +1 -0
  59. package/dist/runtime/daemon.d.ts +1 -0
  60. package/dist/runtime/daemon.js +33 -0
  61. package/dist/runtime/daemon.js.map +1 -0
  62. package/dist/runtime/engine.d.ts +105 -0
  63. package/dist/runtime/engine.js +605 -0
  64. package/dist/runtime/engine.js.map +1 -0
  65. package/dist/runtime/index.d.ts +7 -0
  66. package/dist/runtime/index.js +8 -0
  67. package/dist/runtime/index.js.map +1 -0
  68. package/dist/runtime/managed.d.ts +7 -0
  69. package/dist/runtime/managed.js +75 -0
  70. package/dist/runtime/managed.js.map +1 -0
  71. package/dist/runtime/process-env.d.ts +2 -0
  72. package/dist/runtime/process-env.js +5 -0
  73. package/dist/runtime/process-env.js.map +1 -0
  74. package/dist/runtime/retention.d.ts +15 -0
  75. package/dist/runtime/retention.js +62 -0
  76. package/dist/runtime/retention.js.map +1 -0
  77. package/dist/runtime/server.d.ts +12 -0
  78. package/dist/runtime/server.js +246 -0
  79. package/dist/runtime/server.js.map +1 -0
  80. package/dist/runtime/sites.d.ts +2 -0
  81. package/dist/runtime/sites.js +40 -0
  82. package/dist/runtime/sites.js.map +1 -0
  83. package/dist/runtime/store.d.ts +17 -0
  84. package/dist/runtime/store.js +61 -0
  85. package/dist/runtime/store.js.map +1 -0
  86. package/dist/runtime/stream-decoder.d.ts +30 -0
  87. package/dist/runtime/stream-decoder.js +227 -0
  88. package/dist/runtime/stream-decoder.js.map +1 -0
  89. package/dist/runtime/types.d.ts +102 -0
  90. package/dist/runtime/types.js +12 -0
  91. package/dist/runtime/types.js.map +1 -0
  92. package/dist/runtime/worker.d.ts +1 -0
  93. package/dist/runtime/worker.js +422 -0
  94. package/dist/runtime/worker.js.map +1 -0
  95. package/dist/session.d.ts +5 -0
  96. package/dist/session.js +14 -3
  97. package/dist/session.js.map +1 -1
  98. package/package.json +2 -2
@@ -0,0 +1,605 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import fs from "node:fs/promises";
5
+ import { pruneRuntimeState, retentionPolicy } from "./retention.js";
6
+ import { ConversationArchive } from "./archive.js";
7
+ import { BrowserWorker } from "./browser-worker.js";
8
+ import { RuntimeStore } from "./store.js";
9
+ import { runtimeSites } from "./sites.js";
10
+ import { isTerminal, RuntimeError } from "./types.js";
11
+ export class ChatRuntime {
12
+ store;
13
+ options;
14
+ events = new EventEmitter();
15
+ workers = new Map();
16
+ busy = new Set();
17
+ closing = new Set();
18
+ active = new Map();
19
+ allocation = Promise.resolve();
20
+ stopping = false;
21
+ failure;
22
+ scheduled = false;
23
+ archive;
24
+ maxWorkers;
25
+ perAccountConcurrency;
26
+ sites;
27
+ constructor(store, options) {
28
+ this.store = store;
29
+ this.options = options;
30
+ this.archive = new ConversationArchive(options.directory);
31
+ this.maxWorkers = options.maxWorkers ?? 4;
32
+ this.perAccountConcurrency = options.perAccountConcurrency ?? 2;
33
+ for (const value of [this.maxWorkers, this.perAccountConcurrency]) {
34
+ if (!Number.isInteger(value) || value < 1 || value > 64)
35
+ throw new RuntimeError("INVALID_PARAMS", "Concurrency must be an integer from 1 to 64");
36
+ }
37
+ this.sites = options.sites ?? runtimeSites;
38
+ this.events.setMaxListeners(0);
39
+ }
40
+ static async open(options) {
41
+ const runtime = new ChatRuntime(await RuntimeStore.open(options.directory), options);
42
+ await runtime.store.update((state) => {
43
+ for (const chat of Object.values(state.conversations)) {
44
+ if (chat.closed && !chat.closedAt)
45
+ chat.closedAt = new Date().toISOString();
46
+ chat.lastActivityAt ??= chat.createdAt;
47
+ }
48
+ for (const turn of Object.values(state.turns)) {
49
+ const chat = state.conversations[turn.conversationId];
50
+ if (turn.status === "completed")
51
+ chat.hasCompletedTurn = true;
52
+ const activity = turn.events.at(-1)?.at ?? turn.createdAt;
53
+ if (Date.parse(activity) > Date.parse(chat.lastActivityAt ?? chat.createdAt))
54
+ chat.lastActivityAt = activity;
55
+ if (turn.status === "queued" && runtime.queueExpired(turn)) {
56
+ turn.status = "cancelled";
57
+ turn.error = "Queued turn expired before submission";
58
+ turn.errorCode = "QUEUE_EXPIRED";
59
+ runtime.append(turn, "cancelled", { error: turn.error, errorCode: turn.errorCode });
60
+ }
61
+ if (!isTerminal(turn.status) && turn.status !== "queued") {
62
+ turn.status = "uncertain";
63
+ turn.error = "Runtime restarted while the turn was active; inspect before sending again";
64
+ state.conversations[turn.conversationId].needsReview = true;
65
+ runtime.append(turn, "uncertain", { error: turn.error });
66
+ }
67
+ }
68
+ });
69
+ await runtime.collect();
70
+ await runtime.sweepProfiles();
71
+ runtime.schedule();
72
+ return runtime;
73
+ }
74
+ conversation(state, id) {
75
+ const conversation = Object.hasOwn(state.conversations, id) ? state.conversations[id] : undefined;
76
+ if (!conversation)
77
+ throw new RuntimeError("NOT_FOUND", "Conversation not found");
78
+ return conversation;
79
+ }
80
+ turn(state, id) {
81
+ const turn = Object.hasOwn(state.turns, id) ? state.turns[id] : undefined;
82
+ if (!turn)
83
+ throw new RuntimeError("NOT_FOUND", "Turn not found");
84
+ return turn;
85
+ }
86
+ list() { return this.store.conversations(); }
87
+ get(id) {
88
+ const chat = this.store.conversation(id) ?? this.archive.read(id);
89
+ if (!chat || !Object.hasOwn(chat, "id"))
90
+ throw new RuntimeError("NOT_FOUND", "Conversation not found");
91
+ return chat;
92
+ }
93
+ getTurn(id) {
94
+ const turn = this.store.turn(id);
95
+ if (!turn || !Object.hasOwn(turn, "id"))
96
+ throw new RuntimeError("NOT_FOUND", "Turn not found");
97
+ return turn;
98
+ }
99
+ status() {
100
+ return { version: 1, capabilities: { deepThink: true, accountBootstrap: true, queuedStartup: true, durableResume: true }, maxWorkers: this.maxWorkers, perAccountConcurrency: this.perAccountConcurrency,
101
+ workers: this.workers.size, activeTurns: this.active.size, stopping: this.stopping, failure: this.failure,
102
+ pinnedChats: [...this.workers].filter(([, entry]) => entry.pinned).map(([id]) => id),
103
+ retention: retentionPolicy,
104
+ providers: this.sites.map(({ name, experimental }) => ({ name, experimental: !!experimental })) };
105
+ }
106
+ async create(params) {
107
+ if (this.stopping)
108
+ throw new RuntimeError("SHUTTING_DOWN", "Runtime is shutting down");
109
+ if (!this.sites.some((site) => site.name === params.provider))
110
+ throw new RuntimeError("UNSUPPORTED_PROVIDER", "Unsupported webpage provider");
111
+ this.validateDeepThink(params.provider, params.deepThink);
112
+ const site = this.sites.find(entry => entry.name === params.provider);
113
+ if (params.providerConversationId !== undefined && (!site.conversationUrl || !/^[a-zA-Z0-9_-]{1,160}$/.test(params.providerConversationId))) {
114
+ throw new RuntimeError("INVALID_PARAMS", "Invalid provider conversation id");
115
+ }
116
+ const accountId = params.accountId ?? "default";
117
+ if (typeof accountId !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(accountId))
118
+ throw new RuntimeError("INVALID_PARAMS", "Invalid accountId");
119
+ if (params.headless !== undefined && typeof params.headless !== "boolean")
120
+ throw new RuntimeError("INVALID_PARAMS", "headless must be boolean");
121
+ const chat = await this.updateRetained((state, removed) => {
122
+ if (params.providerConversationId) {
123
+ const existing = Object.values(state.conversations).find(chat => chat.provider === params.provider &&
124
+ chat.accountId === accountId && chat.providerConversationId === params.providerConversationId);
125
+ if (existing)
126
+ return existing;
127
+ }
128
+ this.prune(state, removed, { conversations: 1 });
129
+ if (Object.keys(state.conversations).length >= retentionPolicy.maxConversations)
130
+ throw new RuntimeError("CAPACITY", "Conversation limit reached");
131
+ const record = { id: randomUUID(), provider: params.provider, accountId,
132
+ headless: params.headless ?? true, ...(params.deepThink === undefined ? {} : { deepThink: params.deepThink }), createdAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), needsReview: false, closed: false,
133
+ ...(params.providerConversationId ? { providerConversationId: params.providerConversationId, url: site.conversationUrl + encodeURIComponent(params.providerConversationId) } : {}) };
134
+ state.conversations[record.id] = record;
135
+ return record;
136
+ });
137
+ return chat;
138
+ }
139
+ async resume(id, params = {}) {
140
+ if (this.stopping)
141
+ throw new RuntimeError("SHUTTING_DOWN", "Runtime is shutting down");
142
+ if ([params.headed, params.acknowledgeUncertain, params.allowQueued].some((value) => value !== undefined && typeof value !== "boolean"))
143
+ throw new RuntimeError("INVALID_PARAMS", "Resume options must be boolean");
144
+ this.get(id);
145
+ const unavailable = !this.workers.has(id) && this.workers.size >= this.maxWorkers &&
146
+ ![...this.workers.keys()].some(other => this.canEvict(other));
147
+ if (params.allowQueued && params.headed === undefined && !params.acknowledgeUncertain && !this.closing.has(id) &&
148
+ (this.busy.has(id) || this.busy.size >= this.maxWorkers || unavailable)) {
149
+ const chat = await this.updateRetained((state, removed) => {
150
+ const record = this.restore(state, id, removed);
151
+ record.closed = false;
152
+ delete record.closedAt;
153
+ record.lastActivityAt = new Date().toISOString();
154
+ return record;
155
+ });
156
+ return { ...chat, queued: true };
157
+ }
158
+ if (this.busy.has(id) || this.closing.has(id))
159
+ throw new RuntimeError("CHAT_BUSY", "Conversation has an active operation");
160
+ if (this.busy.size >= this.maxWorkers)
161
+ throw new RuntimeError("CAPACITY", "All browser workers are busy");
162
+ this.busy.add(id);
163
+ try {
164
+ const conversation = await this.updateRetained((state, removed) => {
165
+ const chat = this.restore(state, id, removed);
166
+ if (params.headed !== undefined)
167
+ chat.headless = !params.headed;
168
+ if (params.acknowledgeUncertain)
169
+ chat.needsReview = false;
170
+ chat.closed = false;
171
+ delete chat.closedAt;
172
+ chat.lastActivityAt = new Date().toISOString();
173
+ return chat;
174
+ });
175
+ const worker = await this.acquire(conversation);
176
+ const result = await worker.open();
177
+ const entry = this.workers.get(id);
178
+ if (this.closing.has(id) || this.get(id).closed || entry?.worker !== worker) {
179
+ throw new RuntimeError("CHAT_CLOSED", "Conversation was closed while its browser was opening; resume it again");
180
+ }
181
+ if (params.headed === true)
182
+ entry.pinned = true;
183
+ await this.remember(id, result);
184
+ return { ...this.get(id), ...result };
185
+ }
186
+ finally {
187
+ this.busy.delete(id);
188
+ this.schedule();
189
+ }
190
+ }
191
+ async closeConversation(id) {
192
+ const saved = this.get(id);
193
+ if (saved.archived) {
194
+ saved.closed = true;
195
+ saved.closedAt = new Date().toISOString();
196
+ await this.archive.save(saved);
197
+ return saved;
198
+ }
199
+ if (this.closing.has(id))
200
+ throw new RuntimeError("CHAT_BUSY", "Conversation is closing");
201
+ this.closing.add(id);
202
+ try {
203
+ await this.store.update((state) => {
204
+ const chat = this.conversation(state, id);
205
+ chat.closed = true;
206
+ chat.closedAt = new Date().toISOString();
207
+ chat.lastActivityAt = chat.closedAt;
208
+ });
209
+ const turns = Object.values(this.store.read().turns).filter(turn => turn.conversationId === id && !isTerminal(turn.status));
210
+ for (const turn of turns)
211
+ await this.cancel(turn.id);
212
+ const settling = turns.map(turn => this.active.get(turn.id)).filter(Boolean);
213
+ if (settling.length)
214
+ await new Promise(resolve => {
215
+ const timer = setTimeout(resolve, 3000);
216
+ void Promise.allSettled(settling).then(() => { clearTimeout(timer); resolve(); });
217
+ });
218
+ await this.drop(id);
219
+ return this.get(id);
220
+ }
221
+ finally {
222
+ this.closing.delete(id);
223
+ await this.collect();
224
+ this.schedule();
225
+ }
226
+ }
227
+ async submit(params) {
228
+ if (this.stopping)
229
+ throw new RuntimeError("SHUTTING_DOWN", "Runtime is shutting down");
230
+ if (typeof params.input !== "string" || !params.input.trim() || params.input.length > 100_000 ||
231
+ typeof params.requestId !== "string" || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.requestId)) {
232
+ throw new RuntimeError("INVALID_PARAMS", "A nonempty input and stable requestId are required");
233
+ }
234
+ const timeoutMs = params.timeoutMs ?? 180_000;
235
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 1_800_000)
236
+ throw new RuntimeError("INVALID_PARAMS", "Invalid timeoutMs");
237
+ const turn = await this.updateRetained((state, removed) => {
238
+ const chat = this.restore(state, params.conversationId, removed);
239
+ this.validateDeepThink(chat.provider, params.deepThink);
240
+ const deepThink = params.deepThink ?? chat.deepThink;
241
+ this.validateDeepThink(chat.provider, deepThink);
242
+ const existing = Object.values(state.turns).find((turn) => turn.conversationId === params.conversationId && turn.requestId === params.requestId);
243
+ if (existing) {
244
+ if (existing.input !== params.input || existing.timeoutMs !== timeoutMs || existing.deepThink !== deepThink)
245
+ throw new RuntimeError("REQUEST_CONFLICT", "requestId was already used with different input, timeout or DeepThink setting");
246
+ return existing;
247
+ }
248
+ if (chat.closed)
249
+ throw new RuntimeError("CHAT_CLOSED", "Resume the chat before sending");
250
+ if (chat.needsReview)
251
+ throw new RuntimeError("REVIEW_REQUIRED", "Inspect the uncertain turn and explicitly acknowledge it before sending again");
252
+ chat.lastActivityAt = new Date().toISOString();
253
+ this.prune(state, removed, { turns: 1 });
254
+ if (Object.keys(state.turns).length >= retentionPolicy.maxTurns)
255
+ throw new RuntimeError("CAPACITY", "Turn retention limit reached");
256
+ const turn = { id: randomUUID(), conversationId: chat.id, requestId: params.requestId,
257
+ input: params.input, timeoutMs, ...(deepThink === undefined ? {} : { deepThink }), status: "queued", text: "", events: [], createdAt: new Date().toISOString() };
258
+ this.append(turn, "queued");
259
+ state.turns[turn.id] = turn;
260
+ return turn;
261
+ });
262
+ this.schedule();
263
+ return turn;
264
+ }
265
+ queueExpired(turn) {
266
+ const created = Date.parse(turn.createdAt);
267
+ return !Number.isFinite(created) || Date.now() - created >= turn.timeoutMs;
268
+ }
269
+ async updateRetained(change) {
270
+ const removed = [];
271
+ let cleanup;
272
+ const result = await this.store.update(state => change(state, removed), true, {
273
+ // Validate the entire mutation before writing identities. Saving these
274
+ // non-destructive records before the checkpoint makes GC crash-safe.
275
+ beforeCommit: async () => { for (const chat of removed)
276
+ await this.archive.save(chat); },
277
+ afterCommit: () => {
278
+ if (!removed.length)
279
+ return;
280
+ // Reserve cleanup before the store queue releases the next mutation.
281
+ // Restoring an ID must wait before opening its replacement profile.
282
+ cleanup = this.allocation.then(() => this.removeProfiles(removed.map(chat => chat.id)));
283
+ this.allocation = cleanup.catch(() => undefined);
284
+ },
285
+ });
286
+ await cleanup;
287
+ return result;
288
+ }
289
+ prune(state, removed, reserve = {}) {
290
+ const chats = new Set([...this.busy, ...this.closing, ...this.workers.keys()]);
291
+ const turns = new Set([...this.active.keys(), ...Object.keys(state.turns).filter(id => this.events.listenerCount(id) > 0)]);
292
+ for (const id of turns)
293
+ if (state.turns[id])
294
+ chats.add(state.turns[id].conversationId);
295
+ removed.push(...pruneRuntimeState(state, chats, turns, reserve));
296
+ }
297
+ restore(state, id, removed) {
298
+ if (Object.hasOwn(state.conversations, id))
299
+ return state.conversations[id];
300
+ const archived = this.archive.read(id);
301
+ if (!archived)
302
+ throw new RuntimeError("NOT_FOUND", "Conversation not found");
303
+ if (!this.sites.some(site => site.name === archived.provider))
304
+ throw new RuntimeError("UNSUPPORTED_PROVIDER", "Saved provider is unavailable");
305
+ this.prune(state, removed, { conversations: 1 });
306
+ if (Object.keys(state.conversations).length >= retentionPolicy.maxConversations)
307
+ throw new RuntimeError("CAPACITY", "Conversation limit reached");
308
+ const { archived: _archived, ...chat } = archived;
309
+ chat.lastActivityAt = new Date().toISOString();
310
+ state.conversations[id] = chat;
311
+ return chat;
312
+ }
313
+ async removeProfiles(ids) {
314
+ for (const id of ids)
315
+ if (/^[a-zA-Z0-9_-]{1,160}$/.test(id)) {
316
+ await fs.rm(path.join(this.options.directory, "profiles", id), { recursive: true, force: true })
317
+ .catch(error => console.warn("Chat profile cleanup will retry after restart:", id, error.code));
318
+ }
319
+ }
320
+ async collect() {
321
+ await this.updateRetained((state, removed) => this.prune(state, removed));
322
+ }
323
+ async sweepProfiles() {
324
+ const profiles = path.join(this.options.directory, "profiles");
325
+ const entries = await fs.readdir(profiles).catch(error => {
326
+ if (error.code !== "ENOENT")
327
+ throw error;
328
+ return [];
329
+ });
330
+ const live = new Set(this.list().map(chat => chat.id));
331
+ await this.removeProfiles(entries.filter(id => !live.has(id)));
332
+ }
333
+ validateDeepThink(provider, value) {
334
+ if (value === undefined)
335
+ return;
336
+ if (typeof value !== "boolean")
337
+ throw new RuntimeError("INVALID_PARAMS", "deepThink must be boolean");
338
+ if (provider !== "deepseek")
339
+ throw new RuntimeError("INVALID_PARAMS", "DeepThink is only supported on DeepSeek");
340
+ }
341
+ async cancel(id) {
342
+ const wasStarting = this.getTurn(id).status === "starting";
343
+ const turn = await this.store.update((state) => {
344
+ const turn = this.turn(state, id);
345
+ if (isTerminal(turn.status) || turn.status === "cancelling")
346
+ return turn;
347
+ turn.status = turn.status === "queued" ? "cancelled" : "cancelling";
348
+ this.append(turn, turn.status);
349
+ return turn;
350
+ });
351
+ this.publish(turn.events.at(-1));
352
+ if (turn.status === "cancelling") {
353
+ this.workers.get(turn.conversationId)?.worker.cancel();
354
+ if (wasStarting)
355
+ await this.drop(turn.conversationId);
356
+ }
357
+ this.schedule();
358
+ return turn;
359
+ }
360
+ history(id, after = 0) {
361
+ if (!Number.isInteger(after) || after < 0)
362
+ throw new RuntimeError("INVALID_PARAMS", "after must be a nonnegative sequence");
363
+ return this.getTurn(id).events.filter((event) => event.seq > after);
364
+ }
365
+ subscribe(id, listener) {
366
+ this.getTurn(id);
367
+ this.events.on(id, listener);
368
+ return () => this.events.off(id, listener);
369
+ }
370
+ publish(event) { this.events.emit(event.turnId, event); }
371
+ append(turn, type, extra = {}) {
372
+ const event = { seq: (turn.events.at(-1)?.seq ?? 0) + 1, turnId: turn.id, type, at: new Date().toISOString(), ...extra };
373
+ // Replacements supersede earlier text; long delta streams periodically
374
+ // collapse to a full snapshot. Published sequence numbers never change,
375
+ // so disconnected clients can resume from any earlier cursor.
376
+ if (type === "message_snapshot" || (type === "message_delta" && turn.events.length >= 1024)) {
377
+ turn.events = turn.events.filter(item => item.type !== "message_delta" && item.type !== "message_snapshot");
378
+ event.type = "message_snapshot";
379
+ event.text = turn.text;
380
+ }
381
+ turn.events.push(event);
382
+ return event;
383
+ }
384
+ async remember(id, result) {
385
+ await this.store.update((state) => {
386
+ const chat = this.conversation(state, id);
387
+ if (result.url)
388
+ chat.url = result.url;
389
+ if (result.providerConversationId)
390
+ chat.providerConversationId = result.providerConversationId;
391
+ });
392
+ }
393
+ schedule() {
394
+ if (this.scheduled || this.stopping)
395
+ return;
396
+ this.scheduled = true;
397
+ queueMicrotask(() => {
398
+ this.scheduled = false;
399
+ const state = this.store.read();
400
+ for (const turn of Object.values(state.turns)) {
401
+ if (this.busy.size >= this.maxWorkers)
402
+ break;
403
+ const chat = state.conversations[turn.conversationId];
404
+ if (turn.status !== "queued" || this.busy.has(chat.id) || chat.closed || chat.needsReview)
405
+ continue;
406
+ if (!this.workers.has(chat.id) && this.workers.size >= this.maxWorkers && ![...this.workers.keys()].some((id) => this.canEvict(id)))
407
+ continue;
408
+ const sameAccount = [...this.active.keys()].filter((id) => {
409
+ const other = state.conversations[state.turns[id].conversationId];
410
+ return other.provider === chat.provider && other.accountId === chat.accountId;
411
+ }).length;
412
+ if (sameAccount >= this.perAccountConcurrency)
413
+ continue;
414
+ this.busy.add(chat.id);
415
+ const job = Promise.resolve().then(() => this.run(turn.id)).catch(async (error) => {
416
+ // Stop accepting work if state cannot be persisted. Leave durable state for reconciliation.
417
+ this.failure = String(error.message ?? error);
418
+ this.stopping = true;
419
+ await Promise.allSettled([...this.workers.keys()].map((id) => this.drop(id)));
420
+ }).finally(() => {
421
+ this.active.delete(turn.id);
422
+ this.busy.delete(chat.id);
423
+ this.schedule();
424
+ });
425
+ this.active.set(turn.id, job);
426
+ }
427
+ });
428
+ }
429
+ canEvict(id) {
430
+ if (this.busy.has(id) || this.closing.has(id) || this.workers.get(id)?.pinned)
431
+ return false;
432
+ const chat = this.get(id);
433
+ return !!chat.url || !chat.hasCompletedTurn;
434
+ }
435
+ async acquire(conversation) {
436
+ const run = this.allocation.then(async () => {
437
+ if (this.stopping)
438
+ throw new RuntimeError("SHUTTING_DOWN", "Runtime is shutting down");
439
+ let existing = this.workers.get(conversation.id);
440
+ if (existing && existing.headless !== conversation.headless) {
441
+ await this.drop(conversation.id);
442
+ existing = undefined;
443
+ }
444
+ if (existing) {
445
+ existing.used = Date.now();
446
+ return existing.worker;
447
+ }
448
+ if (this.workers.size >= this.maxWorkers) {
449
+ const idle = [...this.workers].filter(([id]) => this.canEvict(id)).sort(([, a], [, b]) => a.used - b.used)[0];
450
+ if (!idle)
451
+ throw new RuntimeError("CAPACITY", "All browser workers are busy or holding a login window");
452
+ await this.drop(idle[0]);
453
+ }
454
+ if (!conversation.url && conversation.hasCompletedTurn) {
455
+ throw new RuntimeError("RESUME_UNSUPPORTED", "This chat has no saved conversation URL; refusing to open a fresh conversation");
456
+ }
457
+ if (this.stopping)
458
+ throw new RuntimeError("SHUTTING_DOWN", "Runtime is shutting down");
459
+ const worker = (this.options.workerFactory ?? ((config) => new BrowserWorker(config)))({
460
+ conversation, profileDir: path.join(this.options.directory, "profiles", conversation.id),
461
+ authDirectory: path.join(this.options.directory, "accounts"),
462
+ site: this.sites.find((site) => site.name === conversation.provider),
463
+ executablePath: this.options.executablePath, channel: this.options.channel,
464
+ });
465
+ this.workers.set(conversation.id, { worker, used: Date.now(), headless: conversation.headless, pinned: false });
466
+ return worker;
467
+ });
468
+ this.allocation = run.catch(() => undefined);
469
+ return run;
470
+ }
471
+ async drop(id) {
472
+ const entry = this.workers.get(id);
473
+ this.workers.delete(id);
474
+ await entry?.worker.close();
475
+ }
476
+ async notice(id, notice) {
477
+ const durable = notice.type === "submitting" || !["streaming", "cancelling"].includes(this.store.turnStatus(id) ?? "");
478
+ const event = await this.store.update((state) => {
479
+ const turn = this.turn(state, id);
480
+ if (isTerminal(turn.status))
481
+ return undefined;
482
+ const text = notice.text ?? "";
483
+ if (text.length > 1_000_000)
484
+ throw new RuntimeError("RESPONSE_LIMIT", "Reply exceeds limit");
485
+ const chat = this.conversation(state, turn.conversationId);
486
+ if (notice.url)
487
+ chat.url = notice.url;
488
+ if (notice.providerConversationId)
489
+ chat.providerConversationId = notice.providerConversationId;
490
+ if (notice.type === "submitting") {
491
+ if (turn.status !== "cancelling")
492
+ turn.status = "submitting";
493
+ return this.append(turn, "submitting");
494
+ }
495
+ if (text === turn.text)
496
+ return undefined;
497
+ const append = text.startsWith(turn.text);
498
+ const delta = append ? text.slice(turn.text.length) : text;
499
+ turn.text = text;
500
+ if (turn.status !== "cancelling")
501
+ turn.status = "streaming";
502
+ return this.append(turn, append ? "message_delta" : "message_snapshot", { text: delta });
503
+ }, durable);
504
+ if (event)
505
+ this.publish(event);
506
+ }
507
+ async finish(id, status, result = {}, error, errorCode) {
508
+ const event = await this.store.update((state) => {
509
+ const turn = this.turn(state, id);
510
+ if (isTerminal(turn.status))
511
+ return undefined;
512
+ turn.status = status;
513
+ if (result.text !== undefined)
514
+ turn.text = result.text;
515
+ turn.error = error;
516
+ turn.errorCode = errorCode;
517
+ turn.stopConfirmed = result.stopConfirmed;
518
+ turn.source = result.source;
519
+ const chat = this.conversation(state, turn.conversationId);
520
+ if (result.url)
521
+ chat.url = result.url;
522
+ if (result.providerConversationId)
523
+ chat.providerConversationId = result.providerConversationId;
524
+ if (status === "uncertain")
525
+ chat.needsReview = true;
526
+ if (status === "completed")
527
+ chat.hasCompletedTurn = true;
528
+ chat.lastActivityAt = new Date().toISOString();
529
+ return this.append(turn, status, { text: turn.text, error, errorCode, stopConfirmed: result.stopConfirmed, source: result.source });
530
+ });
531
+ if (event)
532
+ this.publish(event);
533
+ }
534
+ async run(id) {
535
+ let attempted = false;
536
+ let pending = Promise.resolve();
537
+ const original = this.getTurn(id);
538
+ try {
539
+ const event = await this.store.update((state) => {
540
+ const turn = this.turn(state, id);
541
+ if (turn.status !== "queued")
542
+ return undefined;
543
+ if (this.queueExpired(turn)) {
544
+ turn.status = "cancelled";
545
+ turn.error = "Queued turn expired before submission";
546
+ turn.errorCode = "QUEUE_EXPIRED";
547
+ return this.append(turn, "cancelled", { error: turn.error, errorCode: turn.errorCode });
548
+ }
549
+ turn.status = "starting";
550
+ return this.append(turn, "starting");
551
+ });
552
+ if (!event)
553
+ return;
554
+ this.publish(event);
555
+ if (event.type !== "starting")
556
+ return;
557
+ const chat = this.get(original.conversationId);
558
+ const worker = await this.acquire(chat);
559
+ if (this.getTurn(id).status === "cancelling") {
560
+ await this.finish(id, "cancelled");
561
+ return;
562
+ }
563
+ const opened = await worker.open();
564
+ if (opened.ready === false)
565
+ throw new RuntimeError(opened.authRequired ? "AUTH_REQUIRED" : "PAGE_NOT_READY", opened.authRequired ? "Chat requires login before sending" : "Chat page did not become ready; wait for it to finish loading and retry");
566
+ await this.remember(chat.id, opened);
567
+ if (this.getTurn(id).status === "cancelling") {
568
+ await this.finish(id, "cancelled");
569
+ return;
570
+ }
571
+ this.workers.get(chat.id).pinned = false;
572
+ const result = await worker.send(original.input, original.timeoutMs, (notice) => {
573
+ if (notice.type === "submitting")
574
+ attempted = true;
575
+ pending = pending.then(() => this.notice(id, notice));
576
+ void pending.catch(() => worker.cancel());
577
+ }, { deepThink: original.deepThink });
578
+ await pending;
579
+ await this.finish(id, "completed", result);
580
+ }
581
+ catch (error) {
582
+ await pending.catch(() => undefined);
583
+ const cancelled = this.getTurn(id).status === "cancelling";
584
+ const status = error.code === "CANCELLED" && error.stopConfirmed ? "cancelled" :
585
+ cancelled && !attempted ? "cancelled" : attempted ? "uncertain" : "failed";
586
+ await this.finish(id, status, { stopConfirmed: error.stopConfirmed }, String(error.message ?? error).slice(0, 1000), error.code);
587
+ await this.drop(original.conversationId);
588
+ }
589
+ }
590
+ async shutdown() {
591
+ this.stopping = true;
592
+ try {
593
+ for (const turn of Object.values(this.store.read().turns)) {
594
+ if (!isTerminal(turn.status))
595
+ await this.finish(turn.id, turn.status === "queued" ? "cancelled" : "uncertain", {}, "Runtime is shutting down");
596
+ }
597
+ }
598
+ finally {
599
+ await this.allocation.catch(() => undefined);
600
+ await Promise.allSettled([...this.workers.keys()].map((id) => this.drop(id)));
601
+ await Promise.allSettled([...this.active.values()]);
602
+ }
603
+ }
604
+ }
605
+ //# sourceMappingURL=engine.js.map