@alphacoder-v0/pi-loops 0.24.0 → 0.24.2
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 +136 -0
- package/README.md +37 -30
- package/README.zh-CN.md +4 -3
- package/docs/cli.md +104 -28
- package/docs/configuration.md +2 -2
- package/docs/design.md +12 -7
- package/docs/triggers.md +11 -7
- package/docs/troubleshooting.md +4 -2
- package/docs/web-ui-parity.md +18 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/browser-open.ts +169 -0
- package/src/cli.ts +300 -95
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +82 -35
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/ui-prefs.ts +6 -2
- package/src/web.mjs +70 -38
package/src/web.mjs
CHANGED
|
@@ -11,7 +11,8 @@ export const DOOR = `<!doctype html><meta charset="utf-8"><title>pi-loops</title
|
|
|
11
11
|
<meta name="color-scheme" content="light dark">
|
|
12
12
|
<body style="font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;background:Canvas;color:CanvasText">
|
|
13
13
|
<h2 style="font-weight:600">This browser has not been here before.</h2>
|
|
14
|
-
<p>Run <code>pi-loops</code> in a terminal on this machine
|
|
14
|
+
<p>Run <code>pi-loops open</code> in a terminal on this machine for a link that signs this browser in.</p>
|
|
15
|
+
<p>Pairing another device? <code>pi-loops pair</code> shows a code.</p>
|
|
15
16
|
<form method="get" style="display:flex;gap:.5rem">
|
|
16
17
|
<input name="pair" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6"
|
|
17
18
|
placeholder="000000" style="font:inherit;letter-spacing:.3em;padding:.55rem .7rem;flex:1;min-width:0;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit">
|
|
@@ -507,6 +508,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
507
508
|
header .grow *{flex-shrink:0}
|
|
508
509
|
header .grow button{padding:5px 7px}
|
|
509
510
|
#cost{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
511
|
+
#cost:empty{display:none}
|
|
510
512
|
}}
|
|
511
513
|
/* The loops rail is not dropped when the window narrows, it is put behind the rail's Loops button: it
|
|
512
514
|
slides over the conversation rather than taking a third of it, the way a drawer does elsewhere. */
|
|
@@ -596,22 +598,21 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
596
598
|
<button id="expand">▸ steps</button>
|
|
597
599
|
<span id="actions">
|
|
598
600
|
<button id="reload">reload</button>
|
|
599
|
-
<button id="find" aria-label="Search this
|
|
601
|
+
<button id="find" aria-label="Search this conversation">find</button>
|
|
600
602
|
<button id="undo">undo</button>
|
|
601
|
-
<button id="save" title="Export this
|
|
603
|
+
<button id="save" title="Export this conversation as HTML">save</button>
|
|
602
604
|
<button id="share">share</button>
|
|
603
605
|
<button id="compact">compact</button>
|
|
604
|
-
<button id="clear">clear</button>
|
|
605
606
|
<button id="resume">resume</button>
|
|
606
607
|
<button id="adddev" aria-label="Add a device">add device</button>
|
|
607
|
-
<button id="theme"
|
|
608
|
+
<button id="theme">theme: system</button>
|
|
608
609
|
<button id="notifyMe">notifications</button>
|
|
609
610
|
</span>
|
|
610
611
|
<span class="badge" id="status">connecting</span>
|
|
611
|
-
<button id="more" title="
|
|
612
|
+
<button id="more" title="Conversation actions" aria-label="Conversation actions">⋯</button>
|
|
612
613
|
</span>
|
|
613
614
|
</header>
|
|
614
|
-
<div id="findbar" hidden><input id="findq" placeholder="search this
|
|
615
|
+
<div id="findbar" hidden><input id="findq" placeholder="search this conversation…" ><span id="findn" class="notice"></span></div>
|
|
615
616
|
<button id="stale" hidden></button>
|
|
616
617
|
<div id="feed" role="log" aria-live="polite" aria-label="Conversation"></div>
|
|
617
618
|
<div id="activitySlot"></div>
|
|
@@ -622,7 +623,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
622
623
|
<div id="pop"></div>
|
|
623
624
|
<div id="thumbs"></div>
|
|
624
625
|
<div class="composer-row">
|
|
625
|
-
<textarea id="input" placeholder="Message pi
|
|
626
|
+
<textarea id="input" placeholder="Message pi"></textarea>
|
|
626
627
|
<button type="button" id="attach">attach</button>
|
|
627
628
|
<button type="submit" class="primary">send</button>
|
|
628
629
|
<button type="button" id="stop">stop</button>
|
|
@@ -642,8 +643,8 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
642
643
|
<a href="#loops" id="tabLoops"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 2l3 3-3 3"/><path d="M4 11V9a4 4 0 0 1 4-4h12"/><path d="M7 22l-3-3 3-3"/><path d="M20 13v2a4 4 0 0 1-4 4H4"/></svg><span>Loops</span></a>
|
|
643
644
|
</nav>
|
|
644
645
|
<div id="scrim" hidden></div>
|
|
645
|
-
<dialog id="menu" aria-label="
|
|
646
|
-
<h3>
|
|
646
|
+
<dialog id="menu" aria-label="Conversation actions" role="dialog">
|
|
647
|
+
<h3>Conversation</h3><div id="menuBody"></div><div id="sid" class="sid"></div>
|
|
647
648
|
<menu><button id="menuClose" value="close">close</button></menu>
|
|
648
649
|
</dialog>
|
|
649
650
|
<dialog id="pairdlg" aria-labelledby="pairTitle" role="dialog">
|
|
@@ -2002,7 +2003,8 @@ function drawLoopRow(slot, j) {
|
|
|
2002
2003
|
num(g.findings30d) ? num(g.findings30d) + " finding" + (num(g.findings30d) === 1 ? "" : "s") + " in 30 days" : "",
|
|
2003
2004
|
num(g.claimed30d) ? num(g.claimed30d) + " claimed" : "",
|
|
2004
2005
|
num(g.dismissed30d) ? num(g.dismissed30d) + " dismissed" : "",
|
|
2005
|
-
|
|
2006
|
+
// A failing loop's empty runs are its errors, which the red line already says.
|
|
2007
|
+
num(g.quietStreak) >= 3 && !j.lastError ? "quiet ×" + num(g.quietStreak) : "",
|
|
2006
2008
|
].filter(Boolean).join(" · ") : "";
|
|
2007
2009
|
if (said) slot.append(textEl("span", "signal", said));
|
|
2008
2010
|
const told = tuneNote.get(j.id);
|
|
@@ -2028,15 +2030,16 @@ function renderSidebar(s) {
|
|
|
2028
2030
|
else {
|
|
2029
2031
|
let html = "";
|
|
2030
2032
|
for (const j of a.jobs) {
|
|
2031
|
-
//
|
|
2032
|
-
// rather than once and cut
|
|
2033
|
-
// rest with no way to see it. Both copies go through safeText; neither is sliced here.
|
|
2033
|
+
// An unnamed loop is told apart by its prompt: a two-line peek in the summary, the whole of it
|
|
2034
|
+
// in the body, rather than once and cut. A named loop's name says what it is, so no peek.
|
|
2034
2035
|
const prompt = safeText(j.prompt);
|
|
2036
|
+
const named = typeof j.name === "string" && j.name !== "";
|
|
2037
|
+
const fired = [j.lastFiredAt ? "last fired " + safeText(clock(j.lastFiredAt)) : "", j.skippedOverlap ? "skipped overlaps " + num(j.skippedOverlap) : ""].filter(Boolean).join(" · ");
|
|
2035
2038
|
html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
|
|
2036
2039
|
'<summary><span class="t"><span class="nm"><b>' + safeText(j.label || j.name || j.id) + "</b>" +
|
|
2037
2040
|
'<span class="m">' + safeText(j.schedule) + (j.stateful ? "" : " · in its conversation") + (j.enabled ? "" : " · disabled") + "</span></span>" +
|
|
2038
2041
|
'<button data-run="' + safeText(j.id) + '">run</button></span>' +
|
|
2039
|
-
'<span class="m peek">' + prompt + "</span>" +
|
|
2042
|
+
(named ? "" : '<span class="m peek">' + prompt + "</span>") +
|
|
2040
2043
|
// A run pi-loops holds has its own line, with Stop this run, under the card's name.
|
|
2041
2044
|
runsLine(j) +
|
|
2042
2045
|
// A one-line hint so a job that is failing says so before it is opened; the whole error is in
|
|
@@ -2050,20 +2053,22 @@ function renderSidebar(s) {
|
|
|
2050
2053
|
'<div class="body">' +
|
|
2051
2054
|
'<pre class="full">' + prompt + "</pre>" +
|
|
2052
2055
|
'<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
|
|
2053
|
-
'<div class="m">
|
|
2056
|
+
(fired ? '<div class="m">' + fired + "</div>" : "") +
|
|
2054
2057
|
// A job belonging to a hostname this machine no longer has: it is listed, because it exists,
|
|
2055
2058
|
// and it says why nothing is happening rather than leaving you to find out from the silence.
|
|
2056
2059
|
(j.lastError ? '<div class="m bad">' + safeText(j.lastError) + "</div>" : "") +
|
|
2057
2060
|
"</div></details>";
|
|
2058
2061
|
}
|
|
2059
2062
|
for (const r of a.rules) {
|
|
2063
|
+
// The condition is the rule's name: what it waits for. Cut in the title, whole in the body.
|
|
2064
|
+
const when = str(r.condition);
|
|
2065
|
+
const cut = clipChars(when, 80);
|
|
2060
2066
|
html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
|
|
2061
|
-
'<summary><span class="t"><b>
|
|
2062
|
-
'<span class="m peek">when ' + safeText(r.condition) + "</span>" +
|
|
2067
|
+
'<summary><span class="t"><b>' + safeText(cut) + '</b><span class="m">' + (r.enabled ? "" : "disabled · ") + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
|
|
2063
2068
|
(r.look ? '<span class="m looks" data-rulelook="' + safeText(r.id) + '"></span>' : "") +
|
|
2064
2069
|
"</summary>" +
|
|
2065
2070
|
'<div class="body">' +
|
|
2066
|
-
'<pre class="full">when ' + safeText(
|
|
2071
|
+
(cut !== when ? '<pre class="full">when ' + safeText(when) + "</pre>" : "") +
|
|
2067
2072
|
'<pre class="full">→ ' + safeText(r.action) + "</pre>" +
|
|
2068
2073
|
"</div></details>";
|
|
2069
2074
|
}
|
|
@@ -2333,7 +2338,8 @@ async function refresh() {
|
|
|
2333
2338
|
api("/stats").then((st) => {
|
|
2334
2339
|
if (!st || st.error) return;
|
|
2335
2340
|
const pct = st.contextUsage?.percent;
|
|
2336
|
-
|
|
2341
|
+
// Nothing spent yet: no badge, rather than "$0.000 · 0k · ctx 0%".
|
|
2342
|
+
$("cost").textContent = !st.tokens?.total ? "" : "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round(st.tokens.total / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
|
|
2337
2343
|
});
|
|
2338
2344
|
for (const ask of state.pendingAsks || []) onAsk(ask);
|
|
2339
2345
|
}
|
|
@@ -2591,6 +2597,13 @@ async function compactNow(instructions) {
|
|
|
2591
2597
|
|
|
2592
2598
|
/** Why the next reload is happening, when it is happening because somebody asked for it. */
|
|
2593
2599
|
let swapWhy = "";
|
|
2600
|
+
/**
|
|
2601
|
+
* A swap is waiting for its answer. The new session's events can arrive before the event that moves
|
|
2602
|
+
* this page to its id: reloaded on the first of them, the page read the old id's history, which the
|
|
2603
|
+
* server opens as that conversation in a pi of its own, and reloaded again on every event after,
|
|
2604
|
+
* each time without the reason. Events wait for the answer, which carries the new id.
|
|
2605
|
+
*/
|
|
2606
|
+
let swapPending = false;
|
|
2594
2607
|
|
|
2595
2608
|
/**
|
|
2596
2609
|
* Start a new session, or go back to one.
|
|
@@ -2606,7 +2619,18 @@ async function swap(call, body, why) {
|
|
|
2606
2619
|
// for a session you had just asked to start.
|
|
2607
2620
|
swapWhy = why;
|
|
2608
2621
|
const note = row("notice", "", "switching…");
|
|
2622
|
+
swapPending = true;
|
|
2609
2623
|
const r = await api(call, body);
|
|
2624
|
+
swapPending = false;
|
|
2625
|
+
const held = waiting.splice(0, waiting.length);
|
|
2626
|
+
if (r.success && !r.data?.cancelled && typeof r.sessionId === "string" && r.sessionId && r.sessionId !== SESSION) {
|
|
2627
|
+
// Moved first, then one reload, for the reason given; what waited is applied after it.
|
|
2628
|
+
moveTo(r.sessionId);
|
|
2629
|
+
waiting.push(...held);
|
|
2630
|
+
await resync(reloadReason());
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
for (const ev of held) applyEvent(ev);
|
|
2610
2634
|
// Mid-turn the server refuses rather than asking, and its refusal says what to do about it. A
|
|
2611
2635
|
// dialog here would be a second way to answer the same question, on the one screen where the
|
|
2612
2636
|
// turn it is about is already visible.
|
|
@@ -2638,7 +2662,7 @@ function pageCommand(text) {
|
|
|
2638
2662
|
else if (name === "resume") openSessions();
|
|
2639
2663
|
else if (name === "sessions") listSessionsHere();
|
|
2640
2664
|
else if (name === "session") row("system", "session", "usage: /session export [path] [--exclude-triggers] | /session import <path>\n/sessions lists this project's sessions; the header shows this one's model, cost and context.");
|
|
2641
|
-
else
|
|
2665
|
+
else newHere();
|
|
2642
2666
|
return true;
|
|
2643
2667
|
}
|
|
2644
2668
|
|
|
@@ -2657,7 +2681,8 @@ async function listSessionsHere() {
|
|
|
2657
2681
|
}
|
|
2658
2682
|
|
|
2659
2683
|
$("compact").onclick = () => compactNow("");
|
|
2660
|
-
|
|
2684
|
+
/** A new conversation in this page's pi: + New in this project, and /clear or /new typed. */
|
|
2685
|
+
function newHere() { swap("/session/new", {}, "new conversation — the one you left is under resume"); }
|
|
2661
2686
|
|
|
2662
2687
|
/** The sessions of this project, newest first — the conversation, not the filename. */
|
|
2663
2688
|
async function openSessions() {
|
|
@@ -2737,7 +2762,6 @@ function qrSvg(q) {
|
|
|
2737
2762
|
$("adddev").onclick = async () => {
|
|
2738
2763
|
const r = await api("/pair", {});
|
|
2739
2764
|
if (!r.success) { row("err", "error", r.error || "could not make a pairing code"); return; }
|
|
2740
|
-
$("pairCode").textContent = r.code;
|
|
2741
2765
|
|
|
2742
2766
|
// The address this browser reached, first: under tailscale serve that is the tailnet name, and
|
|
2743
2767
|
// it is the only one anybody had to configure. Then whatever else the machine can see — which is
|
|
@@ -2745,6 +2769,10 @@ $("adddev").onclick = async () => {
|
|
|
2745
2769
|
const thisWindow = location.origin + "/";
|
|
2746
2770
|
const loopback = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/)/.test(thisWindow);
|
|
2747
2771
|
const candidates = (loopback ? [] : [{ url: thisWindow, via: "this window" }]).concat(r.addresses || []);
|
|
2772
|
+
// No device but this one can reach the page: a code would be a grant nobody can use, so it is
|
|
2773
|
+
// retired at once and the dialog says only how to make the page reachable.
|
|
2774
|
+
if (candidates.length) $("pairCode").textContent = r.code;
|
|
2775
|
+
else { $("pairCode").textContent = ""; api("/pair", { cancel: true }); }
|
|
2748
2776
|
|
|
2749
2777
|
const pick = $("pairPick");
|
|
2750
2778
|
pick.innerHTML = "";
|
|
@@ -3162,10 +3190,19 @@ async function resync(why) {
|
|
|
3162
3190
|
// Whatever happened while we were reading: anything the transcript already covers is dropped by
|
|
3163
3191
|
// the same rule as always.
|
|
3164
3192
|
const held = waiting.splice(0, waiting.length);
|
|
3165
|
-
|
|
3193
|
+
// An event of an earlier epoch numbered at or before the transcript is the session that was left
|
|
3194
|
+
// (the server's numbering runs on across a swap): drawn, it would reload the page again.
|
|
3195
|
+
for (const ev of held) if (!(ev.seq && ev.epoch && ev.epoch !== epoch && ev.seq <= seen)) applyEvent(ev);
|
|
3166
3196
|
refresh();
|
|
3167
3197
|
}
|
|
3168
3198
|
|
|
3199
|
+
/** Point the page's routes and address at another conversation; the list marks it at the next poll. */
|
|
3200
|
+
function moveTo(id) {
|
|
3201
|
+
SESSION = id;
|
|
3202
|
+
history.replaceState(null, "", "/c/" + SESSION + "/");
|
|
3203
|
+
listAt = 0;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3169
3206
|
/**
|
|
3170
3207
|
* One event, against the numbering this page is counting on: already covered by the transcript, the
|
|
3171
3208
|
* next one in sequence, or evidence that something in between never arrived.
|
|
@@ -3174,14 +3211,9 @@ function applyEvent(ev) {
|
|
|
3174
3211
|
// While a reload runs, every event waits for it, a replayed one too: a reload started by the replay
|
|
3175
3212
|
// clears the feed, and the replay must not go on drawing into what it cleared (the history it reads
|
|
3176
3213
|
// has those events already). Applied once the transcript is back.
|
|
3177
|
-
if (resyncing) return void waiting.push(ev);
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
SESSION = ev.sessionId;
|
|
3181
|
-
history.replaceState(null, "", "/c/" + SESSION + "/");
|
|
3182
|
-
// And the list marks it at the next poll, not fifteen seconds later.
|
|
3183
|
-
listAt = 0;
|
|
3184
|
-
}
|
|
3214
|
+
if (resyncing || swapPending) return void waiting.push(ev);
|
|
3215
|
+
// pi is in another conversation now (a new session, a resume).
|
|
3216
|
+
if (ev.type === "page_moved" && ev.sessionId) moveTo(ev.sessionId);
|
|
3185
3217
|
if (!ev.seq) return handle(ev); // not numbered: nothing to reason about
|
|
3186
3218
|
if (epoch && ev.epoch && ev.epoch !== epoch) {
|
|
3187
3219
|
// A different run of the server: its numbers mean nothing next to the ones we were counting.
|
|
@@ -3219,7 +3251,7 @@ const convRows = [];
|
|
|
3219
3251
|
let focusAt = 0;
|
|
3220
3252
|
const HELD_LINE = "Your terminal has this conversation. What you send here is answered there.";
|
|
3221
3253
|
const BESIDE_LINE = "A terminal has this conversation open, and this page's pi has no pi-loops: what you send here is answered here, beside the terminal.";
|
|
3222
|
-
const PLACEHOLDER = $("input").placeholder || "Message pi
|
|
3254
|
+
const PLACEHOLDER = $("input").placeholder || "Message pi";
|
|
3223
3255
|
const phone = () => !!(window.matchMedia && window.matchMedia("(max-width:760px)").matches);
|
|
3224
3256
|
const spanOf = (cls, text) => {
|
|
3225
3257
|
const el = document.createElement("span");
|
|
@@ -3980,7 +4012,7 @@ async function startConversationIn(project) {
|
|
|
3980
4012
|
/** + New: in the project the list shows. The open conversation's starts a new session in this page's pi, as it always did. */
|
|
3981
4013
|
$("newConv").onclick = () => {
|
|
3982
4014
|
const project = listedProject();
|
|
3983
|
-
if (!otherProject(project)) return
|
|
4015
|
+
if (!otherProject(project)) return newHere();
|
|
3984
4016
|
const note = row("notice", "", "starting a new conversation in " + projectName(project) + "…");
|
|
3985
4017
|
startConversationIn(project)
|
|
3986
4018
|
.then((r) => (r.url ? location.assign(r.url) : void (note.textContent = "No new conversation in " + projectName(project) + ": " + r.error)))
|
|
@@ -4088,8 +4120,8 @@ function activityLine(i) {
|
|
|
4088
4120
|
else bits.push(num(i.findings) + " finding" + (num(i.findings) === 1 ? "" : "s"));
|
|
4089
4121
|
if (num(i.seconds)) bits.push(num(i.seconds) + "s");
|
|
4090
4122
|
if (num(i.costUsd)) bits.push("$" + num(i.costUsd).toFixed(3));
|
|
4091
|
-
// Only a card this conversation got is in the chat on the screen.
|
|
4092
|
-
if (i.
|
|
4123
|
+
// Only a card this conversation got is in the chat on the screen (src/run-card.ts runCardsHere).
|
|
4124
|
+
if (i.inChat === true) bits.push("in chat");
|
|
4093
4125
|
return { mark: i.stopped ? "■" : i.ok === false ? "✗" : "✓", text: name + " · " + bits.join(" · ") };
|
|
4094
4126
|
}
|
|
4095
4127
|
|
|
@@ -4278,7 +4310,7 @@ const WATCH_EXAMPLES = [
|
|
|
4278
4310
|
];
|
|
4279
4311
|
/** How long the modal waits for a plan or the end of the turn before it says pi did not answer. */
|
|
4280
4312
|
const WATCH_ANSWER_MS = 90000;
|
|
4281
|
-
const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|monthly|morning|evening|night
|
|
4313
|
+
const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|biweekly|fortnightly|monthly|quarterly|yearly|annually|(?:morning|afternoon|evening|night)s?|noon|midnight|tomorrow|tonight|today|at\s+(?:\d|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|noon|midnight)|@(?:hourly|daily|weekly|monthly)|in\s+(?:\d|an?\s+(?:hour|minute|day|week)|a\s+(?:few|couple\s+of)\s+(?:minutes|hours|days|weeks))|(?:next|this)\s+(?:week|month|quarter|year|weekend|morning|afternoon|evening|monday|tuesday|wednesday|thursday|friday|saturday|sunday)|(?:once|twice)\s+a\s+(?:day|week|month)|(?:mon|tue|wed|thu|fri|sat|sun)s?|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?|weekdays?|weeknights?|weekends?)\b|每|天|点|小时|分钟|周|明天|今晚|晚上|早上|下午|中午|凌晨|季度|(?:^|\s)(?:[\d*\/,-]+\s+){4}[\d*\/,-]+(?:\s|$)/i;
|
|
4282
4314
|
const WATCH_WAIT = /\b(?:when|whenever|if|once|until|as soon as|after|appears?|exists?|changes?|fails?|gets?|arrives?|merge[sd]?|finish(?:es|ed)?|lands?)\b|当|如果|一旦|等|出现|存在|变|红|失败|完成|合并/i;
|
|
4283
4315
|
/** No time word and no wait word: a thing to do now, not a watch. Generous on purpose: a doubt goes to the model. */
|
|
4284
4316
|
function readsLikeNow(sentence) {
|
|
@@ -4643,7 +4675,7 @@ $("watchText").addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.
|
|
|
4643
4675
|
*/
|
|
4644
4676
|
checkStream = (s) => {
|
|
4645
4677
|
// A reload ends by refreshing, and a refresh polls: without this the two call each other.
|
|
4646
|
-
if (resyncing || !s || !s.epoch) return;
|
|
4678
|
+
if (resyncing || swapPending || !s || !s.epoch) return;
|
|
4647
4679
|
if (s.epoch !== epoch) {
|
|
4648
4680
|
epoch = s.epoch;
|
|
4649
4681
|
seen = s.seq ?? seen;
|