toolroll 0.9.96 → 0.9.97
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/CHANGELOG.md +8 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
package/dist/lead.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { leadToolFailureReason, leadToolLabel } from "./lead-progress.js";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { leadTimeoutNotice } from "./store.js";
|
|
4
|
+
import { leadAskAwaitsAnswer, leadQuickAnswersOn, leadTimeoutNotice, setLeadTurnRoute } from "./store.js";
|
|
5
|
+
import { quickAdvertises, quickLeadModel, quickRoute, quickStepEscalates, routeWords } from "./lead-route.js";
|
|
5
6
|
import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
|
|
6
7
|
import { isVerifiedApprover, reproveApprover } from "./principal.js";
|
|
7
8
|
import { LEAD_MAX_CALLS_PER_STEP, LEAD_MAX_STEPS, LEAD_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeLeadRequest, credentialKeyOf, isDirectChatProvider, leadWorstCaseForPrice, performLeadRequest, priceForConfig, cacheWorstPrice, settleWithCache, subscriptionCredentialKey, } from "./converse.js";
|
|
@@ -74,8 +75,9 @@ export const LEAD_FAILURE_COPY = {
|
|
|
74
75
|
empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
|
|
75
76
|
unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
|
|
76
77
|
};
|
|
77
|
-
/** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry).
|
|
78
|
-
|
|
78
|
+
/** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry).
|
|
79
|
+
* Its saved detail (v158), when known, names the exact cause the person saw live ("not installed", "sign-in expired"). */
|
|
80
|
+
export function leadFailureText(reason, unknownSpend = false, detail = null) {
|
|
79
81
|
// A turn whose cost is unknown paused chat: say what happened (its reason's first sentence), then that the person must
|
|
80
82
|
// confirm the cost before chat runs again — never "send it again", which would only meet the pause.
|
|
81
83
|
if (unknownSpend) {
|
|
@@ -87,6 +89,10 @@ export function leadFailureText(reason, unknownSpend = false) {
|
|
|
87
89
|
const happened = /^[^.]*\./.exec(leadFailureText(reason))?.[0] ?? "The lead's reply didn't finish.";
|
|
88
90
|
return `${happened} Its cost isn't known, so chat is paused. Confirm the cost on the Chat page to turn chat back on.`;
|
|
89
91
|
}
|
|
92
|
+
if (reason === "provider-error" && detail === "not-installed")
|
|
93
|
+
return LEAD_FAILURE_COPY.notInstalled;
|
|
94
|
+
if (reason === "provider-error" && detail === "sign-in")
|
|
95
|
+
return LEAD_FAILURE_COPY.signIn;
|
|
90
96
|
switch (reason) {
|
|
91
97
|
case "over-reservation": return "This reply reported more use than was set aside for it, so it was stopped and its cost counted. Send your message again.";
|
|
92
98
|
case "timeout":
|
|
@@ -212,6 +218,13 @@ export async function runLeadTurn(input) {
|
|
|
212
218
|
// The channel's standing, proved before anything is admitted or sent.
|
|
213
219
|
if (input.revalidate !== undefined && !(await input.revalidate()).ok)
|
|
214
220
|
return refuse("channel");
|
|
221
|
+
// Which model answers (responsive-chat.md §8), decided once, before the turn: the faster one only for a plain read
|
|
222
|
+
// question with quick answers on; everything else on the configured model, as before.
|
|
223
|
+
const quickOn = leadQuickAnswersOn(store);
|
|
224
|
+
const quickModel = quickOn ? quickLeadModel(store) : config.model;
|
|
225
|
+
let route = quickRoute({ enabled: quickOn, provider: config.provider, mainModel: config.model, message,
|
|
226
|
+
hasContext: input.context !== undefined, hasReplyImage: input.replyImage !== undefined, askOpen: quickOn && leadAskAwaitsAnswer(store, thread.id, clock()),
|
|
227
|
+
quickModel, quickAllowed: quickOn && store.agentPolicyRefusal(config.provider, quickModel) === null }).route;
|
|
215
228
|
let now = clock();
|
|
216
229
|
store.sweepStaleLeadTurns(now);
|
|
217
230
|
const view = leadViewContextFor(store, who);
|
|
@@ -282,6 +295,7 @@ export async function runLeadTurn(input) {
|
|
|
282
295
|
const started = store.startLeadTurn(turnId, now);
|
|
283
296
|
if (!started.ok)
|
|
284
297
|
throw new Error("new lead turn could not start");
|
|
298
|
+
setLeadTurnRoute(store, turnId, started.generation, route);
|
|
285
299
|
if (input.queuedMessageId === undefined)
|
|
286
300
|
store.appendLeadMessage({ thread: thread.id, turn: turnId, role: "operator", text: message }, now);
|
|
287
301
|
else {
|
|
@@ -308,6 +322,12 @@ export async function runLeadTurn(input) {
|
|
|
308
322
|
}
|
|
309
323
|
catch { /* a watcher never breaks the turn */ } };
|
|
310
324
|
progress({ kind: "started", turn: turnId });
|
|
325
|
+
/** Text that looked like a secret was held back: every preview closes, once (responsive-chat.md §2.1). */
|
|
326
|
+
let blockedSaid = false;
|
|
327
|
+
const blockPreview = () => { if (!blockedSaid) {
|
|
328
|
+
blockedSaid = true;
|
|
329
|
+
progress({ kind: "blocked", turn: turnId });
|
|
330
|
+
} };
|
|
311
331
|
let proposals = 0;
|
|
312
332
|
let reads = 0;
|
|
313
333
|
let steps = 0;
|
|
@@ -336,18 +356,18 @@ export async function runLeadTurn(input) {
|
|
|
336
356
|
return store.listLeadTurnEvidence(turnId).map(one => one.artifact);
|
|
337
357
|
};
|
|
338
358
|
/** The turn ends failed: its drafts are deleted, its cost settled — the whole reservation when any of it is unknown. */
|
|
339
|
-
const fail = (failed, message, unknownSpend) => {
|
|
359
|
+
const fail = (failed, message, unknownSpend, failureDetail = null) => {
|
|
340
360
|
now = clock();
|
|
341
|
-
store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed }, now);
|
|
361
|
+
store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed, failureDetail }, now);
|
|
342
362
|
store.dropLeadAsk(turnId);
|
|
343
363
|
return { ok: false, turn: turnId, failed, message, unknownSpend };
|
|
344
364
|
};
|
|
345
365
|
/** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
|
|
346
366
|
* the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
|
|
347
|
-
const timedOut = (unknownSpend) => {
|
|
367
|
+
const timedOut = (unknownSpend, failureDetail = null) => {
|
|
348
368
|
now = clock();
|
|
349
369
|
const message = leadTimeoutNotice(proposals > 0, unknownSpend);
|
|
350
|
-
const saved = store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
|
|
370
|
+
const saved = store.finalizeLeadTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", failureDetail, keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
|
|
351
371
|
store.dropLeadAsk(turnId);
|
|
352
372
|
if (!saved)
|
|
353
373
|
return { ok: false, turn: turnId, failed: "superseded", message: LEAD_FAILURE_COPY.stopped, unknownSpend: false };
|
|
@@ -387,6 +407,8 @@ export async function runLeadTurn(input) {
|
|
|
387
407
|
let repaired = false;
|
|
388
408
|
/** The turn's one shorten step for a reply over its channel's message limit (LEAD_REPLY_LIMITS); still over, it is split. */
|
|
389
409
|
let shortened = false;
|
|
410
|
+
/** Why the next step runs, when it re-asks about the reply itself (repair or shorten): the live status says so. */
|
|
411
|
+
let nextReason;
|
|
390
412
|
const replyLimit = input.channel === undefined ? null : LEAD_REPLY_LIMITS[input.channel];
|
|
391
413
|
const unbacked = (text) => {
|
|
392
414
|
const claim = deliverableClaim(text);
|
|
@@ -403,7 +425,10 @@ export async function runLeadTurn(input) {
|
|
|
403
425
|
if (remainingMs <= 0)
|
|
404
426
|
return timedOut(false);
|
|
405
427
|
// This request's advertised tools: fixed for the request; a tool found in its reply is advertised from the next one.
|
|
406
|
-
|
|
428
|
+
// A quick step (§8) advertises the reads, and find_tools as its way to ask for more, on the faster model.
|
|
429
|
+
const quick = route === "fast";
|
|
430
|
+
const stepModel = quick ? quickModel : config.model;
|
|
431
|
+
const advertised = quick ? LEAD_TOOL_SCHEMAS.filter(one => quickAdvertises(one.name)) : advertisedTools();
|
|
407
432
|
const advertisedNames = new Set(advertised.map(one => one.name));
|
|
408
433
|
const request = direct ? composeDirect(input.key, advertised) : composeSubscription(advertised);
|
|
409
434
|
// Tool results join the outbound body: scanned again before every dispatch.
|
|
@@ -411,7 +436,7 @@ export async function runLeadTurn(input) {
|
|
|
411
436
|
if (scanForSecrets(outbound).length > 0) {
|
|
412
437
|
return fail("secret-refused", LEAD_FAILURE_COPY.secretRead, false);
|
|
413
438
|
}
|
|
414
|
-
const step = store.openLeadStep({ leadTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model:
|
|
439
|
+
const step = store.openLeadStep({ leadTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: stepModel, deadlineMs: remainingMs + 10_000 }, now);
|
|
415
440
|
if (!step.ok) {
|
|
416
441
|
if (step.reason === "latched")
|
|
417
442
|
return fail("latched", LEAD_FAILURE_COPY.paused, false);
|
|
@@ -421,7 +446,8 @@ export async function runLeadTurn(input) {
|
|
|
421
446
|
if (!stepStarted.ok)
|
|
422
447
|
return fail("provider-error", LEAD_FAILURE_COPY.notStarted, false);
|
|
423
448
|
steps++;
|
|
424
|
-
progress({ kind: "step", turn: turnId, step: steps });
|
|
449
|
+
progress({ kind: "step", turn: turnId, step: steps, ...(nextReason === undefined ? {} : { reason: nextReason }) });
|
|
450
|
+
nextReason = undefined;
|
|
425
451
|
const requestBytes = Buffer.byteLength(outbound, "utf8");
|
|
426
452
|
let result;
|
|
427
453
|
// The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
|
|
@@ -430,7 +456,9 @@ export async function runLeadTurn(input) {
|
|
|
430
456
|
const controller = new AbortController();
|
|
431
457
|
const abortAt = setTimeout(() => controller.abort(), remainingMs);
|
|
432
458
|
const settle = (work) => settleBy(work, remainingMs + LEAD_ABORT_GRACE_MS)
|
|
433
|
-
|
|
459
|
+
// A harness that ended at the deadline still says whether it had written anything (recorded, never shown).
|
|
460
|
+
.then(value => value === LATE ? { ok: false, problem: "timeout" } : controller.signal.aborted
|
|
461
|
+
? { ok: false, problem: "timeout", ...(!value.ok && "quiet" in value && typeof value.quiet === "boolean" ? { quiet: value.quiet } : {}) } : value)
|
|
434
462
|
.finally(() => clearTimeout(abortAt));
|
|
435
463
|
if (direct) {
|
|
436
464
|
result = await settle(performLeadRequest(request, directProvider, controller.signal, fetcher)
|
|
@@ -440,7 +468,7 @@ export async function runLeadTurn(input) {
|
|
|
440
468
|
const runner = input.subscriptionRunner ?? performSubscriptionLeadRequest;
|
|
441
469
|
result = await settle((async () => runner({
|
|
442
470
|
provider: subscriptionProvider,
|
|
443
|
-
model:
|
|
471
|
+
model: stepModel,
|
|
444
472
|
system: LEAD_CONTRACT,
|
|
445
473
|
dataDocument: document,
|
|
446
474
|
history,
|
|
@@ -449,15 +477,22 @@ export async function runLeadTurn(input) {
|
|
|
449
477
|
signal: controller.signal,
|
|
450
478
|
// The reply as it is written; text that looks like a secret is
|
|
451
479
|
// never shown (the finished reply is scanned again before saving).
|
|
452
|
-
...(input.onProgress === undefined ? {} : { onText: (text) => { if (
|
|
453
|
-
|
|
480
|
+
...(input.onProgress === undefined ? {} : { onText: (text) => { if (controller.signal.aborted)
|
|
481
|
+
return; if (scanForSecrets(text).length === 0)
|
|
482
|
+
progress({ kind: "text", turn: turnId, step: steps, text });
|
|
483
|
+
else
|
|
484
|
+
blockPreview(); } }),
|
|
454
485
|
}))().catch(() => ({ ok: false, problem: "provider-error" })));
|
|
455
486
|
}
|
|
456
487
|
now = clock();
|
|
457
488
|
const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
|
|
458
489
|
if (!result.ok) {
|
|
459
490
|
if (!direct) {
|
|
460
|
-
|
|
491
|
+
// The exact cause, saved (v158): what a later reader shows, and what the next timeout diagnosis reads.
|
|
492
|
+
const quiet = "quiet" in result ? result.quiet : undefined;
|
|
493
|
+
const detail = result.problem === "timeout" ? (quiet === true ? "timeout-silent" : quiet === false ? "timeout-output" : null)
|
|
494
|
+
: result.problem === "not-found" ? "not-installed" : result.problem === "malformed-reply" ? null : "sign-in";
|
|
495
|
+
finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0, failureDetail: detail });
|
|
461
496
|
const message = result.problem === "not-found"
|
|
462
497
|
? LEAD_FAILURE_COPY.notInstalled
|
|
463
498
|
: result.problem === "timeout"
|
|
@@ -466,29 +501,32 @@ export async function runLeadTurn(input) {
|
|
|
466
501
|
? LEAD_FAILURE_COPY.unreadable
|
|
467
502
|
: LEAD_FAILURE_COPY.signIn;
|
|
468
503
|
if (result.problem === "timeout")
|
|
469
|
-
return timedOut(false);
|
|
470
|
-
return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
|
|
504
|
+
return timedOut(false, detail);
|
|
505
|
+
return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false, detail);
|
|
471
506
|
}
|
|
472
507
|
if (result.problem.startsWith("status-")) {
|
|
473
508
|
// The provider ANSWERED with an error: nothing billed for this step.
|
|
474
|
-
finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
|
|
475
|
-
return fail("provider-error", LEAD_FAILURE_COPY.refused, false);
|
|
509
|
+
finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0, failureDetail: "refused" });
|
|
510
|
+
return fail("provider-error", LEAD_FAILURE_COPY.refused, false, "refused");
|
|
476
511
|
}
|
|
477
512
|
if (result.problem === "timeout") {
|
|
478
513
|
finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
|
|
479
514
|
return timedOut(true);
|
|
480
515
|
}
|
|
481
516
|
if (result.problem === "network") {
|
|
482
|
-
finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
|
|
483
|
-
return fail("provider-error", LEAD_FAILURE_COPY.lostUnknownCost, true);
|
|
517
|
+
finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true, failureDetail: "lost" });
|
|
518
|
+
return fail("provider-error", LEAD_FAILURE_COPY.lostUnknownCost, true, "lost");
|
|
484
519
|
}
|
|
485
520
|
finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
|
|
486
521
|
return fail("malformed-reply", LEAD_FAILURE_COPY.unreadableUnknownCost, true);
|
|
487
522
|
}
|
|
488
523
|
const answer = result.answer;
|
|
489
524
|
// The streamed reply's finished text, once, secret-checked whole: only now may its last word show live.
|
|
490
|
-
if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== ""
|
|
491
|
-
|
|
525
|
+
if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== "") {
|
|
526
|
+
if (scanForSecrets(answer.text).length === 0)
|
|
527
|
+
progress({ kind: "text", turn: turnId, step: steps, text: answer.text, final: true });
|
|
528
|
+
else
|
|
529
|
+
blockPreview();
|
|
492
530
|
}
|
|
493
531
|
// Usage that cannot be true — more input tokens than bytes sent — is a
|
|
494
532
|
// malformed reply with unknown cost, never a number to settle by.
|
|
@@ -512,7 +550,9 @@ export async function runLeadTurn(input) {
|
|
|
512
550
|
cacheReadTokens: answer.cacheReadTokens ?? null, cacheWriteTokens: answer.cacheWriteTokens ?? null,
|
|
513
551
|
// v140: what the provider itself reported for this request (absent: unreported, kept NULL).
|
|
514
552
|
modelReported: answer.modelReported ?? null, providerTurns: answer.providerTurns ?? null, reportedCostUsd: answer.reportedCostUsd ?? null,
|
|
515
|
-
billingReported: answer.billingReported ?? null
|
|
553
|
+
billingReported: answer.billingReported ?? null,
|
|
554
|
+
// v158: the tools this step asked for, names only (responsive-chat.md §7.2), so the next latency call is data-driven.
|
|
555
|
+
tools: answer.calls.map(one => one.name) });
|
|
516
556
|
tokensIn += answer.tokensIn;
|
|
517
557
|
tokensOut += answer.tokensOut;
|
|
518
558
|
if (answer.cacheReadTokens === undefined)
|
|
@@ -527,6 +567,14 @@ export async function runLeadTurn(input) {
|
|
|
527
567
|
const changed = guard(await revalidated());
|
|
528
568
|
if (changed !== null)
|
|
529
569
|
return changed;
|
|
570
|
+
// A quick step that asked for more than a read (§8): its calls are discarded unrun, and the configured model takes the
|
|
571
|
+
// reply over from the same history, for this step and every later one. Steps and the turn's deadline still bind.
|
|
572
|
+
if (route === "fast" && quickStepEscalates(answer.calls)) {
|
|
573
|
+
route = "escalated";
|
|
574
|
+
setLeadTurnRoute(store, turnId, started.generation, route);
|
|
575
|
+
lastText = "";
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
530
578
|
if (answer.calls.length === 0) {
|
|
531
579
|
if (answer.text.trim() === "")
|
|
532
580
|
return fail("malformed-reply", LEAD_FAILURE_COPY.empty, false);
|
|
@@ -534,6 +582,7 @@ export async function runLeadTurn(input) {
|
|
|
534
582
|
const claim = unbacked(answer.text);
|
|
535
583
|
if (claim !== null && !repaired && steps < maxSteps) {
|
|
536
584
|
repaired = true;
|
|
585
|
+
nextReason = "repair";
|
|
537
586
|
history.push({ role: "assistant", text: answer.text, calls: [] });
|
|
538
587
|
history.push({ role: "operator", text: deliverableRepair(claim) });
|
|
539
588
|
continue;
|
|
@@ -541,6 +590,7 @@ export async function runLeadTurn(input) {
|
|
|
541
590
|
const candidate = claim === null ? answer.text : dropDeliverableClaims(answer.text);
|
|
542
591
|
if (replyLimit !== null && candidate.length > replyLimit && !shortened && steps < maxSteps) {
|
|
543
592
|
shortened = true;
|
|
593
|
+
nextReason = "shorten";
|
|
544
594
|
history.push({ role: "assistant", text: answer.text, calls: [] });
|
|
545
595
|
history.push({ role: "operator", text: shortenAsk([{ field: "Your reply", limit: replyLimit, length: candidate.length }]) });
|
|
546
596
|
continue;
|
|
@@ -598,10 +648,13 @@ export async function runLeadTurn(input) {
|
|
|
598
648
|
reply = `${last === "" ? "" : `${last}\n\n`}(I stopped here: this answer needed more steps than ${maxSteps < LEAD_MAX_STEPS ? "this conversation's remaining spending allows" : "one reply allows"}. Ask me to carry on.)`;
|
|
599
649
|
}
|
|
600
650
|
// Ruling 11: model text is scanned before it becomes durable.
|
|
601
|
-
if (scanForSecrets(reply).length > 0)
|
|
651
|
+
if (scanForSecrets(reply).length > 0) {
|
|
652
|
+
blockPreview();
|
|
602
653
|
return fail("secret-refused", LEAD_FAILURE_COPY.secretReply, false);
|
|
654
|
+
}
|
|
603
655
|
now = clock();
|
|
604
|
-
const
|
|
656
|
+
const routed = routeWords(route, quickModel, config.model);
|
|
657
|
+
const activity = `${activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null)}${routed === null ? "" : ` · ${routed}`}`;
|
|
605
658
|
// One write (finding 12): settle, debit, promote the drafts, append the assistant text.
|
|
606
659
|
const finalized = store.finalizeLeadTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
|
|
607
660
|
if (!finalized)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { LeadLiveTool, LeadProgress } from "./lead-progress.js";
|
|
2
|
+
import { type ShapeOptions } from "./reply-shape.js";
|
|
3
|
+
export type LiveView = {
|
|
4
|
+
phase: "queued";
|
|
5
|
+
} | {
|
|
6
|
+
phase: "working";
|
|
7
|
+
status: string;
|
|
8
|
+
} | {
|
|
9
|
+
phase: "writing";
|
|
10
|
+
status: string;
|
|
11
|
+
text: string;
|
|
12
|
+
} | {
|
|
13
|
+
phase: "closed";
|
|
14
|
+
why: "answered" | "failed" | "blocked";
|
|
15
|
+
};
|
|
16
|
+
/** The status line's fixed words (§2.3). A running tool reads as its own label plus "…". */
|
|
17
|
+
export declare const LIVE_STATUS: {
|
|
18
|
+
readonly queued: "Waiting for your earlier messages…";
|
|
19
|
+
readonly thinking: "Thinking…";
|
|
20
|
+
readonly writing: "Writing…";
|
|
21
|
+
readonly repair: "Fixing the reply…";
|
|
22
|
+
readonly shorten: "Shortening the reply…";
|
|
23
|
+
/** A preview closed because text looked like a password or key; the turn itself may still answer. */
|
|
24
|
+
readonly held: "Working on it…";
|
|
25
|
+
};
|
|
26
|
+
/** Could the end of this text be the start of a password or key? Then nothing of it shows yet. */
|
|
27
|
+
export declare function secretMayStart(text: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* What of a reply still being written is settled: everything before its last whitespace (the unfinished last word
|
|
30
|
+
* stays out), then cut back before any code fence, code span, bold run or link that hasn't closed yet. Trailing
|
|
31
|
+
* whitespace is trimmed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function settledText(raw: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* A partial as it may be shown: `blocked` when it holds a password or key (the preview closes), `withheld` while its
|
|
36
|
+
* tail could still become one (show nothing new), else the settled text shaped for display. A step's finished text
|
|
37
|
+
* (`final`, secret-checked whole by the engine) shows whole.
|
|
38
|
+
*/
|
|
39
|
+
export declare function partialDisplay(raw: string, final: boolean, shape?: ShapeOptions): {
|
|
40
|
+
kind: "blocked";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "withheld";
|
|
43
|
+
} | {
|
|
44
|
+
kind: "shown";
|
|
45
|
+
text: string;
|
|
46
|
+
};
|
|
47
|
+
export type LiveReply = {
|
|
48
|
+
/** Feed one engine event; true when the view changed. Events after close are ignored. */
|
|
49
|
+
push(event: LeadProgress): boolean;
|
|
50
|
+
/** The turn ended (its outcome is the engine's, never inferred from text). Idempotent; a blocked close stays blocked. */
|
|
51
|
+
close(why: "answered" | "failed"): void;
|
|
52
|
+
view(): LiveView;
|
|
53
|
+
/** The status line now, also once a blocked preview closed while the turn runs on. */
|
|
54
|
+
status(): string;
|
|
55
|
+
/** Each step's tool calls, for a surface that lists them behind a disclosure. Fixed copy only, never model text. */
|
|
56
|
+
tools(): LeadLiveTool[][];
|
|
57
|
+
readonly turn: number | null;
|
|
58
|
+
};
|
|
59
|
+
/** One turn's live reply. `shape`: the owner's message (`asked`) and this app's origins, as the final reply is shaped. */
|
|
60
|
+
export declare function createLiveReply(shape?: ShapeOptions, options?: {
|
|
61
|
+
queued?: boolean;
|
|
62
|
+
}): LiveReply;
|
|
63
|
+
export type LiveSender<V> = {
|
|
64
|
+
/** Offer the newest view: it replaces any view still waiting. Ignored once closed. */
|
|
65
|
+
offer(view: V): void;
|
|
66
|
+
/** Drop the waiting view, wait for the request in flight to settle, then refuse every later view. */
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
/** Grows with every offered view; a transport may use it to refuse an older one. */
|
|
69
|
+
readonly generation: number;
|
|
70
|
+
readonly closed: boolean;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The serialized sender for one live surface message (§2.4). `standing` re-proves the viewer's right to see this turn
|
|
74
|
+
* before every send (it may await; the close is re-checked after it). `spacingMs` is the least time between two sends,
|
|
75
|
+
* read before each one (a surface widens it after the platform asks for a pause). A failed send drops that view:
|
|
76
|
+
* previews are best effort.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createLiveSender<V>(options: {
|
|
79
|
+
send: (view: V, generation: number) => unknown;
|
|
80
|
+
standing?: () => boolean | Promise<boolean>;
|
|
81
|
+
spacingMs?: number | (() => number);
|
|
82
|
+
now?: () => number;
|
|
83
|
+
}): LiveSender<V>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared live-reply core (docs/plans/responsive-chat.md §2): one lead turn's `LeadProgress` turned into text-only
|
|
3
|
+
* views that every surface renders. Surfaces never read `LeadProgress` themselves.
|
|
4
|
+
*
|
|
5
|
+
* queued → working (one status line) → writing (status + display-safe partial text) → closed (answered | failed | blocked)
|
|
6
|
+
*
|
|
7
|
+
* Nothing here is durable or authoritative: the saved thread message and its cards remain the record, and no view ever
|
|
8
|
+
* carries a card, a button, an ask or an approval. A partial is the reply as written so far, cut back to what is
|
|
9
|
+
* settled (never an unfinished word, code span, link or bold run), withheld while its tail could be the start of a
|
|
10
|
+
* password or key, and shaped with the final reply's own presentation policy (presentation, not redaction).
|
|
11
|
+
*
|
|
12
|
+
* `createLiveSender` is the one sender per live surface message (§2.4): at most one request in flight, one pending
|
|
13
|
+
* slot holding only the newest view, a generation that only grows, and `close()` — after which nothing more is sent,
|
|
14
|
+
* and which resolves only once the request in flight has settled, so durable delivery can start strictly after it.
|
|
15
|
+
*/
|
|
16
|
+
import { scanForSecrets } from "./evidence.js";
|
|
17
|
+
import { shapeReply, shapeReplyPrefix } from "./reply-shape.js";
|
|
18
|
+
/** The status line's fixed words (§2.3). A running tool reads as its own label plus "…". */
|
|
19
|
+
export const LIVE_STATUS = {
|
|
20
|
+
queued: "Waiting for your earlier messages…",
|
|
21
|
+
thinking: "Thinking…",
|
|
22
|
+
writing: "Writing…",
|
|
23
|
+
repair: "Fixing the reply…",
|
|
24
|
+
shorten: "Shortening the reply…",
|
|
25
|
+
/** A preview closed because text looked like a password or key; the turn itself may still answer. */
|
|
26
|
+
held: "Working on it…",
|
|
27
|
+
};
|
|
28
|
+
/** One token that could still grow into a match of a secret family: a non-empty prefix of one of its openings, or the
|
|
29
|
+
* whole opening followed by its body characters. Preceded by a non-word character or the start, as the detectors'
|
|
30
|
+
* own word boundaries are. */
|
|
31
|
+
function opening(leads, body) {
|
|
32
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
|
|
33
|
+
const alternatives = new Set();
|
|
34
|
+
for (const lead of leads) {
|
|
35
|
+
for (let length = 1; length < lead.length; length += 1)
|
|
36
|
+
alternatives.add(escape(lead.slice(0, length)));
|
|
37
|
+
alternatives.add(`${escape(lead)}${body}*`);
|
|
38
|
+
}
|
|
39
|
+
return `(?:${[...alternatives].join("|")})`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The start of every `SECRET_PATTERNS` family (evidence.ts), checked against the end of the text written so far:
|
|
43
|
+
* while any matches, the partial is withheld. Single-token families look at the unfinished last token; the families
|
|
44
|
+
* whose match spans spaces (a private-key header, an npm `_authToken = value`) look at the whole last line.
|
|
45
|
+
*/
|
|
46
|
+
const SECRET_STARTS = [
|
|
47
|
+
// private-key: "-----BEGIN RSA PRIVATE KEY-----", partway. No start boundary: the complete detector has none either.
|
|
48
|
+
/-{1,5}(?:B(?:E(?:G(?:I(?:N(?:[ \t][A-Z \t]*-{0,5})?)?)?)?)?)?$/,
|
|
49
|
+
// npm-auth-token: "_authToken = value", partway.
|
|
50
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9])${opening(["_authToken"], String.raw `[\s=]`)}\S*$`),
|
|
51
|
+
// aws-access-key, github-token, slack-token, openai-key, stripe-key, google-api-key, gitlab-token.
|
|
52
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["AKIA", "ASIA"], "[0-9A-Z]")}$`),
|
|
53
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"], "[A-Za-z0-9_]")}$`),
|
|
54
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"], "[A-Za-z0-9-]")}$`),
|
|
55
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["sk-"], "[A-Za-z0-9_-]")}$`),
|
|
56
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["sk_live_", "sk_test_", "rk_live_", "rk_test_", "whsec_"], "[A-Za-z0-9]")}$`),
|
|
57
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["AIza"], "[0-9A-Za-z_-]")}$`),
|
|
58
|
+
new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["glpat-"], "[A-Za-z0-9_-]")}$`),
|
|
59
|
+
// telegram-bot-token: "123456789:AA…".
|
|
60
|
+
/(?:^|[^A-Za-z0-9_])\d+(?::(?:A(?:A[A-Za-z0-9_-]*)?)?)?$/,
|
|
61
|
+
// discord-bot-token: "M…24.…6.…27".
|
|
62
|
+
/(?:^|[^A-Za-z0-9_])[MN][A-Za-z\d]*(?:\.[\w-]*(?:\.[\w-]*)?)?$/,
|
|
63
|
+
// password-in-url: "scheme://user:password@host", partway (a scheme and its colon are enough to wait).
|
|
64
|
+
/(?:^|[^A-Za-z0-9+.-])[a-z][a-z0-9+.-]*:\S*$/i,
|
|
65
|
+
];
|
|
66
|
+
/** Could the end of this text be the start of a password or key? Then nothing of it shows yet. */
|
|
67
|
+
export function secretMayStart(text) {
|
|
68
|
+
const lastLine = text.replace(/\r\n?/g, "\n").split("\n").at(-1) ?? "";
|
|
69
|
+
return SECRET_STARTS.some(pattern => pattern.test(lastLine));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What of a reply still being written is settled: everything before its last whitespace (the unfinished last word
|
|
73
|
+
* stays out), then cut back before any code fence, code span, bold run or link that hasn't closed yet. Trailing
|
|
74
|
+
* whitespace is trimmed.
|
|
75
|
+
*/
|
|
76
|
+
export function settledText(raw) {
|
|
77
|
+
const text = raw.replace(/\r\n?/g, "\n");
|
|
78
|
+
let end = text.length;
|
|
79
|
+
while (end > 0 && !/\s/.test(text[end - 1]))
|
|
80
|
+
end -= 1;
|
|
81
|
+
const shown = text.slice(0, end);
|
|
82
|
+
let fence = -1, code = -1, bold = -1, link = -1;
|
|
83
|
+
let linkState = null;
|
|
84
|
+
for (let index = 0; index < shown.length; index += 1) {
|
|
85
|
+
if (shown.startsWith("```", index)) {
|
|
86
|
+
fence = fence === -1 ? index : -1;
|
|
87
|
+
index += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (fence !== -1)
|
|
91
|
+
continue;
|
|
92
|
+
const char = shown[index];
|
|
93
|
+
if (char === "\n") {
|
|
94
|
+
code = -1;
|
|
95
|
+
bold = -1;
|
|
96
|
+
link = -1;
|
|
97
|
+
linkState = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (char === "`") {
|
|
101
|
+
code = code === -1 ? index : -1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (code !== -1)
|
|
105
|
+
continue;
|
|
106
|
+
if (shown.startsWith("**", index)) {
|
|
107
|
+
bold = bold === -1 ? index : -1;
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (char === "[" && linkState === null) {
|
|
112
|
+
link = index;
|
|
113
|
+
linkState = "label";
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (linkState === "label" && char === "]") {
|
|
117
|
+
if (shown[index + 1] === "(") {
|
|
118
|
+
linkState = "url";
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
link = -1;
|
|
123
|
+
linkState = null;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (linkState === "url" && (char === ")" || /\s/.test(char))) {
|
|
128
|
+
link = -1;
|
|
129
|
+
linkState = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const cut = Math.min(...[fence, code, bold, link].filter(at => at !== -1), shown.length);
|
|
133
|
+
return shown.slice(0, cut).trimEnd();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A partial as it may be shown: `blocked` when it holds a password or key (the preview closes), `withheld` while its
|
|
137
|
+
* tail could still become one (show nothing new), else the settled text shaped for display. A step's finished text
|
|
138
|
+
* (`final`, secret-checked whole by the engine) shows whole.
|
|
139
|
+
*/
|
|
140
|
+
export function partialDisplay(raw, final, shape = {}) {
|
|
141
|
+
if (scanForSecrets(raw).length > 0)
|
|
142
|
+
return { kind: "blocked" };
|
|
143
|
+
if (!final && secretMayStart(raw))
|
|
144
|
+
return { kind: "withheld" };
|
|
145
|
+
const text = final ? shapeReply(raw, shape) : shapeReplyPrefix(settledText(raw), shape);
|
|
146
|
+
// Shaping only relabels and removes; a secret in what it returns would mean it came from somewhere else. Never shown.
|
|
147
|
+
if (scanForSecrets(text).length > 0)
|
|
148
|
+
return { kind: "blocked" };
|
|
149
|
+
return { kind: "shown", text };
|
|
150
|
+
}
|
|
151
|
+
/** One turn's live reply. `shape`: the owner's message (`asked`) and this app's origins, as the final reply is shaped. */
|
|
152
|
+
export function createLiveReply(shape = {}, options = {}) {
|
|
153
|
+
const steps = [];
|
|
154
|
+
let queued = options.queued === true;
|
|
155
|
+
let closed = null;
|
|
156
|
+
let turn = null;
|
|
157
|
+
let last = "";
|
|
158
|
+
const current = () => steps.at(-1) ?? (steps.push({ calls: [], raw: "", shown: "" }), steps.at(-1));
|
|
159
|
+
const status = () => {
|
|
160
|
+
const step = steps.at(-1);
|
|
161
|
+
const running = step?.calls.filter(call => call.state === "running").at(-1);
|
|
162
|
+
if (running !== undefined)
|
|
163
|
+
return `${running.label}…`;
|
|
164
|
+
if (step?.reason !== undefined)
|
|
165
|
+
return LIVE_STATUS[step.reason];
|
|
166
|
+
if (closed === "blocked")
|
|
167
|
+
return LIVE_STATUS.held;
|
|
168
|
+
if (step !== undefined && step.raw.trim() !== "")
|
|
169
|
+
return LIVE_STATUS.writing;
|
|
170
|
+
return LIVE_STATUS.thinking;
|
|
171
|
+
};
|
|
172
|
+
const view = () => {
|
|
173
|
+
if (closed !== null)
|
|
174
|
+
return { phase: "closed", why: closed };
|
|
175
|
+
if (queued && steps.length === 0)
|
|
176
|
+
return { phase: "queued" };
|
|
177
|
+
const text = [...steps].reverse().find(step => step.shown !== "")?.shown ?? "";
|
|
178
|
+
return text === "" ? { phase: "working", status: status() } : { phase: "writing", status: status(), text };
|
|
179
|
+
};
|
|
180
|
+
const changed = () => {
|
|
181
|
+
const now = JSON.stringify(view());
|
|
182
|
+
if (now === last)
|
|
183
|
+
return false;
|
|
184
|
+
last = now;
|
|
185
|
+
return true;
|
|
186
|
+
};
|
|
187
|
+
last = JSON.stringify(view());
|
|
188
|
+
return {
|
|
189
|
+
get turn() { return turn; },
|
|
190
|
+
push(event) {
|
|
191
|
+
if (closed !== null)
|
|
192
|
+
return false;
|
|
193
|
+
turn ??= event.turn;
|
|
194
|
+
queued = false;
|
|
195
|
+
switch (event.kind) {
|
|
196
|
+
case "started": break;
|
|
197
|
+
case "step":
|
|
198
|
+
steps.push({ calls: [], raw: "", shown: "", ...(event.reason === undefined ? {} : { reason: event.reason }) });
|
|
199
|
+
break;
|
|
200
|
+
case "tool":
|
|
201
|
+
current().calls.push({ id: event.id, label: event.label, state: "running" });
|
|
202
|
+
break;
|
|
203
|
+
case "tool-result":
|
|
204
|
+
for (const step of steps) {
|
|
205
|
+
const index = step.calls.findIndex(call => call.id === event.id);
|
|
206
|
+
if (index !== -1) {
|
|
207
|
+
step.calls[index] = { id: event.id, label: step.calls[index].label, ...event.outcome };
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case "text": {
|
|
213
|
+
const step = current();
|
|
214
|
+
step.raw = event.text;
|
|
215
|
+
const shown = partialDisplay(event.text, event.final === true, shape);
|
|
216
|
+
if (shown.kind === "blocked") {
|
|
217
|
+
closed = "blocked";
|
|
218
|
+
for (const one of steps)
|
|
219
|
+
one.shown = "";
|
|
220
|
+
}
|
|
221
|
+
else if (shown.kind === "shown")
|
|
222
|
+
step.shown = shown.text;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "blocked":
|
|
226
|
+
closed = "blocked";
|
|
227
|
+
for (const one of steps)
|
|
228
|
+
one.shown = "";
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return changed();
|
|
232
|
+
},
|
|
233
|
+
close(why) {
|
|
234
|
+
if (closed === null)
|
|
235
|
+
closed = why;
|
|
236
|
+
for (const one of steps)
|
|
237
|
+
one.shown = "";
|
|
238
|
+
changed();
|
|
239
|
+
},
|
|
240
|
+
view,
|
|
241
|
+
status,
|
|
242
|
+
tools: () => steps.map(step => step.calls.map(call => ({ ...call }))),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The serialized sender for one live surface message (§2.4). `standing` re-proves the viewer's right to see this turn
|
|
247
|
+
* before every send (it may await; the close is re-checked after it). `spacingMs` is the least time between two sends,
|
|
248
|
+
* read before each one (a surface widens it after the platform asks for a pause). A failed send drops that view:
|
|
249
|
+
* previews are best effort.
|
|
250
|
+
*/
|
|
251
|
+
export function createLiveSender(options) {
|
|
252
|
+
const spacing = () => typeof options.spacingMs === "function" ? options.spacingMs() : options.spacingMs ?? 0;
|
|
253
|
+
const now = options.now ?? Date.now;
|
|
254
|
+
let generation = 0;
|
|
255
|
+
let closed = false;
|
|
256
|
+
let pending = null;
|
|
257
|
+
let inflight = null;
|
|
258
|
+
let timer = null;
|
|
259
|
+
let lastSent = Number.NEGATIVE_INFINITY;
|
|
260
|
+
const pump = () => {
|
|
261
|
+
if (closed || inflight !== null || timer !== null || pending === null)
|
|
262
|
+
return;
|
|
263
|
+
const wait = lastSent + spacing() - now();
|
|
264
|
+
if (wait > 0) {
|
|
265
|
+
timer = setTimeout(() => { timer = null; pump(); }, wait);
|
|
266
|
+
timer.unref?.();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const next = pending;
|
|
270
|
+
pending = null;
|
|
271
|
+
inflight = (async () => {
|
|
272
|
+
try {
|
|
273
|
+
if (options.standing !== undefined && !(await options.standing()))
|
|
274
|
+
return;
|
|
275
|
+
if (closed)
|
|
276
|
+
return;
|
|
277
|
+
lastSent = now();
|
|
278
|
+
await options.send(next.view, next.generation);
|
|
279
|
+
}
|
|
280
|
+
catch { /* best effort: this view is dropped */ }
|
|
281
|
+
})().finally(() => { inflight = null; pump(); });
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
get generation() { return generation; },
|
|
285
|
+
get closed() { return closed; },
|
|
286
|
+
offer(view) {
|
|
287
|
+
if (closed)
|
|
288
|
+
return;
|
|
289
|
+
generation += 1;
|
|
290
|
+
pending = { view, generation };
|
|
291
|
+
pump();
|
|
292
|
+
},
|
|
293
|
+
async close() {
|
|
294
|
+
closed = true;
|
|
295
|
+
pending = null;
|
|
296
|
+
if (timer !== null)
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
timer = null;
|
|
299
|
+
await inflight;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|