lampson 0.1.3 → 0.2.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/.env.example +9 -0
- package/README.md +87 -4
- package/bin/lampson.js +1 -1
- package/chat.syn +133 -0
- package/cli.syn +68 -0
- package/hub.tpl.syn +127 -0
- package/lampson.ps1 +77 -51
- package/lampson.sh +68 -22
- package/lib/agents.syn +1 -1
- package/lib/approvals.syn +179 -0
- package/lib/lsp.syn +10 -1
- package/lib/mcp.syn +10 -1
- package/lib/permission.syn +18 -0
- package/lib/sched_run.syn +161 -0
- package/lib/schedule.syn +660 -0
- package/lib/session.syn +38 -1
- package/lib/settings.syn +66 -3
- package/lib/skills.syn +2 -0
- package/lib/tools.syn +94 -2
- package/lib/workspaces.syn +555 -0
- package/package.json +3 -1
- package/public/css/chat.css +56 -0
- package/public/css/hub.css +12 -0
- package/public/css/layout.css +97 -0
- package/public/css/panel.css +125 -0
- package/public/css/sidebar.css +80 -0
- package/public/css/tokens.css +57 -0
- package/public/hub.html +36 -0
- package/public/index.html +51 -1197
- package/public/js/agents.js +31 -0
- package/public/js/app.js +21 -0
- package/public/js/approvals.js +26 -0
- package/public/js/chat.js +92 -0
- package/public/js/config.js +100 -0
- package/public/js/core.js +91 -0
- package/public/js/events.js +31 -0
- package/public/js/hub.js +40 -0
- package/public/js/lamps.js +112 -0
- package/public/js/lsp.js +81 -0
- package/public/js/mcp.js +74 -0
- package/public/js/memory.js +17 -0
- package/public/js/panel.js +101 -0
- package/public/js/procs.js +45 -0
- package/public/js/schedules.js +118 -0
- package/public/js/sessions.js +69 -0
- package/public/js/sidebar.js +33 -0
- package/public/js/terminal.js +49 -0
- package/public/js/theme.js +6 -0
- package/public/js/todo.js +10 -0
- package/public/js/tree.js +53 -0
- package/public/js/update.js +14 -0
- package/public/js/workspaces.js +83 -0
- package/skills/lampson/SKILL.md +21 -0
- package/skills/synsema/SKILL.md +4 -1
- package/web.syn +165 -55
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// agents.js — subagentes (tool delegate): lista + log vivo por hijo
|
|
2
|
+
let agentOpen = null, agentTimer = null, agentPaint = null;
|
|
3
|
+
async function loadAgents() {
|
|
4
|
+
let r; try { r = await (await fetch(BASE + '/api/agents')).json(); } catch (e) { return; }
|
|
5
|
+
const box = $('#agentsBox'); box.innerHTML = '';
|
|
6
|
+
const list = (r.agents || []).slice().reverse(); const act = list.filter(a => a.status === 'running').length;
|
|
7
|
+
$('#agentCount').textContent = list.length ? (act ? `${act} ● · ${list.length}` : `${list.length}`) : ''; autoSec('agents', act > 0);
|
|
8
|
+
if (!list.length) { box.innerHTML = '<div class="p none">ninguno</div>'; return; }
|
|
9
|
+
for (const a of list) {
|
|
10
|
+
const run = a.status === 'running';
|
|
11
|
+
const d = document.createElement('div'); d.className = 'p' + (run ? ' run' : '') + (agentOpen === a.id ? ' active' : ''); d.title = `${a.status} · ${a.steps} pasos · ${a.tokens} tokens\n${a.brief}`;
|
|
12
|
+
d.innerHTML = `<span class="st"></span><span class="nm">${esc(a.id)}</span><span class="cm">${esc(String(a.brief || '').split('\n')[0].slice(0, 90))}</span>` + (run ? '<button title="parar: devuelve el informe parcial">■</button>' : '');
|
|
13
|
+
d.onclick = () => openAgent(a.id);
|
|
14
|
+
const b = d.querySelector('button'); if (b) b.onclick = async (ev) => { ev.stopPropagation(); await api(BASE + '/api/agents/stop', { id: a.id }); setTimeout(loadAgents, 1500); };
|
|
15
|
+
box.appendChild(d);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function openAgent(id) {
|
|
19
|
+
agentOpen = id; procOpen = null; document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active'));
|
|
20
|
+
const paint = async () => {
|
|
21
|
+
let r; try { r = await (await fetch(BASE + '/api/agents/log?id=' + encodeURIComponent(id))).json(); } catch (e) { return false; }
|
|
22
|
+
const meta = (r.agent || {});
|
|
23
|
+
$('#vpath').textContent = 'subagente ' + id; $('#vmeta').textContent = meta.status === 'running' ? `● corriendo · ${meta.steps} pasos · log en vivo` : `○ ${meta.status || 'terminado'} · ${meta.steps ?? '?'} pasos · ${meta.tokens ?? '?'} tokens`;
|
|
24
|
+
const pre = $('#vbody'); pre.className = 'log'; pre.textContent = (r.log || '(sin salida todavía)') + (meta.status && meta.status !== 'running' && meta.text ? '\n\n── informe ──\n' + meta.text : '');
|
|
25
|
+
return meta.status === 'running';
|
|
26
|
+
};
|
|
27
|
+
agentPaint = async () => { if (agentOpen !== id || $('#viewer').style.display === 'none') return; const still = await paint(); if (!still) loadAgents(); };
|
|
28
|
+
const running = await paint(); showPane('viewer'); $('#stage').scrollTop = $('#stage').scrollHeight;
|
|
29
|
+
clearInterval(agentTimer); if (running && !events) agentTimer = setInterval(agentPaint, 2000); // respaldo sin stream
|
|
30
|
+
loadAgents();
|
|
31
|
+
}
|
package/public/js/app.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// app.js — arranque: config de la cabecera y primera carga de cada sección (se carga último)
|
|
2
|
+
async function loadCfg() {
|
|
3
|
+
try { cfg = await (await fetch(BASE + '/api/config')).json(); } catch (e) { cfg = {}; }
|
|
4
|
+
const ws = cfg.workspace || '';
|
|
5
|
+
$('#proj').textContent = ws.split(/[\\/]/).filter(Boolean).pop() || 'workspace'; $('#proj').title = ws;
|
|
6
|
+
$('#hpath').textContent = ws; $('#hpath').title = ws;
|
|
7
|
+
$('#perm').value = localStorage.getItem('lampson.perm') || cfg.permission || 'ask'; $('#perm').onchange = () => localStorage.setItem('lampson.perm', $('#perm').value);
|
|
8
|
+
paintModel();
|
|
9
|
+
if (cfg.configured === false) openSetup(true);
|
|
10
|
+
const sel = $('#agent'); sel.innerHTML = '';
|
|
11
|
+
for (const a of (cfg.agents || ['build'])) { const o = document.createElement('option'); o.value = a; o.textContent = 'agente: ' + a; sel.appendChild(o); }
|
|
12
|
+
sel.value = localStorage.getItem('lampson.agent') || 'build';
|
|
13
|
+
const hint = () => { const p = (cfg.profiles || {})[sel.value]; const t = p ? sel.value + ' · ' + p.tools.join(' · ') : ''; $('#agentHint').textContent = t; $('#agentHint2').textContent = t; };
|
|
14
|
+
sel.onchange = () => { localStorage.setItem('lampson.agent', sel.value); hint(); }; hint();
|
|
15
|
+
if (!log.children.length) empty();
|
|
16
|
+
}
|
|
17
|
+
loadCfg().then(() => { loadTree(); loadSessions(); loadProcs(); loadMemory(); loadAgents(); loadMcp(); loadLsp(); loadLamps(); loadTodo(); loadSched(); loadApprovals(); checkUpdate(); const pq = new URLSearchParams(location.search).get('proc'); if (pq) openProc(pq); else if (session) open(session); });
|
|
18
|
+
// respaldos lentos por si el stream de eventos se cae
|
|
19
|
+
setInterval(loadProcs, events ? 60000 : 10000);
|
|
20
|
+
setInterval(() => { loadSched(); if (!events) loadApprovals(); }, events ? 60000 : 15000);
|
|
21
|
+
setInterval(() => { if (!events && ($('#agentCount').textContent.includes('●') || busy)) loadAgents(); }, 5000);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// approvals.js — lo que espera tu permiso (lib/approvals.syn), venga del chat o de una tarea programada.
|
|
2
|
+
// Una corrida desatendida que quiere hacer algo peligroso queda esperando acá (y en el webhook/link si están
|
|
3
|
+
// configurados). Las del chat de esta pestaña ya tienen su tarjeta en la conversación; igual aparecen en la lista.
|
|
4
|
+
const apprSeen = new Set();
|
|
5
|
+
async function loadApprovals() {
|
|
6
|
+
let r; try { r = await (await fetch(BASE + '/api/approvals')).json(); } catch (e) { return; }
|
|
7
|
+
const list = r.approvals || [], pend = list.filter(a => a.status === 'pending');
|
|
8
|
+
const box = $('#apprBox'); box.innerHTML = '';
|
|
9
|
+
$('#apprCount').textContent = pend.length ? `${pend.length} ●` : ''; autoSec('approvals', pend.length > 0);
|
|
10
|
+
if (!list.length) { box.innerHTML = '<div class="p none" style="cursor:default">ninguna pendiente</div>'; }
|
|
11
|
+
for (const a of list) {
|
|
12
|
+
const d = document.createElement('div'); d.className = 'p appr'; const src = a.source === 'chat' ? 'chat' : ('⏰ ' + a.source.replace(/^schedule:?/, ''));
|
|
13
|
+
d.innerHTML = `<span class="st ${a.status === 'pending' ? 'busy' : ''}"></span><span class="nm">${esc(src)}</span><span class="cm">${a.status === 'pending' ? 'vence ' + esc(fmtWhen(a.expires_at)) : esc(a.status)}</span><span class="msg">${esc(a.message)}</span><span class="why">${esc(a.why)}</span>`
|
|
14
|
+
+ (a.status === 'pending' ? `<span class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></span>` : '');
|
|
15
|
+
d.querySelectorAll('button').forEach(b => b.onclick = async () => { d.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); setTimeout(loadApprovals, 600); });
|
|
16
|
+
box.appendChild(d);
|
|
17
|
+
}
|
|
18
|
+
if (r.public_url === '' && pend.length) { const n = document.createElement('div'); n.className = 'daemon'; n.textContent = 'para aprobar desde el móvil: URL pública + webhook en ⚙'; box.appendChild(n); }
|
|
19
|
+
// tarjeta en el chat para las que NO vienen de esta conversación (una tarea programada pidiendo permiso)
|
|
20
|
+
for (const a of pend) {
|
|
21
|
+
if (a.source === 'chat' || apprSeen.has(a.id)) continue; apprSeen.add(a.id);
|
|
22
|
+
const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(a.why)} · ${esc(a.source.replace(/^schedule:?/, 'tarea programada '))}</div><code>${esc(a.message)}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`);
|
|
23
|
+
el.dataset.id = a.id; el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); });
|
|
24
|
+
}
|
|
25
|
+
for (const a of list) { if (a.status === 'pending') continue; const el = [...log.querySelectorAll('.approval')].find(x => x.dataset.id === a.id); if (el && el.querySelector('.btns button')) el.querySelector('.btns').innerHTML = a.status === 'approved' ? '<span style="color:var(--str)">✓ permitido</span>' : (a.status === 'expired' ? '<span class="denied">✗ sin respuesta, denegado</span>' : '<span class="denied">✗ denegado</span>'); }
|
|
26
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// chat.js — el composer (texto + imágenes), el envío por SSE y el pintado de cada evento del loop
|
|
2
|
+
// ---- imágenes pegadas / arrastradas al composer ----
|
|
3
|
+
// Se reducen en el navegador (lado mayor ≤ 1568 px, el límite útil de los modelos con visión) y viajan como
|
|
4
|
+
// base64 en el POST; PNG si sigue liviano (capturas con texto), JPEG si no. Máximo 4 por mensaje.
|
|
5
|
+
let attachments = []; // [{media_type, data(base64), preview(dataURL), bytes}]
|
|
6
|
+
const MAX_IMAGES = 4, MAX_SIDE = 1568;
|
|
7
|
+
function fmtBytes(n) { return n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB'; }
|
|
8
|
+
async function shrinkImage(file) {
|
|
9
|
+
const url = URL.createObjectURL(file); const img = new Image();
|
|
10
|
+
await new Promise((ok, ko) => { img.onload = ok; img.onerror = ko; img.src = url; }); URL.revokeObjectURL(url);
|
|
11
|
+
const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height));
|
|
12
|
+
const c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
|
13
|
+
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
|
14
|
+
let dataUrl = c.toDataURL('image/png'); let type = 'image/png';
|
|
15
|
+
if (dataUrl.length > 1.4 * 1048576) { dataUrl = c.toDataURL('image/jpeg', 0.85); type = 'image/jpeg'; }
|
|
16
|
+
const data = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
|
17
|
+
return { media_type: type, data, preview: dataUrl, bytes: Math.round(data.length * 0.75), w: c.width, h: c.height };
|
|
18
|
+
}
|
|
19
|
+
function paintAttach() {
|
|
20
|
+
const box = $('#attach'); box.innerHTML = '';
|
|
21
|
+
if (attachments.length && cfg.vision === false) { const w = document.createElement('div'); w.className = 'novision'; w.textContent = `${cfg.provider} · ${cfg.model} no acepta imágenes: el modelo recibirá solo una nota. Cambiá a un modelo con visión (clic en el modelo, arriba) para que la vea.`; box.appendChild(w); }
|
|
22
|
+
attachments.forEach((a, i) => { const d = document.createElement('div'); d.className = 'th'; d.title = `${a.w}×${a.h} · ${a.media_type} · ${fmtBytes(a.bytes)}`; d.innerHTML = `<img src="${a.preview}" alt=""><span class="rm" title="quitar">✕</span><span class="sz">${fmtBytes(a.bytes)}</span>`; d.querySelector('.rm').onclick = () => { attachments.splice(i, 1); paintAttach(); }; box.appendChild(d); });
|
|
23
|
+
}
|
|
24
|
+
async function addImages(files) {
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
if (!f.type.startsWith('image/')) continue;
|
|
27
|
+
if (attachments.length >= MAX_IMAGES) { add('meta', `máximo ${MAX_IMAGES} imágenes por mensaje`); break; }
|
|
28
|
+
try { attachments.push(await shrinkImage(f)); } catch (e) { add('denied', 'no pude leer la imagen: ' + esc(e.message)); }
|
|
29
|
+
}
|
|
30
|
+
paintAttach(); $('#in').focus();
|
|
31
|
+
}
|
|
32
|
+
$('#in').addEventListener('paste', e => { const files = [...(e.clipboardData?.items || [])].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean); if (files.length) { e.preventDefault(); addImages(files); } });
|
|
33
|
+
const composer = document.querySelector('.composer');
|
|
34
|
+
composer.addEventListener('dragover', e => { e.preventDefault(); composer.classList.add('drop'); });
|
|
35
|
+
composer.addEventListener('dragleave', () => composer.classList.remove('drop'));
|
|
36
|
+
composer.addEventListener('drop', e => { e.preventDefault(); composer.classList.remove('drop'); addImages([...e.dataTransfer.files]); });
|
|
37
|
+
function imagesHtml(ims) { if (!ims || !ims.length) return ''; return '<div class="ims">' + ims.map(im => `<div class="th"><img src="data:${esc(im.media_type)};base64,${im.data}" alt="imagen adjunta"></div>`).join('') + '</div>'; }
|
|
38
|
+
log.addEventListener('click', e => { const im = e.target.closest('.user .th img'); if (im) window.open(im.src, '_blank'); });
|
|
39
|
+
|
|
40
|
+
// ---- envío (SSE) ----
|
|
41
|
+
let aborter = null;
|
|
42
|
+
function setBusy(b) { busy = b; $('#f').classList.toggle('busy', b); $('#send').disabled = b; }
|
|
43
|
+
$('#stop').onclick = () => { if (aborter) aborter.abort(); };
|
|
44
|
+
async function send(message) {
|
|
45
|
+
setBusy(true); aborter = new AbortController();
|
|
46
|
+
if (log.querySelector('.empty')) { log.innerHTML = ''; log.classList.remove('hero'); }
|
|
47
|
+
const images = attachments.map(a => ({ media_type: a.media_type, data: a.data, w: a.w, h: a.h })); attachments = []; paintAttach();
|
|
48
|
+
add('user', esc(message) + imagesHtml(images));
|
|
49
|
+
const thinking = add('working', '<span class="dot"></span><span>pensando…</span>');
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(BASE + '/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ session, message, agent: $('#agent').value || 'build', permission: $('#perm').value || 'ask', images }), signal: aborter.signal });
|
|
52
|
+
if (!res.ok || !res.body) { add('denied', 'error HTTP ' + res.status + ' ' + esc(await res.text())); }
|
|
53
|
+
else {
|
|
54
|
+
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = '';
|
|
55
|
+
while (true) {
|
|
56
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
57
|
+
buf += dec.decode(value, { stream: true });
|
|
58
|
+
let i; while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); handle(chunk, thinking); }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (e) { add('meta', e.name === 'AbortError' ? '■ detenido por vos (el agente para en el próximo paso; el historial queda guardado hasta ahí)' : '‼ ' + esc(e.message)); }
|
|
62
|
+
thinking.remove(); aborter = null; setBusy(false); loadSessions(); loadTree(); loadProcs(); loadMemory(); loadAgents();
|
|
63
|
+
}
|
|
64
|
+
let pending = null;
|
|
65
|
+
function resultHtml(out, bad) { return `<details><summary class="${bad ? 'bad' : ''}">${esc(out.split('\n')[0].slice(0, 140))} <span style="color:var(--ink-3)">(${out.length} chars)</span></summary><pre>${esc(out)}</pre></details>`; }
|
|
66
|
+
function handle(chunk, thinking) {
|
|
67
|
+
const data = chunk.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).join('\n'); if (!data) return;
|
|
68
|
+
let ev; try { ev = JSON.parse(data); } catch { return; }
|
|
69
|
+
const k = ev.kind, d = ev.data;
|
|
70
|
+
if (k === 'session') { const changed = d !== session; session = d; localStorage.setItem('lampson.session', d); if (changed) loadTodo(); }
|
|
71
|
+
else if (k === 'assistant') add('assistant', md(d));
|
|
72
|
+
else if (k === 'tool_call') { thinking.querySelector('span:last-child').textContent = 'ejecutando ' + esc(d.name) + '…'; pending = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(d))}`); wireMore(pending); if (d.name === 'process') setTimeout(loadProcs, 2500); if (d.name === 'delegate') { setTimeout(loadAgents, 800); setTimeout(loadAgents, 4000); } if (d.name === 'todo') setTimeout(loadTodo, 300); if (d.name === 'lamp') setTimeout(loadLamps, 300); if (d.name === 'lsp') setTimeout(loadLsp, 1500); }
|
|
73
|
+
else if (k === 'inbox') { add('meta', '✉ ' + esc(String(d).split('\n')[0].slice(0, 160))); loadAgents(); }
|
|
74
|
+
else if (k === 'tool_result') { const out = String(d.output); const bad = /^(ERROR|DENIED)/.test(out);
|
|
75
|
+
if (!bad && d.call && /^(write|edit|bash|process|delegate|lamp|skill)$/.test(d.call.name)) treeChanged();
|
|
76
|
+
if (d.call && d.call.name === 'schedule') { setTimeout(loadSched, 300); if (/^scheduled '/.test(out)) setSec('sched', true); }
|
|
77
|
+
// lámpara recién creada: avisar en el chat con un acceso directo al switch (el modelo no sabe cómo se enciende en esta UI)
|
|
78
|
+
if (d.call && d.call.name === 'lamp' && /^lamp '([^']+)' created/.test(out)) { const nm = out.match(/^lamp '([^']+)' created/)[1]; const m = add('meta', `☼ lámpara <b>${esc(nm)}</b> creada — está apagada: <a href="#" class="lampgo">encenderla en «lámparas»</a>`); m.querySelector('.lampgo').onclick = (ev) => { ev.preventDefault(); openLamps(nm); }; $('#lamps').classList.add('new'); setTimeout(() => $('#lamps').classList.remove('new'), 4000); }
|
|
79
|
+
const el = pending || add('step', '<span class="ic">→</span>'); pending = null; thinking.querySelector('span:last-child').textContent = 'pensando…'; const ic = el.querySelector('.ic'); if (ic) { ic.textContent = bad ? '✗' : '✓'; ic.className = 'ic ' + (bad ? 'bad' : 'ok'); } el.insertAdjacentHTML('beforeend', resultHtml(out, bad)); }
|
|
80
|
+
else if (k === 'approval_request') { const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(d.why)}</div><code>${esc(describe({name: d.name, args: d.args}))}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`); el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: d.id, decision: b.dataset.ok === '1' }); }); el.dataset.id = d.id; }
|
|
81
|
+
else if (k === 'approval_result') { const el = [...log.querySelectorAll('.approval')].find(x => x.dataset.id === d.id); if (el) el.querySelector('.btns').innerHTML = d.approved ? '<span style="color:var(--str)">✓ permitido</span>' : (d.timeout ? '<span class="denied">✗ sin respuesta, denegado</span>' : '<span class="denied">✗ denegado</span>'); }
|
|
82
|
+
else if (k === 'mode') { $('#agent').value = d.agent; $('#perm').value = d.permission; if (d.has_key === false) { add('denied', `‼ no hay API key para ${esc(d.provider)} — cargala en la ventana de modelo (clic en «${esc(d.provider)} · ${esc(d.model)}» arriba)`); } }
|
|
83
|
+
else if (k === 'tool_denied') { const el = pending || add('step', '<span class="ic">✗</span>'); el.insertAdjacentHTML('beforeend', `<span class="denied"> ✗ ${esc(d.reason)}</span>`); }
|
|
84
|
+
// el hint de "modelo inválido" solo cuando el error realmente habla de eso
|
|
85
|
+
else if (k === 'error') { add('denied', '‼ ' + esc(d)); if (/(invalid|unknown|no such|not exist|not found|unsupported).{0,30}model|model.{0,30}(invalid|unknown|does not exist|not found|unsupported)/i.test(String(d))) add('meta', `el proveedor rechazó el nombre del modelo (${esc(cfg.model || '')}) — clic en «${esc(cfg.provider || '')} · ${esc(cfg.model || '')}» arriba: la lista de modelos válidos aparece al elegir el proveedor`); }
|
|
86
|
+
else if (k === 'compact') add('meta', '⧗ compactando contexto (~' + d.before + ' tokens)');
|
|
87
|
+
else if (k === 'done') { if (d.stopped !== 'user_command') add('meta', `${d.steps} paso${d.steps === 1 ? '' : 's'} · ${d.usage.input + d.usage.output} tokens · ${d.stopped}`); }
|
|
88
|
+
log.appendChild(thinking); $('#stage').scrollTop = $('#stage').scrollHeight;
|
|
89
|
+
}
|
|
90
|
+
$('#f').onsubmit = e => { e.preventDefault(); const t = $('#in').value.trim(); if ((!t && !attachments.length) || busy) return; $('#in').value = ''; send(t || '(imagen adjunta)'); };
|
|
91
|
+
$('#in').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!busy) $('#f').requestSubmit(); } });
|
|
92
|
+
$('#in').addEventListener('input', () => { const t = $('#in'); t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 220) + 'px'; });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// config.js — configuración (⚙) y proveedor/modelo/key: UNA vista browse del Panel. Secciones a la izquierda
|
|
2
|
+
// (General · Aprobaciones a distancia · Proveedor), su formulario a la derecha. Cada cosa configurable nueva
|
|
3
|
+
// entra como una sección más. Los valores fijados en .env se muestran bloqueados; la API key nunca vuelve.
|
|
4
|
+
let setupSel = '';
|
|
5
|
+
function paintModel() {
|
|
6
|
+
const p = (cfg.providers || []).find(x => x.name === cfg.provider);
|
|
7
|
+
$('#model').textContent = cfg.provider ? `${cfg.provider} · ${cfg.model || ''}` : '';
|
|
8
|
+
$('#model').classList.toggle('nokey', !!p && !p.has_key);
|
|
9
|
+
$('#model').style.display = cfg.provider ? '' : 'none';
|
|
10
|
+
}
|
|
11
|
+
const CFG_SECTIONS = [
|
|
12
|
+
{ id: 'general', name: 'General', meta: 'zona horaria' },
|
|
13
|
+
{ id: 'approvals', name: 'Aprobaciones a distancia', meta: 'URL pública · webhook' },
|
|
14
|
+
{ id: 'provider', name: 'Proveedor', meta: 'modelo · API key' }
|
|
15
|
+
];
|
|
16
|
+
function cfgField(v, key, label, placeholder, ds, type) {
|
|
17
|
+
return `<label class="col">${label}<input name="${key}" type="${type || 'text'}" spellcheck="false" autocomplete="off" placeholder="${esc(placeholder)}" value="${esc(v[key] || '')}" ${v[key + '_from_env'] ? 'disabled title="fijado en .env (LAMPSON_' + key.toUpperCase() + '): editalo ahí"' : ''}><span class="ds">${ds}</span></label>`;
|
|
18
|
+
}
|
|
19
|
+
async function openCfg(section, onboarding) {
|
|
20
|
+
let r; try { r = await (await fetch(BASE + '/api/settings/values')).json(); } catch (e) { r = { values: {} }; }
|
|
21
|
+
const v = r.values || {};
|
|
22
|
+
Panel.open({
|
|
23
|
+
id: 'config', eyebrow: 'configuración', title: onboarding ? 'Bienvenido a Lampson' : 'Configuración', sub: 'lampson/.lampson/config.json', layout: 'browse', noClose: !!onboarding,
|
|
24
|
+
select: section || 'general',
|
|
25
|
+
browse: {
|
|
26
|
+
placeholder: 'buscar…', listWidth: '260px', nodot: true, key: s => s.id,
|
|
27
|
+
load: async (q) => CFG_SECTIONS.filter(s => !q || (s.name + ' ' + s.meta).toLowerCase().includes(q.toLowerCase())),
|
|
28
|
+
render: (s) => `<div><div class="nm">${esc(s.name)}</div><div class="meta">${esc(s.meta)}</div></div>`,
|
|
29
|
+
count: (rows) => `${rows.length} secciones`,
|
|
30
|
+
detail: (s) => {
|
|
31
|
+
if (s.id === 'general') return `<div class="dhead"><span class="nm serif">General</span></div><div class="dform"><p class="lead">Se guarda en <code>lampson/.lampson/config.json</code> (local). Un valor fijado en <code>.env</code> gana y se muestra bloqueado.</p>`
|
|
32
|
+
+ cfgField(v, 'tz', 'zona horaria', '-03:00', 'para «daily 09:00» y los horarios de las tareas programadas · vacío = la del sistema' + (typeof r.tz_detected === 'number' ? ` (ahora: ${fmtOff(r.tz_detected)})` : ''))
|
|
33
|
+
+ cfgField(v, 'idle_hours', 'apagar workspaces inactivos tras', '4', 'horas sin uso tras las que el hub apaga el proceso de un workspace (vuelve a arrancar al abrirlo, en 1-2 s) - 0 = nunca - los que tienen tareas programadas encendidas o politica siempre vivo no se apagan')
|
|
34
|
+
+ `<div class="pfoot"><button class="primary" data-save>Guardar</button><span class="derr"></span></div></div>`;
|
|
35
|
+
if (s.id === 'approvals') return `<div class="dhead"><span class="nm serif">Aprobaciones a distancia</span></div><div class="dform"><p class="lead">Cuando una tarea programada (o el chat) necesita tu permiso, además de aparecer acá puede avisarte a tu canal con <b>links de un solo uso</b> para permitir o denegar desde el teléfono.</p>`
|
|
36
|
+
+ cfgField(v, 'public_url', 'URL pública', 'https://lampson.midominio.com', 'por dónde se llega a este lampson desde afuera (túnel, VPS, edge con TLS); con esto cada aprobación trae links <code>/approve/<id>/<token>?d=yes|no</code>')
|
|
37
|
+
+ cfgField(v, 'webhook_url', 'webhook', 'https://hooks.example.com/lampson', 'POST JSON por cada aprobación pendiente (id, mensaje, links); reenvialo a Telegram, Slack o mail con n8n, un bot o un .syn de 6 líneas')
|
|
38
|
+
+ `<label class="col">secreto del webhook<input name="webhook_secret" type="password" spellcheck="false" autocomplete="off" placeholder="${v.has_webhook_secret ? '● guardado — escribí uno nuevo para cambiarlo' : 'opcional'}"><span class="ds">firma HMAC-SHA256 del body en <code>X-Lampson-Signature</code>; dejá vacío para no cambiarlo</span></label>`
|
|
39
|
+
+ `<div class="pfoot"><button class="primary" data-save>Guardar</button><span class="derr"></span></div></div>`;
|
|
40
|
+
// proveedor / modelo / key
|
|
41
|
+
setupSel = cfg.provider || (cfg.providers || [])[0]?.name || '';
|
|
42
|
+
const rows = (cfg.providers || []).map(p => `<div class="pr ${p.name === setupSel ? 'on' : ''}" data-name="${esc(p.name)}"><b>${esc(p.name)}</b><span class="dm">${esc(p.model)}</span><span class="k ${p.has_key ? '' : 'no'}">${p.name === 'ollama' ? 'sin key' : (p.has_key ? '● key guardada' : '○ sin key')}</span></div>`).join('');
|
|
43
|
+
return `<div class="dhead"><span class="nm serif">${onboarding ? 'Bienvenido a Lampson' : 'Proveedor y modelo'}</span></div><div class="dform"><p class="lead">${onboarding ? 'Un agente con herramientas acotadas a este proyecto. Para empezar, elegí un proveedor y pegá su API key.' : 'Elegí con qué modelo trabaja el agente (vale para la web y la terminal).'} La API key queda en <code>lampson/.lampson/config.json</code>, solo en esta máquina; nunca vuelve al navegador.</p>
|
|
44
|
+
<div class="plist">${rows}</div>
|
|
45
|
+
<label>modelo <input name="model" spellcheck="false" autocomplete="off" list="modelList"></label>
|
|
46
|
+
<label>API key <input name="key" type="password" spellcheck="false" autocomplete="off" placeholder="pegala acá"></label>
|
|
47
|
+
<datalist id="modelList"></datalist>
|
|
48
|
+
<div class="pfoot"><button class="primary" data-save-provider>Guardar</button><span class="derr"></span></div></div>`;
|
|
49
|
+
},
|
|
50
|
+
wire: (s, box) => {
|
|
51
|
+
const err = (t) => { const e = box.querySelector('.derr'); if (e) e.textContent = t || ''; };
|
|
52
|
+
const save = box.querySelector('[data-save]');
|
|
53
|
+
if (save) save.onclick = async () => {
|
|
54
|
+
const body = {};
|
|
55
|
+
for (const key of ['tz', 'public_url', 'webhook_url', 'idle_hours']) { const el = box.querySelector(`[name="${key}"]`); if (el && !el.disabled) body[key] = el.value.trim(); }
|
|
56
|
+
const sec = box.querySelector('[name="webhook_secret"]'); if (sec && sec.value.trim()) body.webhook_secret = sec.value.trim();
|
|
57
|
+
if (body.tz && !/^[+-]?\d{1,2}(:?\d{2})?$/.test(body.tz)) { err('zona horaria: usá -03:00 o +0200'); return; }
|
|
58
|
+
if (body.idle_hours && !/^[0-9]+([.][0-9]+)?$/.test(body.idle_hours)) { err('inactividad: horas (0 = nunca)'); return; }
|
|
59
|
+
err('guardando…');
|
|
60
|
+
const r2 = await api(BASE + '/api/settings/values', body);
|
|
61
|
+
if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
|
|
62
|
+
Object.assign(v, r2.data.values || {}); err('✓ guardado'); add('meta', '⚙ configuración guardada'); loadSched(); loadApprovals();
|
|
63
|
+
};
|
|
64
|
+
const saveP = box.querySelector('[data-save-provider]');
|
|
65
|
+
if (saveP) {
|
|
66
|
+
const f = n => box.querySelector(`[name="${n}"]`);
|
|
67
|
+
const fill = () => {
|
|
68
|
+
const p = (cfg.providers || []).find(x => x.name === setupSel) || {};
|
|
69
|
+
f('model').value = setupSel === cfg.provider ? (cfg.model || p.model || '') : (p.model || ''); f('model').placeholder = p.model || '';
|
|
70
|
+
// modelos válidos según la API del proveedor (evita tipear "DeepSeek-V4-Pro" cuando la API quiere "deepseek-v4-pro")
|
|
71
|
+
const dl = box.querySelector('#modelList'); dl.innerHTML = ''; f('model').title = '';
|
|
72
|
+
if (p.has_key || setupSel === 'ollama') fetch(BASE + '/api/models?provider=' + encodeURIComponent(setupSel)).then(r => r.json()).then(d => { if (setupSel !== p.name) return; if (d.error) { f('model').title = 'no pude listar modelos: ' + d.error; return; } dl.innerHTML = (d.models || []).map(m => `<option value="${esc(m)}">`).join(''); f('model').title = (d.models || []).length ? 'modelos disponibles: ' + d.models.join(', ') : ''; }).catch(() => {});
|
|
73
|
+
f('key').value = ''; f('key').disabled = setupSel === 'ollama';
|
|
74
|
+
f('key').placeholder = setupSel === 'ollama' ? 'ollama no necesita key' : (p.has_key ? 'hay una key guardada · pegá otra para reemplazarla' : 'pegala acá');
|
|
75
|
+
};
|
|
76
|
+
box.querySelectorAll('.pr').forEach(r => r.onclick = () => { setupSel = r.dataset.name; box.querySelectorAll('.pr').forEach(x => x.classList.toggle('on', x.dataset.name === setupSel)); fill(); });
|
|
77
|
+
fill(); if (onboarding) f('key').focus();
|
|
78
|
+
f('key').addEventListener('keydown', e => { if (e.key === 'Enter') saveP.click(); });
|
|
79
|
+
saveP.onclick = async () => {
|
|
80
|
+
const body = { provider: setupSel, model: f('model').value.trim() }; if (f('key').value.trim()) body.key = f('key').value.trim();
|
|
81
|
+
const p = (cfg.providers || []).find(x => x.name === setupSel) || {};
|
|
82
|
+
if (setupSel !== 'ollama' && !p.has_key && !body.key) { err('falta la API key'); return; }
|
|
83
|
+
saveP.disabled = true;
|
|
84
|
+
try {
|
|
85
|
+
const r2 = await api(BASE + '/api/settings', body);
|
|
86
|
+
if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
|
|
87
|
+
const d = r2.data; cfg.providers = d.providers; cfg.provider = d.provider; cfg.model = d.model; cfg.vision = d.vision; cfg.configured = d.configured; paintModel(); paintAttach();
|
|
88
|
+
if (onboarding) { Panel.close(); if (log.querySelector('.empty')) empty(); } else { err('✓ guardado'); Panel.detail(); }
|
|
89
|
+
} catch (e) { err(e.message); } finally { saveP.disabled = false; }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// proveedor: la pill de la cabecera y el onboarding abren la misma vista, parados en «Proveedor»
|
|
97
|
+
function openSetup(onboarding) { openCfg('provider', onboarding); }
|
|
98
|
+
$('#cfgBtn').onclick = () => openCfg('general');
|
|
99
|
+
$('#model').onclick = () => openSetup(false);
|
|
100
|
+
if (location.hash === '#config') setTimeout(() => openCfg('general'), 400);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// core.js — estado compartido y helpers que usan todos los módulos (se carga primero)
|
|
2
|
+
const $ = s => document.querySelector(s);
|
|
3
|
+
// detrás del hub la UI vive en /w/<slug>/ y su API en /w/<slug>/api/…; standalone (sin hub) BASE = ''
|
|
4
|
+
const BASE = (location.pathname.match(/^\/w\/[^/]+/) || [''])[0];
|
|
5
|
+
const WS_SLUG = BASE ? BASE.slice(3) : '';
|
|
6
|
+
const log = $('#log');
|
|
7
|
+
let session = new URLSearchParams(location.search).get('session') || localStorage.getItem('lampson.session') || '';
|
|
8
|
+
let busy = false; let cfg = {};
|
|
9
|
+
|
|
10
|
+
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
11
|
+
function md(src) { // Markdown mínimo: code fences, inline code, bold, headers, listas, párrafos
|
|
12
|
+
const blocks = []; src = String(src ?? '').replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => { blocks.push('<pre><code>' + esc(c) + '</code></pre>'); return '\uE000' + (blocks.length - 1) + '\uE001'; }); // marcador = índice entre U+E000/U+E001, NUNCA dígitos a secas (se perdieron dos veces al reescribir el archivo y todo número salía undefined)
|
|
13
|
+
let h = esc(src).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
|
14
|
+
h = h.split(/\n{2,}/).map(p => {
|
|
15
|
+
if (/^\uE000\d+\uE001$/.test(p.trim())) return p;
|
|
16
|
+
if (/^#{1,3} /.test(p)) return '<h3>' + p.replace(/^#{1,3} /, '') + '</h3>';
|
|
17
|
+
if (/^(\s*[-*] .*\n?)+$/.test(p)) return '<ul>' + p.split('\n').map(l => '<li>' + l.replace(/^\s*[-*] /, '') + '</li>').join('') + '</ul>';
|
|
18
|
+
if (/^(\s*\d+[.)] .*\n?)+$/.test(p)) return '<ol>' + p.split('\n').map(l => '<li>' + l.replace(/^\s*\d+[.)] /, '') + '</li>').join('') + '</ol>';
|
|
19
|
+
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
|
|
20
|
+
}).join('');
|
|
21
|
+
return h.replace(/\uE000(\d+)\uE001/g, (m, i) => blocks[+i]);
|
|
22
|
+
}
|
|
23
|
+
// un bloque en el chat: 'user' | 'assistant' | 'step' | 'meta' | 'denied' | 'approval' | 'working'
|
|
24
|
+
function add(cls, html) { showPane('log'); log.classList.remove('hero'); const d = document.createElement('div'); d.className = 'msg ' + cls; d.innerHTML = html; log.appendChild(d); $('#stage').scrollTop = $('#stage').scrollHeight; return d; }
|
|
25
|
+
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
26
|
+
function cmdHtml(text) { const t = String(text); const long = t.length > 220 || t.split('\n').length > 4; return `<span class="cmd">${esc(t)}</span>` + (long ? ' <span class="more">ver todo</span>' : ''); }
|
|
27
|
+
function wireMore(el) { const m = el.querySelector('.more'); if (m) m.onclick = () => { el.querySelector('.cmd').classList.toggle('open'); m.textContent = el.querySelector('.cmd').classList.contains('open') ? 'menos' : 'ver todo'; }; }
|
|
28
|
+
// POST JSON → {ok, status, data}
|
|
29
|
+
async function api(path, body) {
|
|
30
|
+
const r = await fetch(path, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
31
|
+
let data = {}; try { data = await r.json(); } catch (e) {}
|
|
32
|
+
return { ok: r.ok, status: r.status, data };
|
|
33
|
+
}
|
|
34
|
+
// zona central: 'log' | 'viewer' | 'term'
|
|
35
|
+
function showPane(which) {
|
|
36
|
+
log.style.display = which === 'log' ? '' : 'none';
|
|
37
|
+
$('#viewer').style.display = which === 'viewer' ? 'block' : 'none';
|
|
38
|
+
$('#termpane').style.display = which === 'term' ? 'flex' : 'none';
|
|
39
|
+
// volver al chat = al final de la conversación (el viewer de archivos deja el scroll de #stage arriba)
|
|
40
|
+
if (which === 'log') $('#stage').scrollTop = $('#stage').scrollHeight;
|
|
41
|
+
}
|
|
42
|
+
// visor de texto (archivo, log, traza, memoria): título, meta y contenido plano
|
|
43
|
+
function showText(title, meta, text, toEnd) {
|
|
44
|
+
$('#vpath').textContent = title; $('#vmeta').textContent = meta || '';
|
|
45
|
+
const pre = $('#vbody'); pre.className = 'log'; pre.textContent = text;
|
|
46
|
+
showPane('viewer'); $('#stage').scrollTop = toEnd ? $('#stage').scrollHeight : 0;
|
|
47
|
+
}
|
|
48
|
+
// confirmación en línea (sin confirm() del navegador): el ✕ se convierte en «¿borrar? sí · no»
|
|
49
|
+
function inlineConfirm(el, label, onYes) {
|
|
50
|
+
const prev = el.innerHTML; el.classList.add('ask');
|
|
51
|
+
el.innerHTML = `<span class="q">${esc(label)}</span> <span class="yes">sí</span> · <span class="no">no</span>`;
|
|
52
|
+
const restore = () => { el.innerHTML = prev; el.classList.remove('ask'); };
|
|
53
|
+
el.querySelector('.yes').onclick = (ev) => { ev.stopPropagation(); onYes(); };
|
|
54
|
+
el.querySelector('.no').onclick = (ev) => { ev.stopPropagation(); restore(); };
|
|
55
|
+
setTimeout(() => { if (el.classList.contains('ask')) restore(); }, 6000);
|
|
56
|
+
}
|
|
57
|
+
const debounces = {};
|
|
58
|
+
function debounce(key, fn, ms) { clearTimeout(debounces[key]); debounces[key] = setTimeout(fn, ms); }
|
|
59
|
+
function fmtWhen(ts) { if (!ts) return '—'; return new Date(ts * 1000).toLocaleString(undefined, { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
|
60
|
+
function fmtIso(s) { return String(s || '').replace('T', ' ').replace('Z', ''); }
|
|
61
|
+
function fmtOff(sec) { const a = Math.abs(sec), h = Math.floor(a / 3600), m = Math.floor((a % 3600) / 60); return (sec < 0 ? '-' : '+') + String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'); }
|
|
62
|
+
function hl(s, q) { const t = esc(s); if (!q) return t; const i = t.toLowerCase().indexOf(q.toLowerCase()); return i < 0 ? t : t.slice(0, i) + '<mark>' + t.slice(i, i + q.length) + '</mark>' + t.slice(i + q.length); }
|
|
63
|
+
// portada cuando no hay conversación
|
|
64
|
+
function empty() {
|
|
65
|
+
const name = (cfg.workspace || '').split(/[\\/]/).filter(Boolean).pop() || 'workspace';
|
|
66
|
+
log.classList.add('hero');
|
|
67
|
+
log.innerHTML = `<div class="empty">
|
|
68
|
+
<p class="eyebrow">workspace</p>
|
|
69
|
+
<h1>${esc(name)}</h1>
|
|
70
|
+
<p class="lead">Un agente con herramientas acotadas a este proyecto. Vos pedís; él lee, busca, edita y corre comandos — y cada paso queda a la vista.</p>
|
|
71
|
+
<div class="grid">
|
|
72
|
+
<div class="card"><h3>Probá</h3>
|
|
73
|
+
<p><span class="try">Explicame la estructura de este proyecto</span></p>
|
|
74
|
+
<p><span class="try">¿Dónde está el punto de entrada y qué hace?</span></p>
|
|
75
|
+
<p><span class="try">Buscá TODOs y proponé un plan para resolverlos</span></p>
|
|
76
|
+
<p><span class="try">Corré los tests y contame qué falla</span></p></div>
|
|
77
|
+
<div class="card"><h3>Agentes</h3>
|
|
78
|
+
<p><span class="k">build</span> edita y ejecuta (por defecto)</p>
|
|
79
|
+
<p><span class="k">plan</span> solo lectura: investiga y propone</p>
|
|
80
|
+
<p><span class="k">review</span> lee y corre tests, no edita</p>
|
|
81
|
+
<p><span class="k">explore</span> localiza código</p></div>
|
|
82
|
+
<div class="card"><h3>Qué puede tocar</h3>
|
|
83
|
+
<p>Solo archivos dentro de <code>${esc(name)}/</code>.</p>
|
|
84
|
+
<p>Comandos peligrosos (<code>rm -rf</code>, <code>git push --force</code>, <code>sudo</code>…) según el selector <span class="k">permisos</span>: preguntar, permitir todo o denegar.</p>
|
|
85
|
+
<p>Lo destructivo del sistema se bloquea siempre.</p></div>
|
|
86
|
+
<div class="card"><h3>Cómo lo ve el modelo</h3>
|
|
87
|
+
<p>Cada tool y su resultado — errores y rechazos incluidos — queda en el historial y se le reenvía en cada turno.</p>
|
|
88
|
+
<p>Hacé clic en un archivo del panel de archivos para verlo. Un mensaje que empieza con <code>!</code> lo corrés vos, sin el modelo.</p></div>
|
|
89
|
+
</div></div>`;
|
|
90
|
+
log.querySelectorAll('.try').forEach(el => el.onclick = () => { $('#in').value = el.textContent; $('#in').focus(); });
|
|
91
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// events.js — eventos en vivo (SSE /api/events, alimentado por el bus de Synsema)
|
|
2
|
+
// Los supervisores de procesos publican cada línea y cambio de estado; los subagentes, inicio/fin/pasos; las
|
|
3
|
+
// tareas programadas y aprobaciones, sus cambios. La UI reacciona a eso (con un pequeño debounce) en vez de
|
|
4
|
+
// recargar por timer; los timers quedan como respaldo lento por si el stream se cae (reconexión con backoff).
|
|
5
|
+
let events = null, evBackoff = 1000;
|
|
6
|
+
function connectEvents() {
|
|
7
|
+
try { events = new EventSource(BASE + '/api/events'); } catch (e) { events = null; return; }
|
|
8
|
+
events.onopen = () => { evBackoff = 1000; };
|
|
9
|
+
events.addEventListener('event', e => {
|
|
10
|
+
let ev; try { ev = JSON.parse(e.data); } catch { return; }
|
|
11
|
+
const t = ev.topic || '';
|
|
12
|
+
if (t.startsWith('proc.')) {
|
|
13
|
+
const name = ev.data && ev.data.name;
|
|
14
|
+
if (!ev.data || ev.data.line === undefined) debounce('procs', loadProcs, 300); // cambio de estado
|
|
15
|
+
if (name && procOpen === name) debounce('procview', () => refreshProcViewer(name), 250);
|
|
16
|
+
} else if (t.startsWith('mcp.')) {
|
|
17
|
+
debounce('mcp', loadMcp, 300);
|
|
18
|
+
} else if (t.startsWith('schedule.')) {
|
|
19
|
+
debounce('sched', loadSched, 300);
|
|
20
|
+
if (t === 'schedule.done' && ev.data) { setTimeout(loadSessions, 800); add('meta', `⏰ <b>${esc(ev.data.name)}</b> terminó: ${esc(ev.data.status)} — <a href="#" class="schedgo">ver</a>`).querySelector('.schedgo').onclick = (e2) => { e2.preventDefault(); openSched(ev.data.id); }; }
|
|
21
|
+
} else if (t.startsWith('approval.')) {
|
|
22
|
+
debounce('appr', loadApprovals, 200);
|
|
23
|
+
} else if (t.startsWith('subagent.')) {
|
|
24
|
+
debounce('agents', loadAgents, 300);
|
|
25
|
+
const id = t.slice('subagent.'.length);
|
|
26
|
+
if (id && agentOpen === id && agentPaint) debounce('agentview', agentPaint, 250);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
events.onerror = () => { events.close(); events = null; setTimeout(connectEvents, evBackoff); evBackoff = Math.min(evBackoff * 2, 30000); };
|
|
30
|
+
}
|
|
31
|
+
connectEvents();
|
package/public/js/hub.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// hub.js — la pantalla de workspaces (public/hub.html): tarjetas + el mismo Panel de workspaces que usa la UI de un
|
|
2
|
+
// workspace (js/workspaces.js) para crear/encender/apagar. Sin core.js: acá no hay chat.
|
|
3
|
+
const $ = s => document.querySelector(s);
|
|
4
|
+
const BASE = '', WS_SLUG = '';
|
|
5
|
+
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
6
|
+
async function api(path, body) { const r = await fetch(path, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); let data = {}; try { data = await r.json(); } catch (e) {} return { ok: r.ok, status: r.status, data }; }
|
|
7
|
+
function fmtWhen(ts) { if (!ts) return '—'; return new Date(ts * 1000).toLocaleString(undefined, { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
|
8
|
+
function inlineConfirm(el, label, onYes) {
|
|
9
|
+
const prev = el.innerHTML; el.classList.add('ask');
|
|
10
|
+
el.innerHTML = `<span class="q">${esc(label)}</span> <span class="yes">sí</span> · <span class="no">no</span>`;
|
|
11
|
+
const restore = () => { el.innerHTML = prev; el.classList.remove('ask'); };
|
|
12
|
+
el.querySelector('.yes').onclick = (ev) => { ev.stopPropagation(); onYes(); };
|
|
13
|
+
el.querySelector('.no').onclick = (ev) => { ev.stopPropagation(); restore(); };
|
|
14
|
+
setTimeout(() => { if (el.classList.contains('ask')) restore(); }, 6000);
|
|
15
|
+
}
|
|
16
|
+
// tema
|
|
17
|
+
const SUN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.7v2.1M12 19.2v2.1M2.7 12h2.1M19.2 12h2.1M5.3 5.3l1.5 1.5M17.2 17.2l1.5 1.5M18.7 5.3l-1.5 1.5M6.8 17.2l-1.5 1.5"/></svg>';
|
|
18
|
+
const MOON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20.2 14.6A8.6 8.6 0 0 1 9.4 3.8a8.6 8.6 0 1 0 10.8 10.8z"/></svg>';
|
|
19
|
+
function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; }
|
|
20
|
+
$('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); };
|
|
21
|
+
paintTheme();
|
|
22
|
+
// tarjetas
|
|
23
|
+
async function paintGrid() {
|
|
24
|
+
const grid = $('#wsGrid'); let r; try { r = await (await fetch(BASE + '/api/workspaces')).json(); } catch (e) { grid.innerHTML = '<div class="card"><p>el hub no responde</p></div>'; return; }
|
|
25
|
+
wsList = r.workspaces || []; wsIdle = r.idle_hours;
|
|
26
|
+
grid.innerHTML = '';
|
|
27
|
+
for (const w of wsList) {
|
|
28
|
+
const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ''); c.href = w.url;
|
|
29
|
+
c.innerHTML = `<h3>${w.alive ? '● vivo' : '○ apagado'}${w.schedules_on ? ' · ⏰ ' + w.schedules_on : ''}</h3><p class="nm">${esc(w.name)}</p><p class="pth">${esc(w.path)}</p><p class="meta">último uso ${esc(fmtWhen(w.last_used))}</p>`;
|
|
30
|
+
grid.appendChild(c);
|
|
31
|
+
}
|
|
32
|
+
const add = document.createElement('a'); add.className = 'card ws new'; add.href = '#'; add.innerHTML = '<h3>+</h3><p class="nm">nuevo workspace</p><p class="pth">elegí una carpeta de tu disco</p>';
|
|
33
|
+
add.onclick = (e) => { e.preventDefault(); openWorkspaces('__new'); };
|
|
34
|
+
grid.appendChild(add);
|
|
35
|
+
const all = document.createElement('a'); all.className = 'card ws'; all.href = '#'; all.innerHTML = '<h3>⌕</h3><p class="nm">gestionar</p><p class="pth">encender, apagar, política de vida, quitar</p>';
|
|
36
|
+
all.onclick = (e) => { e.preventDefault(); openWorkspaces(); };
|
|
37
|
+
grid.appendChild(all);
|
|
38
|
+
}
|
|
39
|
+
paintGrid();
|
|
40
|
+
setInterval(paintGrid, 10000);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// lamps.js — lámparas (plugins de tools): pill en la cabecera + vista browse del Panel (lista, detalle con
|
|
2
|
+
// switch, tools con formulario generado del schema, correr, borrar) y la ayuda «?».
|
|
3
|
+
let lampsData = [], lampsMeta = { global: 'lamps/', project: '.lampson/lamps/' };
|
|
4
|
+
async function fetchLamps() {
|
|
5
|
+
let r; try { r = await (await fetch(BASE + '/api/lamps')).json(); } catch (e) { return lampsData; }
|
|
6
|
+
lampsData = r.lamps || []; lampsMeta = { global: r.global || 'lamps/', project: r.project || '.lampson/lamps/' };
|
|
7
|
+
const on = lampsData.filter(l => l.enabled).length;
|
|
8
|
+
const pill = $('#lamps'); pill.textContent = lampsData.length ? `lámparas ${on}/${lampsData.length}` : 'lámparas'; pill.classList.toggle('on', on > 0);
|
|
9
|
+
return lampsData;
|
|
10
|
+
}
|
|
11
|
+
async function loadLamps() { await fetchLamps(); if (Panel.is('lamps')) Panel.refresh(); }
|
|
12
|
+
function lampMatches(l, q) {
|
|
13
|
+
if (!q) return true;
|
|
14
|
+
const hay = [l.name, l.description || '', l.scope, l.kind, ...(l.tools || [])].join(' ').toLowerCase();
|
|
15
|
+
return q.toLowerCase().split(/\s+/).every(t => hay.includes(t));
|
|
16
|
+
}
|
|
17
|
+
function lampField(k, p, required) {
|
|
18
|
+
const def = p.default !== undefined ? p.default : ''; const req = required.includes(k) ? ' <span class="req">(obligatorio)</span>' : '';
|
|
19
|
+
let input;
|
|
20
|
+
if (Array.isArray(p.enum)) input = `<select data-k="${esc(k)}">${p.enum.map(v => `<option value="${esc(String(v))}" ${String(v) === String(def) ? 'selected' : ''}>${esc(String(v))}</option>`).join('')}</select>`;
|
|
21
|
+
else if (p.type === 'boolean') input = `<select data-k="${esc(k)}"><option value="false" ${def === true ? '' : 'selected'}>no</option><option value="true" ${def === true ? 'selected' : ''}>sí</option></select>`;
|
|
22
|
+
else if (p.type === 'object' || p.type === 'array') input = `<textarea data-k="${esc(k)}" data-json="1" spellcheck="false" placeholder="JSON">${esc(def === '' ? (p.type === 'array' ? '[]' : '{}') : JSON.stringify(def, null, 1))}</textarea>`;
|
|
23
|
+
else input = `<input data-k="${esc(k)}" type="${p.type === 'integer' || p.type === 'number' ? 'number' : 'text'}" value="${esc(String(def))}" placeholder="${esc(p.type || 'texto')}">`;
|
|
24
|
+
return `<label class="lf"><span class="lk">${esc(k)}${req}</span>${input}${p.description ? `<span class="ds">${esc(p.description)}</span>` : ''}</label>`;
|
|
25
|
+
}
|
|
26
|
+
// lee los campos de una tool → args (o {error})
|
|
27
|
+
function lampArgs(tdiv, props, required) {
|
|
28
|
+
const args = {};
|
|
29
|
+
for (const el of tdiv.querySelectorAll('[data-k]')) {
|
|
30
|
+
const k = el.dataset.k, p = props[k] || {}; let v = el.value;
|
|
31
|
+
if (el.dataset.json) { if (!v.trim()) continue; try { v = JSON.parse(v); } catch (e) { return { error: `${k}: JSON inválido` }; } }
|
|
32
|
+
else if (p.type === 'boolean') v = v === 'true';
|
|
33
|
+
else if (p.type === 'integer' || p.type === 'number') { if (v === '') { if (required.includes(k)) return { error: `${k} es obligatorio` }; continue; } v = Number(v); }
|
|
34
|
+
else if (v === '') { if (required.includes(k)) return { error: `${k} es obligatorio` }; continue; }
|
|
35
|
+
args[k] = v;
|
|
36
|
+
}
|
|
37
|
+
return { args };
|
|
38
|
+
}
|
|
39
|
+
const LAMPS_HELP = `<h4>Herramientas hechas a medida para este proyecto</h4>
|
|
40
|
+
<p>Una lámpara es una tool que el agente (o vos) escribe para <em>este</em> repo: una carpeta con <code>lamp.json</code> y el código — en Synsema (corre bajo un techo de capacidades que no puede superar) o en cualquier lenguaje (<code>python</code>, <code>node</code>, un binario). A diferencia de un script suelto, el modelo la ve en su catálogo con parámetros tipados, vos la podés correr desde acá, y queda en el repo para la próxima sesión.</p>
|
|
41
|
+
<p><b>Pedila en el chat</b> — <i>«hacé una lámpara que liste los endpoints del backend»</i>. El agente la escribe en <code data-project></code>, la valida y te pide encenderla acá.</p>
|
|
42
|
+
<p><b>Encendida = existe</b> — apagada, el agente ni la ve. Al encenderla, sus tools entran al catálogo como <code>lamp_<lámpara>_<tool></code> desde el próximo turno. Las globales viven en <code data-global></code> y valen para todos los proyectos.</p>
|
|
43
|
+
<p><b>Ejemplos</b></p>
|
|
44
|
+
<ul>
|
|
45
|
+
<li><b>Consultas del proyecto</b> — <code>endpoints()</code> que parsea tus rutas y devuelve método + path + handler; <code>env_keys()</code> usadas vs. definidas en <code>.env.example</code>.</li>
|
|
46
|
+
<li><b>Generadores con tus convenciones</b> — <code>new_component(name)</code> que crea el <code>.tsx</code> + test + story con tu plantilla exacta.</li>
|
|
47
|
+
<li><b>Checkers</b> — <code>check_i18n()</code> claves faltantes por idioma; <code>lint_styles()</code> colores hardcodeados fuera de tus tokens.</li>
|
|
48
|
+
<li><b>Datos locales</b> — <code>db_schema()</code> / <code>query(sql)</code> contra tu SQLite con techo <code>db=./dev.db</code>.</li>
|
|
49
|
+
<li><b>Integraciones sin MCP</b> — <code>deploy_status()</code> contra tu API con techo <code>net=api.x.com</code>.</li>
|
|
50
|
+
</ul>
|
|
51
|
+
<p><button class="primary" data-back>← volver a las lámparas</button></p>`;
|
|
52
|
+
function openLamps(selectName) {
|
|
53
|
+
let first = selectName || null;
|
|
54
|
+
Panel.open({
|
|
55
|
+
id: 'lamps', eyebrow: 'lámparas', title: 'Lámparas', layout: 'browse',
|
|
56
|
+
headActions: [{ label: '?', title: '¿qué es una lámpara?', onClick: (b) => { Panel.help(true); const h = b.querySelector('.phelp'); h.querySelector('[data-project]').textContent = lampsMeta.project; h.querySelector('[data-global]').textContent = lampsMeta.global; h.querySelector('[data-back]').onclick = () => Panel.help(false); } }],
|
|
57
|
+
browse: {
|
|
58
|
+
placeholder: 'buscar lámpara o tool…', listWidth: '260px', help: LAMPS_HELP,
|
|
59
|
+
key: l => l.name,
|
|
60
|
+
load: async (q) => { const all = await fetchLamps(); const list = all.filter(l => lampMatches(l, q)); if (first) { const i = list.findIndex(l => l.name === first); first = null; if (i > 0) list.unshift(list.splice(i, 1)[0]); } return list; },
|
|
61
|
+
render: (l) => `<span class="dot">${l.error ? '!' : (l.enabled ? '●' : '○')}</span><div><div class="nm">${esc(l.name)}</div><div class="meta">${esc(l.scope)} · ${esc(l.kind)} · ${(l.tools || []).length} tool${(l.tools || []).length === 1 ? '' : 's'}${l.error ? ' · rota' : ''}</div></div>`,
|
|
62
|
+
count: (rows, q) => q ? `${rows.length} de ${lampsData.length}` : `${lampsData.length} lámpara${lampsData.length === 1 ? '' : 's'}`,
|
|
63
|
+
emptyHtml: 'ninguna todavía',
|
|
64
|
+
emptyDetail: `Todavía no hay lámparas.<br><br>Pedile una al agente en el chat — <i>«hacé una lámpara que liste los endpoints del backend»</i> — o creá una carpeta con <code>lamp.json</code> en <code>${esc(lampsMeta.project)}</code> (proyecto) o <code>${esc(lampsMeta.global)}</code> (global). El botón <b>?</b> de arriba explica qué son y para qué sirven.`,
|
|
65
|
+
detail: (l) => {
|
|
66
|
+
const specs = l.tool_specs || [];
|
|
67
|
+
const cap = l.error ? `<div class="dcap err">rota: ${esc(l.error)}</div>` : (l.kind === 'syn' ? `<div class="dcap">techo de capacidades: ${esc(l.caps)}</div>` : `<div class="dcap"><b>sin techo</b> (exec) · $ ${esc(l.command)}</div>`);
|
|
68
|
+
return `<div class="dhead"><span class="nm">${esc(l.name)}</span><span class="meta">${esc(l.scope)} · ${esc(l.kind)}</span><label class="sw ${l.enabled ? 'on' : ''}" title="${l.error ? 'no se puede encender: está rota' : (l.enabled ? 'apagar' : 'encender')}"><input type="checkbox" ${l.enabled ? 'checked' : ''} ${l.error ? 'disabled' : ''}>${l.enabled ? 'encendida' : 'apagada'}</label></div>`
|
|
69
|
+
+ (l.description ? `<div class="ddesc">${esc(l.description)}</div>` : '') + cap
|
|
70
|
+
+ (l.enabled || l.error ? '' : `<div class="dnote">Apagada: el agente no la ve y no se puede correr. Encendela con el switch — el código va a poder ejecutarse${l.kind === 'exec' ? ' <b>sin techo de capacidades</b> (es un ejecutable)' : ' bajo el techo de arriba'}.</div>`)
|
|
71
|
+
+ specs.map((t, i) => {
|
|
72
|
+
const props = (t.parameters && t.parameters.properties) || {}; const required = (t.parameters && t.parameters.required) || [];
|
|
73
|
+
return `<div class="tool" data-i="${i}"><div class="th"><code>${esc(t.name)}</code>${t.description ? `<span class="ds">${esc(t.description)}</span>` : '<span></span>'}<span class="ds">lamp_${esc(l.name)}_${esc(t.name)}</span></div>`
|
|
74
|
+
+ (Object.keys(props).length ? Object.keys(props).map(k => lampField(k, props[k], required)).join('') : '<div class="ds" style="margin-top:6px;color:var(--ink-3)">no toma parámetros</div>')
|
|
75
|
+
+ (l.enabled ? `<div class="go"><button class="primary run">▶ correr</button><span class="st"></span></div><pre class="out" style="display:none"></pre>` : '')
|
|
76
|
+
+ `</div>`;
|
|
77
|
+
}).join('')
|
|
78
|
+
+ `<div class="dfoot"><span>${esc(l.dir || '')}</span>${l.scope === 'project' ? '<span class="del">eliminar</span>' : '<span title="las globales se borran a mano">global · se borra a mano</span>'}</div><div class="err" data-err></div>`;
|
|
79
|
+
},
|
|
80
|
+
wire: (l, box) => {
|
|
81
|
+
const errEl = box.querySelector('[data-err]');
|
|
82
|
+
box.querySelector('.sw input').onchange = async (ev) => {
|
|
83
|
+
errEl.textContent = '';
|
|
84
|
+
const r = await api(BASE + '/api/lamps/toggle', { name: l.name, enabled: ev.target.checked });
|
|
85
|
+
if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); ev.target.checked = !ev.target.checked; return; }
|
|
86
|
+
add('meta', '☼ ' + esc(r.data.result || '')); loadLamps(); loadSched();
|
|
87
|
+
};
|
|
88
|
+
for (const tdiv of box.querySelectorAll('.tool')) {
|
|
89
|
+
const t = (l.tool_specs || [])[Number(tdiv.dataset.i)]; const props = (t.parameters && t.parameters.properties) || {}; const required = (t.parameters && t.parameters.required) || [];
|
|
90
|
+
const b = tdiv.querySelector('button.run'); if (!b) continue;
|
|
91
|
+
b.onclick = async () => {
|
|
92
|
+
const st = tdiv.querySelector('.st'), out = tdiv.querySelector('.out'); const got = lampArgs(tdiv, props, required);
|
|
93
|
+
if (got.error) { st.textContent = got.error; return; }
|
|
94
|
+
st.textContent = 'corriendo…'; out.style.display = 'none'; b.disabled = true;
|
|
95
|
+
const r = await api(BASE + '/api/lamps/run', { tool: 'lamp_' + l.name + '_' + t.name, args: got.args });
|
|
96
|
+
st.textContent = r.ok ? '' : (r.data.error || 'error ' + r.status);
|
|
97
|
+
if (r.ok) { out.textContent = r.data.output; out.style.display = ''; }
|
|
98
|
+
b.disabled = false;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const del = box.querySelector('.dfoot .del');
|
|
102
|
+
if (del) del.onclick = () => inlineConfirm(del, `¿borrar ${l.name} del disco?`, async () => {
|
|
103
|
+
const r = await api(BASE + '/api/lamps/remove', { name: l.name });
|
|
104
|
+
if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); return; }
|
|
105
|
+
add('meta', '☼ ' + esc(r.data.result || '')); loadLamps();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
$('#lamps').onclick = () => openLamps();
|
|
112
|
+
if (location.hash === '#lamps') setTimeout(() => openLamps(), 400); // deep link: abre el panel al cargar
|