@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/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, or type the code from <b>add device</b> on a browser that is signed in:</p>
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 session">find</button>
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 session as HTML">save</button>
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" aria-label="Change theme">theme: system</button>
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="Session actions" aria-label="Session actions">⋯</button>
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 session…" ><span id="findn" class="notice"></span></div>
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 — / for commands, @ for files, Enter sends"></textarea>
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="Session actions" role="dialog">
646
- <h3>Session</h3><div id="menuBody"></div><div id="sid" class="sid"></div>
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
- num(g.quietStreak) >= 3 ? "quiet ×" + num(g.quietStreak) : "",
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
- // The prompt is shown twice — a two-line peek in the summary, the whole of it in the body —
2032
- // rather than once and cut, which is what the 90-character slice was: a preview that hid the
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">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(clock(j.lastFiredAt)) : "") + (j.skippedOverlap ? " · skipped overlaps " + num(j.skippedOverlap) : "") + "</div>" +
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>rule</b><span class="m">' + (r.enabled ? "" : "disabled · ") + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
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(r.condition) + "</pre>" +
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
- $("cost").textContent = "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round((st.tokens?.total ?? 0) / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
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 swap("/session/new", {}, "new session — the one you left is under resume");
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
- $("clear").onclick = () => swap("/session/new", {}, "new session — the one you left is under resume");
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
- for (const ev of held) applyEvent(ev);
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
- if (ev.type === "page_moved" && ev.sessionId) {
3179
- // pi is in another conversation now (a new session, a resume): its routes and its address move.
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 — / for commands, @ for files, Enter sends";
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 $("clear").onclick();
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.carded && i.fromThisConversation === true) bits.push("in chat");
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|noon|midnight|tomorrow|tonight|today|at\s+\d|@(?:hourly|daily|weekly|monthly)|in\s+(?:\d|an?\s+(?:hour|minute|day|week))|(?:once|twice)\s+a\s+(?:day|week|month)|mon|tue|wed|thu|fri|sat|sun|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?|weekdays?|weeknights?|weekends?)|每|天|点|小时|分钟|周|明天|晚上|早上|(?:^|\s)(?:[\d*\/,-]+\s+){4}[\d*\/,-]+(?:\s|$)/i;
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;