wisp-agents 0.6.0 → 0.7.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.
package/web/js/app.js CHANGED
@@ -197,7 +197,29 @@ function renderFeedHead(needs, working) {
197
197
  // never shifts the feed, and it stays reachable deep inside a long open card.
198
198
  const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (C) · Esc collapses the focused one"><span></span><kbd>C</kbd></button>`);
199
199
  document.body.append(collapseAllBtn);
200
- const collapseAll = () => [...S.open].forEach((id) => collapse(id));
200
+ // Collapsing shrinks everything above the reader, which would leave them staring
201
+ // at unrelated cards far down the feed. Keep the card they were reading in view.
202
+ const collapseAll = () => {
203
+ const anchor = readingCard();
204
+ [...S.open].forEach((id) => collapse(id));
205
+ if (!anchor) return;
206
+ setFocus(anchor, { scroll: false });
207
+ const card = S.nodes.get(anchor)?.card;
208
+ if (card?.isConnected) card.scrollIntoView({ block: 'center' });
209
+ };
210
+ // The open card covering the most of the viewport; the focused one breaks ties.
211
+ function readingCard() {
212
+ let best = null;
213
+ let bestArea = 0;
214
+ for (const id of S.open) {
215
+ const card = S.nodes.get(id)?.card;
216
+ if (!card?.isConnected) continue;
217
+ const r = card.getBoundingClientRect();
218
+ const seen = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0) + (id === S.focus ? 1 : 0);
219
+ if (seen > bestArea) (best = id), (bestArea = seen);
220
+ }
221
+ return best || (S.open.has(S.focus) ? S.focus : null);
222
+ }
201
223
  collapseAllBtn.addEventListener('click', collapseAll);
