openvibers/openvibe.actor Commits
Your agents: five templates (job search, research brief, price watch, morning briefing, make something) fill the form; an agent is not made while a part in <angle brackets> is left (#113)
Changes
@@@ -33,3 +33,13 @@.pa-btn.pa-primary:hover { filter: brightness(1.08); }.pa-pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22rem; overflow: auto; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,.15)); font-size: 13px; }.pa-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }++++/* Templates: a first agent in one click (the chosen one stays lit while its form is open below). */++.pa-templates { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); gap: .6rem; }++.pa-template { display: flex; flex-direction: column; gap: .25rem; height: 100%; box-sizing: border-box; padding: .85rem 1rem; border-radius: 12px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }++.pa-template strong { font-size: 1rem; }++.pa-template span { color: var(--text-secondary, #96a7c2); font-size: .9rem; }++.pa-template small { color: var(--text-muted, #7386a3); font-size: .78rem; }++.pa-template.is-chosen { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #131c2e)); }++.pa-template:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (hover: hover) { .pa-template:hover { border-color: var(--accent, #3b82f6); } }@@@ -29,6 +29,22 @@ function createAgentPages(ctx) {const MODE_LABELS = { balanced: 'Balanced (default)', cheapest: 'Cheapest', best: 'Best', fastest: 'Fastest', private: 'Private' };const SCHEDULE_LABELS = { off: 'Only when I run it', hourly: 'Every hour', daily: 'Every day', weekly: 'Every week' };const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];++ // Templates (consumer plan step 4, "personal agents grow up"): a first agent in one click, its instructions written++ // for what Actor's runtime can do (OpenVibe's services as tools, the web in the fuller modes, Codes on your machine).++ // The parts in <angle brackets> are the person's to fill in; an agent is not created while one is left.++ const TEMPLATES = [++ { id: 'job-search', name: 'Job search', schedule: 'daily', schedule_at: '08:00', mode: 'balanced', line: 'New jobs that fit you, every morning',++ instructions: 'Find job listings posted in the last day for <the role you want, your main skills, and where: a city or "remote">. Look on OpenVibe Jobs (openvibe.work) first, then the wider web. List up to 10, each with its title, company, place, the pay when it is given, and the link to apply. Leave out any listing your memory says you were already shown, and note the new ones in your memory.' },++ { id: 'research-brief', name: 'Research brief', schedule: 'weekly', schedule_at: '09:00', schedule_dow: 1, mode: 'best', line: 'A one-page brief on a subject, every week',++ instructions: 'Research <a subject you follow> and write a one-page brief: what is new this week, the main points of view, and what to watch next. Give the link of every source you rely on, and say plainly when something could not be confirmed.' },++ { id: 'price-watch', name: 'Price watch', schedule: 'daily', schedule_at: '12:00', mode: 'cheapest', line: 'Tell me when a price drops',++ instructions: 'Check today\'s price of <the product, and the link to its page in a shop>. Say the price, whether it is lower than the last price in your memory, and the cheapest place you found with its link. Then keep today\'s price in your memory. (For a page that changes often, OpenVibe Alerts can watch it every few minutes and wake this agent.)' },++ { id: 'morning-briefing', name: 'Morning briefing', schedule: 'daily', schedule_at: '07:00', mode: 'balanced', line: 'Five bullets on what happened overnight',++ instructions: 'Summarise what happened in <the subjects you care about> in the last 24 hours in five bullets, each with its link. Then add one line on anything new on OpenVibe from its changelog (openvibe.blog/changelog).' },++ { id: 'make-something', name: 'Make something', schedule: 'off', mode: 'best', line: 'Build it on your computer, as a pull request',++ instructions: 'Build <what you want: a small web page, a script, a tool> in <the repository, for example your-name/your-project> with OpenVibe Code on my own computer, run its tests, and open a pull request for me to review. Never push to the main branch.' },++ ];++ const UNFILLED = /<[^<>]{3,80}>/;function form(a = {}) {return html`<div class="pa-form"><label class="pa-wide">Name <input name="name" maxlength="60" required value="${a.name || ''}" placeholder="Morning briefing"></label>@@@ -55,17 +71,23 @@ function createAgentPages(ctx) {if (!signed(req)) return login(req, res);const agents = await personal.list(s, owner(req));const waiting = await approvals.pendingCount(s, owner(req));++ const chosen = TEMPLATES.find((x) => x.id === req.query.template) || null;page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1><p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p><p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away.</p></header>${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a><span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span><span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`-- : html`<p class="pa-empty">No agent yet. Create your first one below.</p>`}--<section class="pa-panel"><h2>Create an agent</h2><form method="post" action="/me/agents">${form()}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);++ : html`<p class="pa-empty">No agent yet. Start from a template, or create your own below.</p>`}++<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Start from a template</h2>++<ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>++<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);});r.post('/me/agents', async (req, res) => {if (!write(req, res)) return;++ if (UNFILLED.test(String(req.body.instructions || ''))) {++ return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>Fill in the parts in <angle brackets> first: the agent needs to know what you mean.</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit" class="pa-btn pa-primary">Create agent</button></form>`, 422);++ }const out = await personal.create(s, config, owner(req), fields(req.body));if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);res.redirect(303, `/me/agents/${out.row.id}`);@@@ -33,6 +33,21 @@ const SAME = { 'sec-fetch-site': 'same-origin' };assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: a })).status, 200);assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: b })).status, 404);});++ await check('templates fill the form, and an agent is not made while a part in <angle brackets> is left', async () => {++ const listing = await t.get('/me/agents', { as: a });++ ['job-search', 'research-brief', 'price-watch', 'morning-briefing', 'make-something'].forEach((id) => assert.ok(listing.text.includes(`href="/me/agents?template=${id}#create"`), id));++ const chosen = await t.get('/me/agents?template=job-search', { as: a });++ assert.match(chosen.text, /<h2>Create: Job search<\/h2>/);++ assert.match(chosen.text, /value="Job search"/);++ assert.match(chosen.text, /Find job listings posted in the last day for <the role you want/);++ assert.match(chosen.text, /<option value="daily" selected>/);++ const base = { name: 'Job search', mode: 'balanced', budget_per_run_usd: '0.02', daily_runs_max: '1', schedule: 'daily', schedule_at: '08:00', schedule_dow: '', status: 'active' };++ const unfilled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: <the role you want>.' }, headers: SAME });++ assert.strictEqual(unfilled.status, 422);++ assert.match(unfilled.text, /Fill in the parts in <angle brackets> first/);++ const filled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: a junior video editor, remote.' }, headers: SAME });++ assert.strictEqual(filled.status, 303);++ });} finally { await t.close(); }done();})();
@@ -33,3 +33,13 @@
@@ -33,3 +33,13 @@
.pa-btn.pa-primary:hover { filter: brightness(1.08); }
.pa-btn.pa-primary:hover { filter: brightness(1.08); }
.pa-pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22rem; overflow: auto; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,.15)); font-size: 13px; }
.pa-pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22rem; overflow: auto; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,.15)); font-size: 13px; }
.pa-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.pa-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
+
+/* Templates: a first agent in one click (the chosen one stays lit while its form is open below). */
+.pa-templates { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); gap: .6rem; }
+.pa-template { display: flex; flex-direction: column; gap: .25rem; height: 100%; box-sizing: border-box; padding: .85rem 1rem; border-radius: 12px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }
+.pa-template strong { font-size: 1rem; }
+.pa-template span { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
+.pa-template small { color: var(--text-muted, #7386a3); font-size: .78rem; }
+.pa-template.is-chosen { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #131c2e)); }
+.pa-template:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (hover: hover) { .pa-template:hover { border-color: var(--accent, #3b82f6); } }
@@ -29,6 +29,22 @@ function createAgentPages(ctx) {
@@ -29,6 +29,22 @@ function createAgentPages(ctx) {
const MODE_LABELS = { balanced: 'Balanced (default)', cheapest: 'Cheapest', best: 'Best', fastest: 'Fastest', private: 'Private' };
const MODE_LABELS = { balanced: 'Balanced (default)', cheapest: 'Cheapest', best: 'Best', fastest: 'Fastest', private: 'Private' };
const SCHEDULE_LABELS = { off: 'Only when I run it', hourly: 'Every hour', daily: 'Every day', weekly: 'Every week' };
const SCHEDULE_LABELS = { off: 'Only when I run it', hourly: 'Every hour', daily: 'Every day', weekly: 'Every week' };
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+ // Templates (consumer plan step 4, "personal agents grow up"): a first agent in one click, its instructions written
+ // for what Actor's runtime can do (OpenVibe's services as tools, the web in the fuller modes, Codes on your machine).
+ // The parts in <angle brackets> are the person's to fill in; an agent is not created while one is left.
+ const TEMPLATES = [
+ { id: 'job-search', name: 'Job search', schedule: 'daily', schedule_at: '08:00', mode: 'balanced', line: 'New jobs that fit you, every morning',
+ instructions: 'Find job listings posted in the last day for <the role you want, your main skills, and where: a city or "remote">. Look on OpenVibe Jobs (openvibe.work) first, then the wider web. List up to 10, each with its title, company, place, the pay when it is given, and the link to apply. Leave out any listing your memory says you were already shown, and note the new ones in your memory.' },
+ { id: 'research-brief', name: 'Research brief', schedule: 'weekly', schedule_at: '09:00', schedule_dow: 1, mode: 'best', line: 'A one-page brief on a subject, every week',
+ instructions: 'Research <a subject you follow> and write a one-page brief: what is new this week, the main points of view, and what to watch next. Give the link of every source you rely on, and say plainly when something could not be confirmed.' },
+ { id: 'price-watch', name: 'Price watch', schedule: 'daily', schedule_at: '12:00', mode: 'cheapest', line: 'Tell me when a price drops',
+ instructions: 'Check today\'s price of <the product, and the link to its page in a shop>. Say the price, whether it is lower than the last price in your memory, and the cheapest place you found with its link. Then keep today\'s price in your memory. (For a page that changes often, OpenVibe Alerts can watch it every few minutes and wake this agent.)' },
+ { id: 'morning-briefing', name: 'Morning briefing', schedule: 'daily', schedule_at: '07:00', mode: 'balanced', line: 'Five bullets on what happened overnight',
+ instructions: 'Summarise what happened in <the subjects you care about> in the last 24 hours in five bullets, each with its link. Then add one line on anything new on OpenVibe from its changelog (openvibe.blog/changelog).' },
+ { id: 'make-something', name: 'Make something', schedule: 'off', mode: 'best', line: 'Build it on your computer, as a pull request',
+ instructions: 'Build <what you want: a small web page, a script, a tool> in <the repository, for example your-name/your-project> with OpenVibe Code on my own computer, run its tests, and open a pull request for me to review. Never push to the main branch.' },
+ ];
+ const UNFILLED = /<[^<>]{3,80}>/;
function form(a = {}) {
function form(a = {}) {
return html`<div class="pa-form">
return html`<div class="pa-form">
<label class="pa-wide">Name <input name="name" maxlength="60" required value="${a.name || ''}" placeholder="Morning briefing"></label>
<label class="pa-wide">Name <input name="name" maxlength="60" required value="${a.name || ''}" placeholder="Morning briefing"></label>
@@ -55,17 +71,23 @@ function createAgentPages(ctx) {
@@ -55,17 +71,23 @@ function createAgentPages(ctx) {
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const agents = await personal.list(s, owner(req));
const agents = await personal.list(s, owner(req));
const waiting = await approvals.pendingCount(s, owner(req));
const waiting = await approvals.pendingCount(s, owner(req));
+ const chosen = TEMPLATES.find((x) => x.id === req.query.template) || null;
page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>
page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>
<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p>
<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p>
<p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away.</p></header>
<p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away.</p></header>
${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a>
${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a>
<span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span>
<span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span>
<span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`
<span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`
- : html`<p class="pa-empty">No agent yet. Create your first one below.</p>`}
-<section class="pa-panel"><h2>Create an agent</h2><form method="post" action="/me/agents">${form()}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);
+ : html`<p class="pa-empty">No agent yet. Start from a template, or create your own below.</p>`}
+<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Start from a template</h2>
+<ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>
+<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);
});
});
r.post('/me/agents', async (req, res) => {
r.post('/me/agents', async (req, res) => {
if (!write(req, res)) return;
if (!write(req, res)) return;
+ if (UNFILLED.test(String(req.body.instructions || ''))) {
+ return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>Fill in the parts in <angle brackets> first: the agent needs to know what you mean.</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit" class="pa-btn pa-primary">Create agent</button></form>`, 422);
+ }
const out = await personal.create(s, config, owner(req), fields(req.body));
const out = await personal.create(s, config, owner(req), fields(req.body));
if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);
if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);
res.redirect(303, `/me/agents/${out.row.id}`);
res.redirect(303, `/me/agents/${out.row.id}`);
@@ -33,6 +33,21 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -33,6 +33,21 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: a })).status, 200);
assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: a })).status, 200);
assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: b })).status, 404);
assert.strictEqual((await t.get(`/me/agents/${id}/forget`, { as: b })).status, 404);
});
});
+ await check('templates fill the form, and an agent is not made while a part in <angle brackets> is left', async () => {
+ const listing = await t.get('/me/agents', { as: a });
+ ['job-search', 'research-brief', 'price-watch', 'morning-briefing', 'make-something'].forEach((id) => assert.ok(listing.text.includes(`href="/me/agents?template=${id}#create"`), id));
+ const chosen = await t.get('/me/agents?template=job-search', { as: a });
+ assert.match(chosen.text, /<h2>Create: Job search<\/h2>/);
+ assert.match(chosen.text, /value="Job search"/);
+ assert.match(chosen.text, /Find job listings posted in the last day for <the role you want/);
+ assert.match(chosen.text, /<option value="daily" selected>/);
+ const base = { name: 'Job search', mode: 'balanced', budget_per_run_usd: '0.02', daily_runs_max: '1', schedule: 'daily', schedule_at: '08:00', schedule_dow: '', status: 'active' };
+ const unfilled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: <the role you want>.' }, headers: SAME });
+ assert.strictEqual(unfilled.status, 422);
+ assert.match(unfilled.text, /Fill in the parts in <angle brackets> first/);
+ const filled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: a junior video editor, remote.' }, headers: SAME });
+ assert.strictEqual(filled.status, 303);
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})();
})();