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/CHANGELOG.md +15 -1
- package/README.md +14 -0
- package/package.json +2 -2
- package/server/index.js +64 -0
- package/server/openapi.js +21 -1
- package/server/preferences.js +296 -0
- package/server/runner.js +2 -1
- package/server/update.js +136 -0
- package/web/css/app.css +83 -1
- package/web/index.html +5 -0
- package/web/js/app.js +350 -18
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
|
-
|
|
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: '
|
|
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('
|
|
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))
|
|
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
|
|