202
224
  function placeCollapseAll() {
203
225
  const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
@@ -434,6 +456,20 @@ function autosize(ta, max = 340) {
434
456
 
435
457
  let launchProject = load('wisp.launchProject', null);
436
458
 
459
+ // The model is remembered per harness (ids aren't portable between them).
460
+ function savedModel(id) {
461
+ const d = S.config.defaults;
462
+ const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
463
+ const models = S.agents.find((a) => a.id === id)?.models || [];
464
+ return models.some((m) => m.id === want) ? want : '';
465
+ }
466
+ // Thinking level too, and only if the chosen model still accepts it.
467
+ function savedEffort(id, model) {
468
+ const d = S.config.defaults;
469
+ const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
470
+ return effortsFor(id, model).includes(want) ? want : '';
471
+ }
472
+
437
473
  function renderLauncher() {
438
474
  const box = $('#launcher');
439
475
  const mode = S.config.defaults.mode || 'edits';
@@ -486,19 +522,6 @@ function renderLauncher() {
486
522
  };
487
523
  paintMode();
488
524
  modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
489
- // The model is remembered per harness (ids aren't portable between them).
490
- const savedModel = (id) => {
491
- const d = S.config.defaults;
492
- const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
493
- const models = S.agents.find((a) => a.id === id)?.models || [];
494
- return models.some((m) => m.id === want) ? want : '';
495
- };
496
- // Thinking level too, and only if the chosen model still accepts it.
497
- const savedEffort = (id, model) => {
498
- const d = S.config.defaults;
499
- const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
500
- return effortsFor(id, model).includes(want) ? want : '';
501
- };
502
525
  let curModel = savedModel(agent?.id);
503
526
  let curEffort = savedEffort(agent?.id, curModel);
504
527
  const runnerBtn = $('#runner', box);
@@ -587,7 +610,7 @@ function modeMenu(anchor, current, pick) {
587
610
  menu.addEventListener('keydown', (ev) => {
588
611
  const items = $$('button', menu);
589
612
  const i = items.indexOf(document.activeElement);
590
- if (ev.key === 'Escape') (close(), anchor.focus());
613
+ if (ev.key === 'Escape') (ev.stopPropagation(), close(), anchor.focus());
591
614
  else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
592
615
  ev.preventDefault();
593
616
  items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
@@ -1473,6 +1496,7 @@ function taskMenu(id, e) {
1473
1496
  <div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
1474
1497
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
1475
1498
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
1499
+ <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
1476
1500
  ${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
1477
1501
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
1478
1502
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
@@ -1487,6 +1511,7 @@ function taskMenu(id, e) {
1487
1511
  if (a === 'open') expand(id);
1488
1512
  if (a === 'collapse') collapse(id);
1489
1513
  if (a === 'rename') rename(id);
1514
+ if (a === 'rerun') rerun(id);
1490
1515
  if (a === 'done') toggleDone(id);
1491
1516
  if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
1492
1517
  if (a === 'delete') deleteSession(id);
@@ -1506,6 +1531,96 @@ async function deleteSession(id) {
1506
1531
  await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
1507
1532
  }
1508
1533
 
1534
+ // Launch the task again as a new session in the same folder: same prompt and attachments,
1535
+ // optionally edited, on whichever harness, model and thinking level you pick. The original stays.
1536
+ async function rerun(id) {
1537
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch((err) => toast(esc(err.message), { tone: 'bad' })));
1538
+ const first = s?.turns?.[0];
1539
+ if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
1540
+ const has = (a) => S.agents.some((x) => x.id === a && x.available);
1541
+ let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
1542
+ const models = S.agents.find((x) => x.id === agent)?.models || [];
1543
+ let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
1544
+ let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
1545
+ let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || 'edits';
1546
+ const files = first.attachments || [];
1547
+
1548
+ const node = el(`
1549
+ <div class="modal rerun-modal">
1550
+ <header><h2>Rerun task</h2><button class="icon-btn close">${icon('x')}</button></header>
1551
+ <div class="rr-box">
1552
+ <textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
1553
+ ${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
1554
+ <div class="l-bar">
1555
+ <button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
1556
+ <button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
1557
+ <span class="grow"></span>
1558
+ <button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
1559
+ </div>
1560
+ </div>
1561
+ <p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
1562
+ </div>`);
1563
+ const close = overlay(node);
1564
+ $('.close', node).onclick = close;
1565
+ const ta = $('.rr-prompt', node);
1566
+ ta.value = first.prompt;
1567
+ autosize(ta);
1568
+ const modeBtn = $('[data-a="mode"]', node);
1569
+ const paintMode = () => {
1570
+ const m = MODES.find((x) => x.id === mode) || MODES[1];
1571
+ modeBtn.className = `mode-btn m-${m.id}`;
1572
+ modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
1573
+ modeBtn.title = m.hint;
1574
+ };
1575
+ const runnerBtn = $('[data-a="runner"]', node);
1576
+ const paintRunner = () => {
1577
+ const a = S.agents.find((x) => x.id === agent);
1578
+ const m = (a?.models || []).find((x) => x.id === model);
1579
+ runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent)}</span><span class="rb-h">${esc(a?.name || agentName(agent))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`;
1580
+ };
1581
+ paintMode();
1582
+ paintRunner();
1583
+ modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
1584
+ // Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
1585
+ runnerBtn.onclick = () =>
1586
+ runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort }, (a, m, e) => {
1587
+ [agent, model, effort] = [a, m, e];
1588
+ paintRunner();
1589
+ });
1590
+
1591
+ const go = async () => {
1592
+ const prompt = ta.value.trim();
1593
+ if (!prompt) return ta.focus();
1594
+ const btn = $('[data-a="go"]', node);
1595
+ btn.disabled = true;
1596
+ try {
1597
+ const next = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) } });
1598
+ S.sessions.set(next.id, next);
1599
+ close();
1600
+ if (S.filter !== 'all' && S.filter !== next.projectId) {
1601
+ S.filter = 'all';
1602
+ save('wisp.filter', S.filter);
1603
+ }
1604
+ renderFeed();
1605
+ setFocus(next.id);
1606
+ toast(`Rerunning on ${esc(agentName(agent))}`);
1607
+ } catch (err) {
1608
+ btn.disabled = false;
1609
+ toast(esc(err.message), { tone: 'bad' });
1610
+ }
1611
+ };
1612
+ $('[data-a="go"]', node).onclick = go;
1613
+ ta.addEventListener('keydown', (e) => {
1614
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) (e.preventDefault(), go());
1615
+ if (e.key === 'Tab' && e.shiftKey) {
1616
+ e.preventDefault();
1617
+ mode = MODES[(MODES.findIndex((m) => m.id === mode) + 1) % MODES.length].id;
1618
+ paintMode();
1619
+ }
1620
+ });
1621
+ requestAnimationFrame(() => (ta.focus(), ta.setSelectionRange(ta.value.length, ta.value.length)));
1622
+ }
1623
+
1509
1624
  async function setArchived(id, archived) {
1510
1625
  try {
1511
1626
  await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
@@ -1716,6 +1831,7 @@ function openSettings() {
1716
1831
  <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1717
1832
  <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1718
1833
  <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1834
+ <div class="setting"><span>Memory</span><span class="learn-hint muted xs"></span><button class="ghost xs" id="openMemory">Open</button><button class="switch" id="learn" role="switch"></button></div>
1719
1835
  <div class="agents">${S.agents
1720
1836
  .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1721
1837
  .join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
@@ -1762,6 +1878,160 @@ function openSettings() {
1762
1878
  paintTele();
1763
1879
  };
1764
1880
  paintTele();
1881
+ // Memory: one line here (on/off and a summary); the lists and retros live in their own view.
1882
+ const learn = $('#learn', node);
1883
+ const paintLearn = async () => {
1884
+ learn.setAttribute('aria-checked', S.config.defaults.learn !== 'off');
1885
+ $('.learn-hint', node).textContent = memorySummary(await api('/preferences').catch(() => null));
1886
+ };
1887
+ learn.onclick = async () => {
1888
+ await setLearning(S.config.defaults.learn === 'off');
1889
+ paintLearn();
1890
+ };
1891
+ $('#openMemory', node).onclick = () => openMemory();
1892
+ node.paintLearn = paintLearn;
1893
+ paintLearn();
1894
+ }
1895
+
1896
+ // --- memory: what the background retros learned ---------------------------------------------
1897
+
1898
+ const MEM_KINDS = [
1899
+ { kind: 'preferences', label: 'Preferences', one: 'preference', icon: 'spark', hint: 'How you like agents to work', empty: 'Corrections and “always / never” asks from your sessions land here.' },
1900
+ { kind: 'lessons', label: 'Lessons', one: 'lesson', icon: 'check', hint: 'A mistake an agent made, and the way that worked', empty: 'When an agent hits a wall and finds the fix, the fix lands here.' },
1901
+ ];
1902
+ const MEM_CAP = 10;
1903
+ const memCount = (x) => x.preferences.length + x.lessons.length;
1904
+ const projectName = (p) => S.config.projects.find((x) => x.path === p)?.name || String(p || '').split(/[\\/]/).pop();
1905
+
1906
+ async function setLearning(on) {
1907
+ await api('/config', { method: 'PATCH', body: { defaults: { learn: on ? 'on' : 'off' } } });
1908
+ await loadConfig();
1909
+ }
1910
+
1911
+ function memorySummary(data) {
1912
+ if (!data) return '';
1913
+ if (!data.enabled) return 'Off · nothing learned or applied';
1914
+ const n = memCount(data.global) + data.projects.reduce((a, p) => a + memCount(p), 0);
1915
+ const last = data.history[0];
1916
+ if (data.running) return `Retro running · ${n} remembered`;
1917
+ return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
1918
+ }
1919
+
1920
+ function openMemory({ tab = 'global' } = {}) {
1921
+ const node = el(`<div class="modal memory-modal">
1922
+ <header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
1923
+ <p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
1924
+ <div class="mem-tabs" role="tablist"></div>
1925
+ <div class="mem-body"></div>
1926
+ </div>`);
1927
+ const close = overlay(node);
1928
+ $('.close', node).onclick = close;
1929
+ let data;
1930
+ let editing = null; // { scope, kind, index } (index -1 = adding)
1931
+
1932
+ const render = async () => {
1933
+ data = (await api('/preferences').catch(() => null)) || data;
1934
+ if (!data || !node.isConnected) return;
1935
+ // Projects with something learned, plus the one picked in the launcher so you can add to it.
1936
+ const current = projectById(launchProject);
1937
+ const projects = data.projects.filter((p) => memCount(p) || p.path === current?.path);
1938
+ if (current && !projects.some((p) => p.path === current.path)) projects.push({ path: current.path, name: current.name, preferences: [], lessons: [] });
1939
+ const scopes = [{ scope: 'global', name: 'Everywhere', ...data.global }, ...projects.map((p) => ({ scope: p.path, ...p }))];
1940
+ if (tab !== 'retros' && !scopes.some((x) => x.scope === tab)) tab = 'global';
1941
+ $('#memOn', node).setAttribute('aria-checked', data.enabled);
1942
+ $('.mem-live', node).innerHTML = data.running ? `<span class="orb live"></span>Retro running` : data.enabled ? '' : 'Off';
1943
+ $('.mem-tabs', node).innerHTML =
1944
+ scopes.map((x) => `<button role="tab" data-tab="${esc(x.scope)}" class="${tab === x.scope ? 'on' : ''}" title="${esc(x.scope === 'global' ? 'Applies in every project' : x.scope.replace(S.config.home, '~'))}">${esc(x.name)}<span>${memCount(x)}</span></button>`).join('') +
1945
+ `<i></i><button role="tab" data-tab="retros" class="${tab === 'retros' ? 'on' : ''}">Retros<span>${data.history.length}</span></button>`;
1946
+ const body = $('.mem-body', node);
1947
+ body.classList.toggle('off', !data.enabled);
1948
+ const banner = data.enabled ? '' : `<div class="mem-off">${icon('info')}<span>Memory is off: no retros run, and nothing below reaches your agents.</span><button class="ghost xs" data-act="on">Turn on</button></div>`;
1949
+ body.innerHTML = banner + (tab === 'retros' ? retrosView() : scopeView(scopes.find((x) => x.scope === tab)));
1950
+ const input = $('.mem-edit input', body);
1951
+ if (input) input.focus(), input.setSelectionRange(input.value.length, input.value.length);
1952
+ };
1953
+
1954
+ // Which retro added an item, if it's still in the log.
1955
+ const origin = (scope, kind, text) => data.history.find((h) => h.added?.some((a) => a.text === text && a.kind === kind && (a.scope === 'global') === (scope === 'global')));
1956
+
1957
+ const scopeView = (x) => {
1958
+ const chars = [...(x.scope === 'global' ? [] : [...data.global.preferences, ...data.global.lessons]), ...x.preferences, ...x.lessons].reduce((a, t) => a + t.length + 3, 0);
1959
+ const budget = x.scope === 'global' ? `Added to every prompt · about ${Math.ceil(chars / 4)} tokens` : `Added to prompts in ${esc(x.name)}, with Everywhere · about ${Math.ceil(chars / 4)} tokens`;
1960
+ return `<p class="mem-budget muted xs">${budget}</p>` + MEM_KINDS.map((k) => {
1961
+ const items = x[k.kind];
1962
+ const row = (t, i) => {
1963
+ if (editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === i) return editRow(t, k);
1964
+ const o = origin(x.scope, k.kind, t);
1965
+ return `<div class="mem-item" data-i="${i}"><span class="mem-ico">${icon(k.icon)}</span><div class="mem-main"><p>${esc(t)}</p>${o ? `<span class="mem-src">${o.sessionId && S.sessions.has(o.sessionId) ? `<a href="#" data-session="${esc(o.sessionId)}">${esc(o.title || 'session')}</a>` : esc(o.title || 'a session')} · ${ago(o.at)}</span>` : ''}</div>
1966
+ <span class="mem-acts"><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="forget" title="Forget">${icon('trash')}</button></span></div>`;
1967
+ };
1968
+ const adding = editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === -1;
1969
+ return `<section class="mem-sec" data-scope="${esc(x.scope)}" data-kind="${k.kind}">
1970
+ <h4>${k.label}<span class="muted xs">${k.hint}</span><em class="${items.length >= MEM_CAP ? 'full' : ''}" title="At most ${MEM_CAP}; a retro drops the least useful when full">${items.length}/${MEM_CAP}</em></h4>
1971
+ ${items.map(row).join('') || (adding ? '' : `<p class="mem-empty">${k.empty}</p>`)}
1972
+ ${adding ? editRow('', k) : `<button class="mem-add" data-act="add">${icon('plus')}Add a ${k.one}</button>`}
1973
+ </section>`;
1974
+ }).join('');
1975
+ };
1976
+ const editRow = (t, k) => `<div class="mem-item mem-edit"><span class="mem-ico">${icon(k.icon)}</span><input value="${esc(t)}" maxlength="180" spellcheck="false" placeholder="One short sentence, e.g. “Run tests with node --test”"><span class="muted xs">↵ save · esc cancel</span></div>`;
1977
+
1978
+ const retrosView = () => {
1979
+ const live = data.running ? `<div class="tl running"><span class="tl-dot"><span class="orb live"></span></span><div class="tl-main"><div class="tl-head"><b>${esc(data.running.title || 'session')}</b><span class="muted xs">now</span></div><p class="muted xs">Reading ${data.running.turns} turn${data.running.turns === 1 ? '' : 's'} with ${esc(agentName(data.running.agent))}…</p></div></div>` : '';
1980
+ const where = (a) => `<span class="tl-tag">${a.scope === 'global' ? 'everywhere' : 'project'} · ${a.kind === 'lessons' ? 'lesson' : 'preference'}</span>`;
1981
+ const entries = data.history.map((h) => {
1982
+ const status = h.status || 'learned';
1983
+ const lines =
1984
+ status === 'failed' ? `<p class="tl-err">${esc(h.error || 'The retro run failed.')}</p>`
1985
+ : status === 'nothing' ? '<p class="muted xs">Nothing new worth remembering.</p>'
1986
+ : [...h.added.map((a) => `<p class="tl-add">${esc(a.text ?? a)} ${a.kind ? where(a) : ''}</p>`), ...h.removed.map((a) => `<p class="tl-del"><s>${esc(a.text ?? a)}</s> ${a.kind ? where(a) : ''}</p>`)].join('');
1987
+ const title = S.sessions.has(h.sessionId) ? `<a href="#" data-session="${esc(h.sessionId)}">${esc(h.title || 'session')}</a>` : esc(h.title || 'session');
1988
+ return `<div class="tl ${status}"><span class="tl-dot"></span><div class="tl-main"><div class="tl-head"><b>${title}</b>${h.project ? `<span class="muted xs">${esc(projectName(h.project))}</span>` : ''}<span class="muted xs">${ago(h.at)}</span></div>${lines}</div></div>`;
1989
+ });
1990
+ return live + entries.join('') || `<p class="mem-empty">No retros yet. One runs 90 s after a session finishes, or as soon as you mark it done or archive it.</p>`;
1991
+ };
1992
+
1993
+ const save = async (scope, kind, items) => {
1994
+ await api('/preferences', { method: 'PUT', body: { scope, kind, items } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
1995
+ editing = null;
1996
+ await render();
1997
+ };
1998
+ const commit = (input) => {
1999
+ if (!editing) return;
2000
+ const { scope, kind, index } = editing;
2001
+ editing = null; // Enter then blur must not save twice
2002
+ const list = ((scope === 'global' ? data.global : data.projects.find((p) => p.path === scope)) || { [kind]: [] })[kind].slice();
2003
+ const v = input.value.trim();
2004
+ if (index === -1) v ? list.push(v) : null;
2005
+ else v ? (list[index] = v) : list.splice(index, 1);
2006
+ save(scope, kind, list);
2007
+ };
2008
+
2009
+ node.addEventListener('click', async (e) => {
2010
+ const t = e.target.closest('[data-tab],[data-act],[data-session]');
2011
+ if (!t) return;
2012
+ e.preventDefault();
2013
+ if (t.dataset.tab) return (tab = t.dataset.tab), (editing = null), render();
2014
+ if (t.dataset.session) return close(), reveal(t.dataset.session);
2015
+ const act = t.dataset.act;
2016
+ if (act === 'on') return setLearning(true).then(render);
2017
+ const sec = t.closest('.mem-sec');
2018
+ const { scope, kind } = sec.dataset;
2019
+ const index = Number(t.closest('.mem-item')?.dataset.i ?? -1);
2020
+ if (act === 'add' || act === 'edit') return (editing = { scope, kind, index }), render();
2021
+ if (act === 'forget') {
2022
+ const list = (scope === 'global' ? data.global : data.projects.find((p) => p.path === scope))[kind].filter((_, i) => i !== index);
2023
+ return save(scope, kind, list);
2024
+ }
2025
+ });
2026
+ node.addEventListener('keydown', (e) => {
2027
+ if (!e.target.matches('.mem-edit input')) return;
2028
+ if (e.key === 'Enter') e.preventDefault(), commit(e.target);
2029
+ if (e.key === 'Escape') e.preventDefault(), e.stopPropagation(), (editing = null), render();
2030
+ });
2031
+ node.addEventListener('focusout', (e) => e.target.matches('.mem-edit input') && editing && commit(e.target));
2032
+ $('#memOn', node).onclick = async () => (await setLearning(!data?.enabled), render());
2033
+ node.refresh = () => !editing && render();
2034
+ render();
1765
2035
  }
1766
2036
 
1767
2037
  // Say once that anonymous stats are on, with a way out.
@@ -1785,6 +2055,7 @@ function shortcuts() {
1785
2055
  ['⌫', 'Delete item'],
1786
2056
  ['T', 'Take over in terminal'],
1787
2057
  ['R', 'Replay presentation'],
2058
+ ['⇧R', 'Rerun with another harness, model or thinking level'],
1788
2059
  ['1–9', 'Pick an answer when asked'],
1789
2060
  ['↵', 'Launch / send'],
1790
2061
  ['⇧↵', 'New line'],
@@ -1831,6 +2102,7 @@ function paletteItems() {
1831
2102
  if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
1832
2103
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
1833
2104
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
2105
+ items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
1834
2106
  items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
1835
2107
  items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
1836
2108
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
@@ -1861,7 +2133,8 @@ function paletteItems() {
1861
2133
  items.push(
1862
2134
  { group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
1863
2135
  { group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
1864
- { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences editor terminal notifications', run: openSettings },
2136
+ { group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
2137
+ { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
1865
2138
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
1866
2139
  );
1867
2140
 
@@ -1923,7 +2196,13 @@ function connect() {
1923
2196
  // fold it unless the user is actually watching it.
1924
2197
  const n = S.nodes.get(s.id);
1925
2198
  if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
2199
+ // A state change moves the card to another section (a follow-up sends it from
2200
+ // "Needs you" to "Working"). If the user is looking at it, follow it there
2201
+ // instead of leaving them on whatever slid into its place. (Marking done
2202
+ // moves focus to the next item on purpose, so that case is left alone.)
2203
+ const pin = prev && prev.state !== s.state && s.state !== 'done' && n?.card.isConnected && isInView(n.card) ? n.card.getBoundingClientRect().top : null;
1926
2204
  renderFeed();
2205
+ if (pin !== null && n.card.isConnected) stage.scrollTop += n.card.getBoundingClientRect().top - pin;
1927
2206
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
1928
2207
  });
1929
2208
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
@@ -1938,7 +2217,12 @@ function connect() {
1938
2217
  renderFeed();
1939
2218
  });
1940
2219
  es.addEventListener('config', () => loadConfig());
1941
- es.addEventListener('hello', () => loadSessions());
2220
+ es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
2221
+ es.addEventListener('preferences', () => {
2222
+ $('.memory-modal')?.refresh();
2223
+ $('.settings-modal')?.paintLearn?.();
2224
+ });
2225
+ es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
1942
2226
  }
1943
2227
 
1944
2228
  async function loadSessions() {
@@ -1956,6 +2240,48 @@ async function loadConfig() {
1956
2240
  renderLaunchChips();
1957
2241
  }
1958
2242
 
2243
+ // --- updates ----------------------------------------------------------------------------
2244
+
2245
+ // The server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
2246
+ let update = null;
2247
+ const loadUpdate = () => api('/update').then(renderUpdate).catch(() => {});
2248
+ function renderUpdate(u) {
2249
+ update = u;
2250
+ const b = $('#updateBtn');
2251
+ const busy = u.status === 'installing' || u.status === 'restarting';
2252
+ b.hidden = !u.available && !busy;
2253
+ b.disabled = busy;
2254
+ b.classList.toggle('busy', busy);
2255
+ b.querySelector('span').textContent = u.status === 'installing' ? 'Updating…' : u.status === 'restarting' ? 'Restarting…' : `Update to ${u.latest}`;
2256
+ b.title = u.auto ? `wisp ${u.current} → ${u.latest}: install and restart` : `wisp ${u.latest} is out. Run \`${u.command}\` to update`;
2257
+ }
2258
+
2259
+ async function installUpdate() {
2260
+ if (!update?.available) return;
2261
+ if (!update.auto) return toast(`wisp <b>${esc(update.latest)}</b> is out. Run <code>${esc(update.command)}</code>`, { action: { label: 'Copy', run: () => copy(update.command) }, timeout: 8000 });
2262
+ const post = (force) => api('/update', { method: 'POST', body: { force } });
2263
+ try {
2264
+ await post(false).catch(async (err) => {
2265
+ const running = [...S.sessions.values()].filter((s) => s.state === 'running' || s.state === 'waiting').length;
2266
+ if (err.message !== 'agents are running') throw err;
2267
+ if (!confirm(`Update to wisp ${update.latest} and restart? ${running || 'Some'} running agent${running === 1 ? '' : 's'} will be stopped.`)) throw null;
2268
+ return post(true);
2269
+ });
2270
+ } catch (err) {
2271
+ if (err) toast(esc(err.message), { tone: 'bad', timeout: 10_000, action: { label: 'Copy command', run: () => copy(update.command) } });
2272
+ return;
2273
+ }
2274
+ renderUpdate({ ...update, status: 'restarting' });
2275
+ // The server exits and a new one takes the port: reload once it answers with the new version.
2276
+ const from = update.current;
2277
+ const t0 = Date.now();
2278
+ const poll = setInterval(async () => {
2279
+ const h = await fetch('/api/health').then((r) => r.json()).catch(() => null);
2280
+ if (h?.version && h.version !== from) return clearInterval(poll), location.reload();
2281
+ if (Date.now() - t0 > 120_000) clearInterval(poll), toast('wisp did not come back. Start it again from your terminal.', { tone: 'bad', timeout: 15_000 });
2282
+ }, 1000);
2283
+ }
2284
+
1959
2285
  // --- keyboard ---------------------------------------------------------------------------
1960
2286
 
1961
2287
  const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
@@ -1976,7 +2302,10 @@ document.addEventListener('keydown', (e) => {
1976
2302
  if (e.key === 'Escape') {
1977
2303
  if (!$('#overlay').hidden) return closeOverlay();
1978
2304
  if (typing) return e.target.blur();
1979
- if (S.focus && S.open.has(S.focus)) collapse(S.focus);
2305
+ if (S.focus && S.open.has(S.focus)) {
2306
+ collapse(S.focus);
2307
+ S.nodes.get(S.focus)?.card.scrollIntoView({ block: 'nearest' });
2308
+ }
1980
2309
  return;
1981
2310
  }
1982
2311
  if (typing || e.metaKey || e.ctrlKey || e.altKey || !$('#overlay').hidden) return;
@@ -2007,6 +2336,7 @@ document.addEventListener('keydown', (e) => {
2007
2336
  e.preventDefault();
2008
2337
  deleteSession(id);
2009
2338
  } else if (k === 't' && id) takeOver(id);
2339
+ else if (k === 'r' && e.shiftKey && id) rerun(id);
2010
2340
  else if (k === 'r' && id) replay(id);
2011
2341
  else if (k === '/' && id) {
2012
2342
  e.preventDefault();
@@ -2023,6 +2353,7 @@ document.addEventListener('keydown', (e) => {
2023
2353
 
2024
2354
  $('#settingsBtn').onclick = openSettings;
2025
2355
  $('#searchBtn').onclick = openPalette;
2356
+ $('#updateBtn').onclick = installUpdate;
2026
2357
  $('#searchBtn').title = `Search (${combo('K')})`;
2027
2358
  $('#searchBtn .mod-k').textContent = combo('K');
2028
2359
  $('#secDone .toggle-done').onclick = () => {
@@ -2069,6 +2400,7 @@ function route() {
2069
2400
 
2070
2401
  window.addEventListener('hashchange', route);
2071
2402
  window.addEventListener('focus', () => {
2403
+ loadUpdate();
2072
2404
  for (const id of S.open) if (S.sessions.get(id)?.unread) scheduleRefetch(id);
2073
2405
  });
2074
2406