openvibers/openvibe.actor Commits
HO3 goals that keep going, and HO4 the routine answers in its thread (#333)
Changes
nnew file mode 100644@@@ -0,0 +1,34 @@++-- phase: expand++-- HO3 (a goal that keeps going) and HO4 (the routine answers in its thread).++--++-- A goal is a trigger of kind goal: it keeps working toward GOAL->>'text' on its schedule, and each++-- run answers its done-test GOAL->>'done_test'. GOAL holds what the person set (the text, the++-- done-test, the run limit, the money budget and the deadline); the progress columns hold how far it++-- has got. A goal ends itself (state 'done' or paused with budget/deadline) and says so in the thread,++-- so the state check has to admit 'done'.++--++-- ANSWER_IN_CHAT is the routine's own switch (default on): each run's answer is posted into the++-- conversation that made the trigger, under its card. NOTIFY is off by default: the answer in the++-- chat is the telling, unless the person also asked to be told through OpenVibe's notifications.++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS goal jsonb;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS runs_used integer NOT NULL DEFAULT 0;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS spent_usd double precision NOT NULL DEFAULT 0;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS verdict jsonb;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS answer_in_chat boolean NOT NULL DEFAULT true;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT false;++ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS ended_at bigint;++++-- 0037 wrote the state check inline, so PostgreSQL named it actor_triggers_state_check. A goal that++-- reached its done-test is 'done', not paused: it is over, and resuming it would make no sense.++ALTER TABLE actor_triggers DROP CONSTRAINT IF EXISTS actor_triggers_state_check;++ALTER TABLE actor_triggers ADD CONSTRAINT actor_triggers_state_check CHECK (state IN ('active','paused','done','deleted'));++++-- The third kind: a goal that keeps going until its own check passes.++ALTER TABLE actor_triggers DROP CONSTRAINT IF EXISTS actor_triggers_kind_check;++ALTER TABLE actor_triggers ADD CONSTRAINT actor_triggers_kind_check CHECK (kind IN ('schedule','event','goal'));++++-- A goal's own kind, for the queries that only look at live goals.++CREATE INDEX IF NOT EXISTS actor_triggers_goal ON actor_triggers (owner) WHERE kind = 'goal' AND deleted_at IS NULL;++++-- What a run cost, so a goal's money budget is the sum of its runs and not a guess.++ALTER TABLE actor_trigger_runs ADD COLUMN IF NOT EXISTS cost_usd double precision;@@@ -71,6 +71,23 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app.ct-trig-del[open] > summary { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger-text, #f87171) 55%, var(--ovt-line)); }.ct-trig-del > p { flex-basis: 100%; margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: 13.5px; }.ovt-card-btn.is-danger { background: var(--danger-text, #f87171); color: #1a0505; }++/* A goal (plan HO3): "Run 4 of at most 12 · not yet: €134", and the goal in the person's own words. */++.ct-trig.is-done .ct-trig-state { background: color-mix(in srgb, var(--ovt-accent, #f97316) 18%, transparent); color: var(--ovt-accent, #f97316); }++.ct-trig-goal { font-variant-numeric: tabular-nums; }++.ct-trig-goal-text { color: var(--text-secondary, #96a7c2); font-size: 13.5px; font-style: italic; overflow-wrap: anywhere; }++/* The routine's two switches (plan HO4): answer in this chat, and also tell me. */++.ct-trig-switches { margin-top: 2px; }++.ct-trig-switches .ovt-card-btn.is-on { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, #f97316) 55%, transparent); }++/* What a routine's run said, posted into its chat under the card (plan HO4). */++.ct-trig-runs { display: grid; gap: 8px; margin-top: 8px; }++.ct-trig-run { border-left: 2px solid var(--line, rgba(255,255,255,.12)); padding: 2px 0 2px 12px; display: grid; gap: 4px; }++.ct-trig-run.is-bad { border-left-color: color-mix(in srgb, var(--warning-text, #fbbf24) 60%, transparent); }++.ct-trig-run p { margin: 0; }++.ct-trig-run-h { color: var(--text-secondary, #96a7c2); font-size: 13px; }++.ct-trig-run-h a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }++.ct-trig-run-line { color: var(--warning-text, #fbbf24); font-size: 14px; }++.ct-trig-sources { color: var(--text-secondary, #96a7c2); font-size: 12.5px; overflow-wrap: anywhere; }++.ct-trig-sources a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }@media (max-width: 480px) { .ct-trig .ovt-card-btn { min-height: 44px; } }/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }@@@ -234,15 +234,18 @@ function stepFor(name, input) {// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──case 'schedule_trigger': {const what = text(a.task || a.request, 160);++ const goal = a.goal && typeof a.goal === 'object' && a.goal.done_test ? text(a.goal.done_test, 200) : null;const when = a.cron ? `${text(a.cron, 40)}${a.timezone ? `, ${text(a.timezone, 40)}` : ''}`: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');return {act: 'act',kind: 'routine',-- summary: `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,++ summary: goal++ ? `Keep working${when ? ` (${when})` : ''} until ${quoted(goal, 200)}, then stop: ${quoted(what, 160) || 'the goal you asked for'}`++ : `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,// What repeats and when, both: allowing a schedule must not allow whatever task rides on it.target: routineTarget(a),-- payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id }),++ payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id, goal: a.goal, notify: a.notify }),};}@@@ -194,12 +194,15 @@ function personText(task) {return end < 0 ? t : t.slice(0, end);}--async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null } = {}) {++async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null } = {}) {const { s, config, engine, webhooks } = ctx;if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };++ // A goal's run (plan HO3) does a piece of the work and then answers the goal's done-test, in one line++ // the trigger records: the agent judges, with what the run actually saw.++ const runInput = goal && goal.done_test ? require('../triggers/goal').promptFor(goal, input) : input;// The person's own active memory and this agent's active notes; a suggestion is not put in a run.const notes = await memoryStore.forRun(s, agent.owner, agent.id);-- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, beforeInsert: async () => {++ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, runInput), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, beforeInsert: async () => {const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));@@@ -111,6 +111,10 @@ function load(env = process.env) {},// The per-call liability ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.maxCostUsd: optNum(env.ACTOR_AI_MAX_COST_USD),++ // Latency classes (plan BA/HO): a routine's or a goal's run may wait, so it asks AI's router for the++ // "background" class (a Flex tier, off-peak, or the host's own idle model). Contracts 0.336.0 carries++ // options.latency (BA3); off until AI is deployed with it, because an older AI refuses an unknown option.++ latencyClasses: bool(env.ACTOR_AI_LATENCY_CLASSES),// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is// the plain one it was before.@@@ -325,10 +325,12 @@ function createApi(ctx) {? await triggers.forDelivery(row.owner, { triggerId: body.trigger_id, watchId: body.watch_id }): null;if (trigger && trigger.state === 'paused') return problem(req, res, 409, 'actor.trigger.paused', 'This trigger is paused, so nothing ran. Resume it on its card.');++ if (trigger && trigger.state === 'done') return problem(req, res, 409, 'actor.trigger.done', 'This goal is done, so nothing ran.');const out = await personal.run(ctx, row, body.input || '', {origin: viaWatch ? 'routine' : 'person',level: trigger ? trigger.level : null,triggerId: trigger ? trigger.id : null,++ goal: trigger ? trigger.goal : null,});if (out.code) return problem(req, res, out.status, out.code, out.detail);res.set('Location', `/api/v1/tasks/${out.task.id}`);@@@ -642,9 +644,10 @@ function createApi(ctx) {const b = req.body || {};const out = await triggers.create({owner: triggerOwner(req), agentId: b.agent_id, task: b.task, kind: b.kind,-- cron: b.cron, timezone: b.timezone, event: b.event,++ cron: b.cron, timezone: b.timezone, event: b.event, goal: b.goal,level: 'ask', turnTaskId: TASK_ID.test(String(b.turn_task_id || '')) ? b.turn_task_id : null,personToken: watchToken(req),++ answerInChat: b.answer_in_chat !== false, notify: b.notify === true,});if (out.error) return triggerFail(req, res, out);res.status(201).json({ trigger: out.trigger });@@@ -307,15 +307,22 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o}/**-- * The routines this turn made (plan §12 A-4): one card each, its latest state. What runs and when in words, the-- * next run in the reader's own zone, how the last run went, and the person's moves as plain forms (they work with-- * no script): Pause or Resume, Run now, and Undo for ten minutes, then Delete with a confirmation. A shared, read-only-- * turn shows the card as it was made, with no moves and nothing read beyond the turn itself.++ * The routines this turn made (plan §12 A-4): one card each, its latest state, and under it the runs'++ * answers (plan HO4) — each run posts its answer, its sources and a link to the run into this chat.++ * What runs and when in words, the next run in the reader's own zone, how the last run went, and the++ * person's moves as plain forms (they work with no script): Pause or Resume, Run now, and Undo for ten++ * minutes, then Delete with a confirmation. A shared, read-only turn shows the card as it was made, with++ * no moves and nothing read beyond the turn itself.*/async function triggerBlocks(row, events, readOnly = false) {const latest = new Map();++ const runs = new Map();for (const e of events || []) {if (e && e.kind === 'output' && e.step === 'trigger' && e.trigger && TRIGGER_ID.test(String(e.trigger.id || ''))) latest.set(e.trigger.id, e.trigger);++ if (e && e.kind === 'output' && e.step === 'trigger-run' && TRIGGER_ID.test(String(e.trigger_id || ''))) {++ if (!runs.has(e.trigger_id)) runs.set(e.trigger_id, []);++ runs.get(e.trigger_id).push(e);++ }}if (!latest.size) return '';const owner = String(row.requester || '').replace(/^user:/, '');@@@ -327,37 +334,85 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="oconst live = await ctx.triggers.get(owner, id).catch(() => null);if (live) card = live; else gone = true;}-- cards.push(triggerCard(row, card, { gone, readOnly }));++ cards.push(triggerCard(row, card, { gone, readOnly, runs: runs.get(id) || [] }));}return html`${cards}`;}-- function triggerCard(row, card, { gone = false, readOnly = false } = {}) {++ /**++ * One run of a routine, as it was posted into this chat (plan HO4): when it ran, its answer, the addresses++ * it read and a link to the run. A run that did not finish is one line that says why. A goal's run also++ * shows the run's own verdict on the done-test when there is one.++ */++ function runPost(run) {++ const at = run.at ? new Date(run.at) : null;++ const when = at && !Number.isNaN(at.getTime())++ ? html`<time datetime="${at.toISOString()}" data-ct-local>${at.toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`++ : 'A run';++ const taskId = run.run && run.run.task_id;++ const failed = run.run && run.run.state !== 'ok';++ const sources = Array.isArray(run.sources) ? run.sources.slice(0, 8) : [];++ const body = failed || !run.text++ ? html`<p class="ct-trig-run-line">${String(run.text || 'That run did not finish.')}</p>`++ : html`<div class="prose">${raw(markdown(String(run.text).slice(0, 8000), { headingOffset: 3 }))}</div>`;++ return html`<div class="ct-trig-run${failed ? ' is-bad' : ''}">++<p class="ct-trig-run-h">${when}${taskId ? html` · <a href="/tasks/${taskId}">open the run</a>` : ''}</p>++${body}++${sources.length ? html`<p class="ct-trig-sources">${sources.map((u, i) => html`${i ? ' · ' : ''}<a href="${u}" target="_blank" rel="noopener nofollow">${clip(String(u).replace(/^https?:\/\//, ''), 60)}</a>`)}</p>` : ''}++</div>`;++ }++++ function triggerCard(row, card, { gone = false, readOnly = false, runs = [] } = {}) {const when = card.when || {};++ const goal = card.goal || null;const paused = !gone && card.state === 'paused';-- const state = gone ? 'Deleted' : paused ? 'Paused' : 'On';++ const done = !gone && card.state === 'done';++ const state = gone ? 'Deleted' : done ? 'Done' : paused ? 'Paused' : 'On';const last = card.last_run && LAST_RUN[card.last_run.state] ? card.last_run : null;const undoable = !gone && card.undo_until && Date.parse(card.undo_until) > now();-- const why = paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.` : 'Paused by you.') : '';-- const icon = card.kind === 'event' ? 'zap' : 'calendar';++ const why = done++ ? (card.paused_reason === 'done' && goal ? `The goal is reached: ${goal.done_test}.` : 'This goal is finished.')++ : paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.`++ : card.paused_reason === 'budget' ? 'Stopped: its runs or its money ran out.'++ : card.paused_reason === 'deadline' ? 'Stopped: its deadline passed.'++ : 'Paused by you.') : '';++ const icon = card.kind === 'event' ? 'zap' : card.kind === 'goal' ? 'target' : 'calendar';const words = String(when.words || (card.kind === 'event' ? 'When it happens' : 'On a schedule'));-- const zone = card.kind === 'schedule' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';++ const zone = card.kind !== 'event' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';const meta = [];-- if (!gone && !paused && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);++ // A goal's progress in one line (plan HO3): "Run 4 of at most 12 · not yet: €134".++ if (goal) {++ const used = Number(goal.runs_used) || 0;++ const cap = goal.runs_max == null ? null : Number(goal.runs_max);++ const spend = goal.budget_usd == null ? '' : ` · $${Number(goal.spent_usd || 0).toFixed(2)} of $${Number(goal.budget_usd).toFixed(2)}`;++ const nobody = done || paused || gone ? '' : (goal.not_yet ? ` · not yet: ${clip(goal.not_yet, 120)}` : '');++ meta.push(html`<span class="ct-trig-goal">Run ${used}${cap == null ? '' : ` of at most ${cap}`}${spend}${nobody}</span>`);++ if (goal.deadline_at && !done && !gone) meta.push(html`until <time datetime="${new Date(goal.deadline_at).toISOString()}" data-ct-local>${new Date(goal.deadline_at).toISOString().slice(0, 10)}</time>`);++ }++ if (!gone && !paused && !done && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);if (last) meta.push(html`<a href="/tasks/${last.task_id}">Last run ${LAST_RUN[last.state]}</a>`);if (card.agent_name) meta.push(html`Runs as ${card.agent_name}`);const moves = gone || readOnly ? '' : html`<div class="ovt-card-acts">--${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>`}--<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>++${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : ''}++${!paused && !done ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>` : ''}++${done ? '' : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>`}${undoable ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="undo">Undo</button>`: html`<details class="ct-trig-del"><summary class="ovt-card-btn is-quiet">Delete</summary><p>Delete it for good? No future run happens.</p><button type="submit" class="ovt-card-btn is-danger" name="do" value="delete">Delete it</button></details>`}</div>`;-- return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}">++ // The two switches (plan HO4): answer in this chat, and also tell me through notifications.++ const switches = gone || readOnly ? '' : html`<div class="ovt-card-acts ct-trig-switches">++<button type="submit" class="ovt-card-btn is-quiet${card.answer_in_chat === false ? '' : ' is-on'}" name="do" value="${card.answer_in_chat === false ? 'answer-on' : 'answer-off'}" aria-pressed="${card.answer_in_chat === false ? 'false' : 'true'}">Answer in this chat: ${card.answer_in_chat === false ? 'off' : 'on'}</button>++<button type="submit" class="ovt-card-btn is-quiet${card.notify ? ' is-on' : ''}" name="do" value="${card.notify ? 'notify-off' : 'notify-on'}" aria-pressed="${card.notify ? 'true' : 'false'}">Tell me too: ${card.notify ? 'on' : 'off'}</button>++</div>`;++ const shown = runs.slice(-8).map(runPost);++ return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}${done ? ' is-done' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}"><p class="ovt-card-h">${raw(glyphs.svg(icon, { size: 18 }))}<span class="ct-trig-when"><b>${words}</b>${zone}</span><span class="ct-trig-state">${state}</span></p><p class="ct-trig-task">${String(card.task || '')}</p>++${goal && goal.text ? html`<p class="ct-trig-goal-text">${clip(goal.text, 400)}</p>` : ''}${meta.length ? html`<p class="ct-trig-meta">${meta.map((m, i) => html`${i ? ' · ' : ''}${m}`)}</p>` : ''}${why ? html`<p class="ct-trig-why">${why}</p>` : ''}--${moves}</form>`;++${moves}${switches}</form>++${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;}/** The files beside the question (thread.files draws them). */@@@ -712,7 +767,10 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on: move === 'run' ? await t.runNow(owner, req.params.tid): move === 'undo' ? await t.undo(owner, req.params.tid, { token }): move === 'delete' ? await t.remove(owner, req.params.tid, { token, confirm: true })-- : { error: { status: 400, detail: 'Say pause, resume, run, undo or delete.' } };++ // The routine's two switches (HO4): answer in this chat, and also tell me.++ : move === 'answer-on' || move === 'answer-off' ? await t.setSwitch(owner, req.params.tid, { answerInChat: move === 'answer-on' })++ : move === 'notify-on' || move === 'notify-off' ? await t.setSwitch(owner, req.params.tid, { notify: move === 'notify-on' })++ : { error: { status: 400, detail: 'Say pause, resume, run, undo, delete, answer-on or notify-on.' } };if (out && out.error) {const status = out.error.status || 409;if (wantsJson) return res.status(status).json({ error: out.error.detail || 'That did not change.', code: out.error.code || null });@@@ -302,6 +302,8 @@ function createLoop({ config, models, registry, log = console, now = () => Date.maxTokens: MAX_ANSWER_TOKENS,signal: state.signal,...(onDelta ? { onDelta } : {}),++ // A routine's run may wait (plan BA): the class it asks AI's router for, when the caller named one.++ ...(ctx.latency ? { latency: ctx.latency } : {}),});} catch (err) {await spanModel(ctx.trace, { tier: state.tier, family: state.family, startedAt, ms: now() - startedAt, error: err, step: state.calls + 1 });@@@ -856,7 +858,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered* without another model call.*/-- async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null, personToken = null, timezone = null, triggers = null, level = null }) {++ async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null }) {const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);const restored = restoreState(source);if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });@@@ -877,7 +879,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.// delivers a saved final answer without calling the model again.if (fork) state.answered = false;const ctx = {-- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -1014,9 +1016,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.}}-- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null }) {++ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null }) {if (resume || fork) {-- return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });++ return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta, latency });}const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });@@@ -1040,7 +1042,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.},};const ctx = {-- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -1,17 +1,21 @@'use strict';/**-- * A trigger made in the conversation (plan §12 A-4): "every weekday at 9, summarise my inbox".++ * A trigger made in the conversation (plan §12 A-4, HO3, HO4): "every weekday at 9, summarise my inbox", "when a++ * new issue lands in my repo, triage it", or "keep checking the price of X every 30 minutes until it drops++ * under €120, then tell me" — a goal that keeps going until its own check passes.*-- * The sentence is parsed here, deterministically. When it is ambiguous (no time, no timezone, two-- * times, "every so often", no event it can name) the tool creates nothing and returns the one-- * question to ask in plain words. When it is clear, the subscription is created in OpenVibe.Watch-- * with the person's own token, linked to this thread, and the card goes onto the turn as a trigger-- * step. The model may also pass the fields explicitly (`cron`, `timezone`, `task`, `event`) after the-- * person answered.++ * The sentence is parsed here, deterministically. When it is ambiguous (no time, no timezone, two times, "every++ * so often", no event it can name, an "until" no run can check) the tool creates nothing and returns the one++ * question to ask in plain words. When it is clear, the subscription is created in OpenVibe.Watch with the++ * person's own token, linked to this thread, and the card goes onto the turn as a trigger step. The model may++ * also pass the fields explicitly (`cron`, `timezone`, `task`, `event`, `goal`) after the person answered.++ *++ * A goal (kind goal) carries the goal in words, the done-test each run answers yes or no, and its limits: runs,++ * money and a deadline. Its runs post their answers in this chat (unless the person switches that off).*/const { personOf } = require('./_openvibe');const contacts = require('../../agents/contacts');--const { parseRequest } = require('../../triggers/parse');++const { parseRequest, wordsFor } = require('../../triggers/parse');const cron = require('../../triggers/cron');const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;@@@ -27,7 +31,7 @@ async function namedAgent(s, config, owner, text) {module.exports = {name: 'schedule_trigger',-- description: 'Make something run on a schedule or when an OpenVibe event happens: "every weekday at 9, summarise my inbox" or "when a new issue lands in my repo, triage it". It parses the sentence, creates the Watch subscription as the person, and puts a live trigger card on this thread. If the request is ambiguous it creates nothing and tells you the one question to ask them first (the time, the timezone, what event, or what to do). Money, passwords, deletions and new recipients still always ask when the trigger runs.',++ description: 'Make something run on a schedule, when an OpenVibe event happens, or keep working toward a goal until it is done: "every weekday at 9, summarise my inbox", "when a new issue lands in my repo, triage it", or "keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me". It parses the sentence, creates the Watch subscription as the person, and puts a live trigger card on this thread. If the request is ambiguous it creates nothing and tells you the one question to ask them first (the time, the timezone, what event, what to do, or — for a goal — the check that says it is done). A goal ends itself when its check passes, or when its runs, money or deadline is spent, and each run posts its answer in this chat. Money, passwords, deletions and new recipients still always ask when the trigger runs.',scope: 'person',acts: true,costUsd: 0,@@@ -49,6 +53,19 @@ module.exports = {},required: ['pattern'],},++ goal: {++ type: 'object',++ description: 'A goal to keep working toward, when the person asked for one ("until …"). Give it when the sentence did not parse into one after you asked, or to set its limits.',++ properties: {++ text: { type: 'string', description: 'The goal in the person\'s words.' },++ done_test: { type: 'string', description: 'The check a run answers yes or no, such as "the price is under €120" or "the tests pass".' },++ runs_max: { type: 'integer', description: 'The most runs allowed (default 12).' },++ budget_usd: { type: 'number', description: 'The most its runs may spend (default: the person\'s daily allowance).' },++ deadline_at: { type: 'string', description: 'A date-time after which the goal ends (default: seven days).' },++ },++ required: ['text', 'done_test'],++ },++ notify: { type: 'boolean', description: 'Also tell the person through OpenVibe\'s notifications when a run finishes. Default: only when their sentence asked to be told.' },},required: [],},@@@ -59,23 +76,29 @@ module.exports = {const said = String(input.request || '').trim();const explicitCron = typeof input.cron === 'string' && input.cron.trim() ? input.cron.trim() : null;const explicitEvent = input.event && typeof input.event === 'object' && EVENT_PATTERN.test(String(input.event.pattern || '')) ? input.event : null;++ const explicitGoal = input.goal && typeof input.goal === 'object' && String(input.goal.text || '').trim() && String(input.goal.done_test || '').trim() ? input.goal : null;++ const timezone = String(input.timezone || ctx.timezone || '').trim();let parsed;-- if (explicitCron || explicitEvent) {-- const kind = explicitCron ? 'schedule' : 'event';-- const timezone = String(input.timezone || ctx.timezone || '').trim();++ if (explicitCron || explicitEvent || explicitGoal) {++ const kind = explicitGoal ? 'goal' : explicitCron ? 'schedule' : 'event';const task = String(input.task || said || '').trim();if (!task) return { text: 'Before I make it: what should I do each time?', data: { question: 'What should I do each time?' } };-- if (kind === 'schedule') {++ if (kind !== 'event') {if (!timezone || !cron.isZone(timezone)) return { text: 'Before I make it: which timezone should the schedule use? (For example Europe/Berlin.)', data: { question: 'Which timezone?' } };-- const verdict = cron.validate(explicitCron, timezone);++ const expr = explicitCron || '*/15 * * * *';++ const verdict = cron.validate(expr, timezone);if (!verdict.valid) return { text: `That schedule cannot run: ${verdict.error}. What time should it run?`, data: { question: `That schedule cannot run: ${verdict.error}. What time should it run?` } };-- parsed = { status: 'ok', kind, cron: explicitCron, timezone, task, words: require('../../triggers/parse').wordsFor(explicitCron, timezone) };++ parsed = {++ status: 'ok', kind, cron: expr, timezone, task, words: wordsFor(expr, timezone),++ ...(explicitGoal ? { goal: { text: String(explicitGoal.text).trim(), done_test: String(explicitGoal.done_test).trim(), runs_max: explicitGoal.runs_max, budget_usd: explicitGoal.budget_usd, deadline_at: explicitGoal.deadline_at } } : {}),++ tell: false,++ };} else {-- parsed = { status: 'ok', kind, task, event: { pattern: input.event.pattern, filter: input.event.filter || null }, words: `When ${input.event.pattern}` };++ parsed = { status: 'ok', kind, task, event: { pattern: input.event.pattern, filter: input.event.filter || null }, words: `When ${input.event.pattern}`, tell: false };}} else {-- parsed = parseRequest(said, { timezone: String(input.timezone || ctx.timezone || '').trim() || null, task: input.task || null });++ parsed = parseRequest(said, { timezone: timezone || null, task: input.task || null });}if (parsed.status !== 'ok') {return { text: `I need one thing before I make it: ${parsed.question}`, data: { needs_you: true, question: parsed.question }, sources: [] };@@@ -94,16 +117,19 @@ module.exports = {}if (!agentId) return { text: 'Before I make it: which of your agents should run it?', data: { question: 'Which agent?' } };++ const notify = input.notify === true || (input.notify === undefined && parsed.tell === true);const out = await ctx.triggers.create({owner, agentId, task: parsed.task,kind: parsed.kind,cron: parsed.cron || null,timezone: parsed.timezone || null,event: parsed.event || null,++ goal: parsed.goal || null,level: ctx.level || 'ask',turnTaskId: ctx.taskId || null,personToken: ctx.personToken || null,announce: false,++ notify,});if (out.error) {return {@@@ -112,7 +138,8 @@ module.exports = {};}const card = out.trigger;-- const line = `It is on: ${card.when.words}. ${clip(card.task, 160)}${card.next_run_at ? ` Next run ${card.next_run_at}.` : ''}`;++ const goal = card.goal ? ` It ends when ${card.goal.done_test}, or after ${card.goal.runs_max} runs${card.goal.deadline_at ? ` or on ${String(card.goal.deadline_at).slice(0, 10)}` : ''}.` : '';++ const line = `It is on: ${card.when.words}. ${clip(card.task, 160)}${card.next_run_at ? ` Next run ${card.next_run_at}.` : ''}${goal}${notify ? ' I will tell you when a run finishes.' : ''}`;if (ctx.emit) await ctx.emit({ step: 'trigger', text: line, trigger: card });return { text: line, data: { trigger: card } };},@@@ -59,6 +59,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =let waits = waitsArg;// Triggers are created after the engine (they need it to run now); the app sets them once.let triggers = triggersArg;++++ /**++ * The latency class a run asks AI's router for (plan BA track, HO3/HO4). A routine's run — a schedule, an++ * event or a goal — may wait, so it asks for "background": AI then picks the cheapest tier that can wait (a++ * Flex service tier, off-peak, or the host's own idle model). An interactive task asks for nothing and keeps++ * the router's normal choice. server/runtime/models.js sends the class only when ACTOR_AI_LATENCY_CLASSES is++ * on, so a server pointed at an AI still on the older contract (before options.latency) is never refused.++ */++ async function latencyClass(taskId) {++ const root = await s.db.maybe('SELECT origin FROM actor_assignments WHERE task_id = $1 AND depth = 0', [taskId]).catch(() => null);++ return root && root.origin === 'routine' ? 'background' : null;++ }const setTriggers = (t) => { triggers = t || null; };const controls = new Map(); // task id → the run's control object (live assumptions)let stopped = false;@@@ -158,7 +170,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (ended) runTokens.drop(id);if (ended) stream.end(id);// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.-- if (ended && row && (state === 'succeeded' || state === 'failed')) await tellPerson(row, state);++ // A routine's run is the exception (plan HO4): its answer already goes into the conversation that made the++ // trigger, so the person is told through notifications only when the routine asked for it.++ if (ended && row && (state === 'succeeded' || state === 'failed')) {++ const routine = triggers && typeof triggers.tellsPerson === 'function' ? await triggers.tellsPerson(row).catch(() => null) : null;++ if (routine !== false) await tellPerson(row, state);++ }// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.if (webhooks && row && row.webhooks) {try {@@@ -470,6 +487,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =personText, agentId: ownAgent, notifyTask,askPerson, control, resume, fork, spent: () => spent,waits, waitEvent: wakePayload, attachments,++ // A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.++ latency: await latencyClass(id),}));// The run parked (plan A2): the wait is open, so the task is not finished. It stays open// ('running' is the closest state there is today), its wall clock waits with it, and itsnnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops under €120".++ *++ * The goal is a trigger of kind goal. Its schedule is the check interval (Watch's five-field cron, the++ * same 15-minute floor), and each run does a piece of the work and then answers the goal's done-test:++ * one short yes or no with its reason, on a line of its own. The answer is the agent's judgement, made++ * with what the run actually saw; a run that never says is not a done-test passing.++ *++ * promptFor(goal, task) the run input: the work, then what to answer++ * verdictOf(answer) { done, reason, answer } — the machine line taken out of the answer++ * endingOf({ goal, runsUsed, spentUsd, at }) { reason, line } — how the goal ends now, or null++ */++++/** "VERDICT: yes — €112 on the maker's store" (an em dash, a colon or a hyphen all do). */++const VERDICT_SOURCE = String.raw`^[ \t]*VERDICT[ \t]*:[ \t]*(yes|no|true|false)[ \t]*(?:[—–:-]+[ \t]*)?(.*)$`;++const verdictRe = () => new RegExp(VERDICT_SOURCE, 'gim');++const MAX_REASON = 300;++++const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();++const clip = (s, n) => { const t = clean(s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++++/** What one run is asked: do a piece of the work, then answer the done-test in one line. */++function promptFor(goal, task) {++ const text = clip(goal && goal.text, 500);++ const test = clip(goal && goal.done_test, 300);++ if (!test) return String(task || '');++ return [++ `This is one run of a goal the person set: "${text}".`,++ `The goal is done only when this is true: "${test}". Each run you do a piece of the work, then check it.`,++ 'End your answer with one line of exactly this shape:',++ 'VERDICT: yes — <the one fact that settles it>',++ 'or',++ 'VERDICT: no — <what is still missing, in as few words as possible>',++ 'Use what this run actually saw. Never say yes on a guess, and never write anything after that line.',++ '',++ String(task || ''),++ ].join('\n');++}++++/**++ * The answer with its verdict: { done: true|false|null, reason, answer }. A run that did not answer the++ * done-test is done: null, so the goal keeps going and says the agent did not say.++ */++function verdictOf(answer) {++ const text = String(answer == null ? '' : answer);++ let found = null;++ const re = verdictRe();++ for (const m of text.matchAll(re)) found = m;++ if (!found) return { done: null, reason: '', answer: text.trim() };++ const done = /^(yes|true)$/i.test(found[1]);++ const reason = clip(found[2].replace(/^[\s—–:-]+/, ''), MAX_REASON);++ const kept = text.replace(verdictRe(), '').replace(/\n{3,}/g, '\n\n').trim();++ return { done, reason, answer: kept };++}++++/**++ * How a goal ends after a run, from its own limits: the done-test passed, or the last run, the money or++ * the deadline is spent. → { reason, line } or null. `reason` is the card's paused_reason.++ */++function endingOf({ goal, verdict, runsUsed, spentUsd, at = Date.now() }) {++ const g = goal || {};++ if (verdict && verdict.done === true) {++ return { reason: 'done', line: `Goal reached: ${clip(g.done_test, 200)}${verdict.reason ? ` (${verdict.reason})` : ''}.` };++ }++ const runsMax = g.runs_max == null ? NaN : Number(g.runs_max);++ if (Number.isFinite(runsMax) && runsMax > 0 && runsUsed >= runsMax) {++ return { reason: 'budget', line: `Stopping here: the goal has used its ${runsMax} run${runsMax === 1 ? '' : 's'} and is not done.` };++ }++ const budget = g.budget_usd == null ? NaN : Number(g.budget_usd);++ if (Number.isFinite(budget) && spentUsd >= budget) {++ return { reason: 'budget', line: `Stopping here: the goal has spent its $${budget.toFixed(2)} budget and is not done.` };++ }++ const deadline = g.deadline_at ? Date.parse(g.deadline_at) : NaN;++ if (Number.isFinite(deadline) && at >= deadline) {++ return { reason: 'deadline', line: `Stopping here: ${new Date(deadline).toISOString().slice(0, 10)} passed and the goal is not done.` };++ }++ return null;++}++++module.exports = { promptFor, verdictOf, endingOf, VERDICT_SOURCE, MAX_REASON };@@@ -163,6 +163,56 @@ function scheduleIn(text) {return best;}++/**++ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops++ * under €120, then tell me". The check that ends it is the clause after "until"; the interval is the++ * schedule phrase when there is one and every fifteen minutes (Watch's floor) when there is not. A++ * check that cannot be seen ("until it's done", "until I'm happy") is a question, never a guess.++ */++const GOAL_EVERY = '*/15 * * * *';++/** A done-test must name something a run can see: a number, or a change of state. */++const CHECKABLE = /\b(pass(?:es|ed)?|fail(?:s|ed)?|green|resolv(?:ed|es)?|fixed|below|above|under|over|less than|more than|at least|at most|cheaper|cheapest|drop(?:s|ped)?|fall(?:s|en)?|rose?|rises?|reach(?:es|ed)?|hit(?:s)?|in stock|sold out|available|publish(?:ed|es)?|approv(?:ed|es)?|repl(?:y|ies|ied)|answer(?:s|ed)?|arriv(?:es|ed)|exceed(?:s|ed)?|match(?:es|ed)?|merge(?:s|d)?|fix(?:ed|es)?|return(?:s|ed)?|starts?|ends?|opens?|closes?)\b/i;++const TELLING = /[,;]?\s*(?:and\s+|then\s+|so\s+)?(?:please\s+)?(?:tell me|let me know|notify me|message me|text me|ping me|email me|remind me|call me)\b.*$/i;++/** The sentence itself asks to be told, at its end: "… under €120, then tell me" (not "email me the summary"). */++const WANTS_TELLING = /(?:^|[,;])\s*(?:and\s+|then\s+|so\s+)?(?:please\s+)?(?:tell me|let me know|notify me|message me|text me|ping me)\b[^.]{0,120}$/i;++const VERB_FIX = [[/^checking\b/i, 'check'], [/^watching\b/i, 'watch'], [/^monitoring\b/i, 'monitor'], [/^tracking\b/i, 'track'], [/^following\b/i, 'follow'], [/^working on\b/i, 'work on'], [/^looking at\b/i, 'look at'], [/^keeping an eye on\b/i, 'check']];++++/** The thing the person keeps checking, when the sentence names it. */++function objectOf(lead) {++ const m = String(lead || '').match(/\b(?:check(?:ing)?|watch(?:ing)?|monitor(?:ing)?|track(?:ing)?|follow(?:ing)?|work(?:ing)?\s+on|look(?:ing)?\s+at|keep(?:ing)?\s+an\s+eye\s+on)\s+(.+?)(?:\s+every\b|\s+until\b|\s*$)/i);++ return m ? clean(m[1]) : null;++}++++/** The sentence's "until …" clause, the check it names and the work before it, or null. */++function untilIn(text) {++ const source = clean(text);++ const m = source.match(/\buntil\b\s+(.+)$/i);++ if (!m) return null;++ const lead = clean(source.slice(0, m.index));++ const clause = clean(m[1].replace(TELLING, '')).replace(/[.;]+$/, '').trim();++ if (!clause) return { lead, clause: '', ambiguous: true, reason: 'the sentence says "until" but not what ends it' };++ // "it" in the clause is the thing the sentence names: "check the price of X … until it drops under €120".++ const object = objectOf(lead);++ const pronoun = /^it['’]s\b/i.test(clause) ? (object ? `${object} is${clause.slice(4)}` : null)++ : /^it\b/i.test(clause) ? (object ? `${object}${clause.slice(2)}` : null) : null;++ const test = pronoun && (CHECKABLE.test(pronoun) || /\d/.test(pronoun)) ? pronoun : clause;++ if (!(CHECKABLE.test(test) || /\d/.test(test))) {++ return { lead, clause, ambiguous: true, reason: 'the "until" clause says how it should feel, not what a run can check' };++ }++ return { lead, clause: test, test };++}++++/** The work each check does: the sentence's lead, without "keep", its schedule phrase or the goal. */++function taskFrom(lead, schedule) {++ let t = clean(lead).replace(/^(?:and|then|please)\s+/i, '').replace(/^keep\s+an\s+eye\s+on\s+/i, 'check ').replace(/^keep\s+/i, '');++ for (const [re, to] of VERB_FIX) { if (re.test(t)) { t = t.replace(re, to); break; } }++ if (schedule && schedule.phrase) {++ const i = t.toLowerCase().indexOf(schedule.phrase.toLowerCase());++ if (i >= 0) t = clean(`${t.slice(0, i)} ${t.slice(i + schedule.phrase.length)}`);++ }++ return t.replace(/\s+(?:every|until)\b.*$/i, '').replace(/[,;:.\s]+$/, '').trim();++}++/** A Watch event source from "when …", or null. */function eventIn(text) {const source = clean(text);@@@ -209,6 +259,28 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = nullif (AMBIGUOUS.test(source)) {return { status: 'ask', question: 'How often should this run, exactly? For example: every weekday at 9, or every hour.' };}++ // "keep … until …" is a goal that keeps going (plan HO3), not a one-off routine. The check that++ // ends it is the clause after "until"; a clause no run can see is the one question to ask.++ const until = untilIn(source);++ if (until) {++ const when = scheduleIn(source);++ const cronExpr = when ? when.cron : GOAL_EVERY;++ const zone = knownZone || zoneFromText(source);++ if (!zone) return { status: 'ask', question: `I will check ${when ? when.phrase.replace(/^every\s+/i, 'every ') : 'every 15 minutes'} in which timezone?` };++ const verdict = cron.validate(cronExpr, zone);++ if (!verdict.valid) return { status: 'ask', question: `That check interval cannot run: ${verdict.error}. How often should I check?` };++ if (until.ambiguous) {++ return { status: 'ask', question: 'How will I know it is done? Say the check in one line, for example "the price is under €120" or "the tests pass".' };++ }++ const work = taskFrom(until.lead, when);++ if (!work) return { status: 'ask', question: 'What should I do on each check, before I test whether it is done?' };++ return {++ status: 'ok', kind: 'goal', cron: cronExpr, timezone: zone, task: clean(givenTask || work).slice(0, 8000),++ goal: { text: clean(source.replace(TELLING, '').slice(0, 500)), done_test: until.test },++ words: when ? wordsFor(cronExpr, zone) : wordsFor(GOAL_EVERY, zone),++ tell: WANTS_TELLING.test(source),++ };++ }const schedule = scheduleIn(source);const event = eventIn(source);if (schedule && event) {@@@ -236,13 +308,15 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = nullreturn {status: 'ok', kind: 'schedule', cron: schedule.cron, timezone: zone, task,words: wordsFor(schedule.cron, zone),++ tell: WANTS_TELLING.test(source),};}return {status: 'ok', kind: 'event', task,event: { pattern: event.pattern, filter: event.filter },words: event.words,++ tell: WANTS_TELLING.test(source),};}--module.exports = { parseRequest, scheduleIn, eventIn, zoneFromText, wordsFor, AMBIGUOUS };++module.exports = { parseRequest, scheduleIn, eventIn, untilIn, zoneFromText, wordsFor, AMBIGUOUS, GOAL_EVERY, WANTS_TELLING };@@@ -17,9 +17,11 @@ const cron = require('./cron');const store = require('./store');const watchClient = require('./watch');const parse = require('./parse');++const goalLib = require('./goal');const contacts = require('../agents/contacts');const levels = require('../agents/levels');const personal = require('../agents/personal');++const tasks = require('../tasks/store');const ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;const WATCH_ID = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -28,9 +30,17 @@ const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;const MAX_ACTIVE = 20;const UNDO_MS = 10 * 60_000;const MAX_TASK = 8000;++const KINDS = ['schedule', 'event', 'goal'];++const MAX_GOAL_TEXT = 500;++const MAX_DONE_TEST = 300;++const MAX_GOAL_RUNS = 200;++/** A goal with no run limit given gets this many; with no deadline, this long. */++const GOAL_RUNS = 12;++const GOAL_DAYS = 7;const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++const usd = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;const problem = (status, code, detail) => ({ status, code, detail });function createTriggers({ s, config, engine = null, webhooks = null, stream = null, watch = null, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {@@@ -48,10 +58,22 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuasync function card(row, { token = null, live = false } = {}) {if (!row) return null;const agent = await agentOf(row.owner, row.agent_id).catch(() => null);++ const scheduled = row.kind === 'schedule' || row.kind === 'goal';let next = null;-- if (row.state === 'active' && row.kind === 'schedule' && row.schedule && row.schedule.cron) {++ if (row.state === 'active' && scheduled && row.schedule && row.schedule.cron) {try { const at = cron.next(row.schedule.cron, row.schedule.timezone || row.timezone || 'UTC', nowMs()); if (at) next = iso(at); } catch { /* no next */ }}++ const goal = row.kind === 'goal' && row.goal ? {++ text: String(row.goal.text || ''),++ done_test: String(row.goal.done_test || ''),++ runs_used: Number(row.runs_used) || 0,++ runs_max: row.goal.runs_max == null ? null : Number(row.goal.runs_max),++ spent_usd: usd(row.spent_usd),++ budget_usd: row.goal.budget_usd == null ? null : Number(row.goal.budget_usd),++ deadline_at: row.goal.deadline_at || null,++ not_yet: row.verdict && row.verdict.done === false ? String(row.verdict.reason || '') || null : null,++ verdict: row.verdict && row.verdict.at ? { done: Boolean(row.verdict.done), reason: String(row.verdict.reason || ''), at: row.verdict.at } : null,++ } : null;const out = {id: row.id,owner: row.owner,@@@ -61,15 +83,18 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nukind: row.kind,when: {words: row.words,-- cron: row.kind === 'schedule' && row.schedule ? row.schedule.cron : null,-- timezone: row.kind === 'schedule' ? (row.timezone || (row.schedule && row.schedule.timezone) || null) : null,++ cron: scheduled && row.schedule ? row.schedule.cron : null,++ timezone: scheduled ? (row.timezone || (row.schedule && row.schedule.timezone) || null) : null,event: row.kind === 'event' && row.event ? { pattern: row.event.pattern, filter: row.event.filter || null } : null,},++ goal,++ answer_in_chat: row.answer_in_chat !== false,++ notify: row.notify === true,next_run_at: next,last_run: row.last_task_id && row.last_state && row.last_run_at? { task_id: row.last_task_id, state: row.last_state, at: iso(row.last_run_at) }: null,-- state: row.state === 'paused' ? 'paused' : 'active',++ state: row.state === 'paused' ? 'paused' : row.state === 'done' ? 'done' : 'active',paused_reason: row.paused_reason || null,failures: Number(row.failures) || 0,watch: row.watch_id ? { id: row.watch_id, status: 'active' } : null,@@@ -104,19 +129,19 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nutry {const cardNow = await card(row);const event = { at: new Date(nowMs()).toISOString(), kind: 'output', step: 'trigger', agent: 'openvibe-runtime', text: clip(text, 500), trigger: cardNow };-- const saved = await require('../tasks/store').appendEvent(s, row.turn_task_id, event);++ const saved = await tasks.appendEvent(s, row.turn_task_id, event);if (stream && typeof stream.publish === 'function') stream.publish(row.turn_task_id, saved);} catch (err) {log.warn && log.warn('[Actor] trigger card:', row.id, (err && err.message) || err);}}-- function validate({ owner, agentId, task, kind, cronExpr, timezone, event }) {++ function validate({ owner, agentId, task, kind, cronExpr, timezone, event, goal }) {if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || ''))) return problem(401, 'actor.trigger.sign_in', 'Sign in to make a trigger.');if (!agentId) return problem(422, 'actor.trigger.agent', 'Choose which of your agents should run it.');if (typeof task !== 'string' || !task.trim() || task.length > MAX_TASK) return problem(422, 'actor.trigger.task', 'Say what the agent should do each time, in a sentence.');-- if (!['schedule', 'event'].includes(kind)) return problem(422, 'actor.trigger.kind', 'A trigger is a schedule or a Watch event.');-- if (kind === 'schedule') {++ if (!KINDS.includes(kind)) return problem(422, 'actor.trigger.kind', 'A trigger is a schedule, a Watch event, or a goal.');++ if (kind !== 'event') {if (!timezone || !cron.isZone(timezone)) return problem(422, 'actor.trigger.timezone', `Unknown time zone "${timezone}". Say the city or the IANA name.`);const verdict = cron.validate(cronExpr, timezone);if (!verdict.valid) return problem(422, 'actor.trigger.schedule', verdict.error);@@@ -125,11 +150,40 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuif (!event || !EVENT_PATTERN.test(String(event.pattern || ''))) return problem(422, 'actor.trigger.event', 'An event source is an Events topic pattern such as github.issue.opened.');if (event.filter != null && (typeof event.filter !== 'object' || Array.isArray(event.filter))) return problem(422, 'actor.trigger.event', 'event.filter must be an object of field values.');}++ if (kind === 'goal') {++ const g = goal || {};++ const text = String(g.text == null ? '' : g.text).trim();++ const test = String(g.done_test == null ? '' : g.done_test).trim();++ if (!text) return problem(422, 'actor.trigger.goal', 'Say what the goal is working toward, in a sentence.');++ if (text.length > MAX_GOAL_TEXT) return problem(422, 'actor.trigger.goal', `The goal is at most ${MAX_GOAL_TEXT} characters; say it in a sentence.`);++ if (!test) return problem(422, 'actor.trigger.done_test', 'A goal needs the check that ends it: what a run answers yes or no, such as "the price is under €120".');++ if (test.length > MAX_DONE_TEST) return problem(422, 'actor.trigger.done_test', `The done-test is at most ${MAX_DONE_TEST} characters; say the check in one line.`);++ if (g.runs_max != null) {++ const n = Number(g.runs_max);++ if (!Number.isInteger(n) || n < 1 || n > MAX_GOAL_RUNS) return problem(422, 'actor.trigger.goal', `The run limit is a whole number from 1 to ${MAX_GOAL_RUNS}.`);++ }++ if (g.budget_usd != null && !(Number(g.budget_usd) >= 0)) return problem(422, 'actor.trigger.goal', 'The money budget is a number of dollars, zero or more.');++ if (g.deadline_at != null && !Number.isFinite(Date.parse(String(g.deadline_at)))) return problem(422, 'actor.trigger.goal', 'The deadline is a date, such as 2026-11-14T09:00:00Z.');++ }return null;}-- async function create({ owner, agentId, task, kind, cron: cronExpr = null, timezone = null, event = null, level = null, turnTaskId = null, personToken = null, announce = true }) {-- const bad = validate({ owner, agentId, task, kind, cronExpr, timezone, event });++ /** A goal's own fields: the text and the done-test as said, its limits and its deadline, defaulted. */++ function goalRow(goal, at) {++ const g = goal || {};++ const perDay = Number(config && config.allowance && config.allowance.perDayUsd);++ return {++ text: String(g.text).trim().slice(0, MAX_GOAL_TEXT),++ done_test: String(g.done_test).trim().slice(0, MAX_DONE_TEST),++ runs_max: g.runs_max == null ? GOAL_RUNS : Number(g.runs_max),++ // With no money budget of its own, a goal spends what the person's day allows and no more.++ budget_usd: g.budget_usd == null ? (Number.isFinite(perDay) ? perDay : null) : Number(g.budget_usd),++ deadline_at: g.deadline_at == null ? iso(at + GOAL_DAYS * 86_400_000) : new Date(Date.parse(String(g.deadline_at))).toISOString(),++ };++ }++++ async function create({ owner, agentId, task, kind, cron: cronExpr = null, timezone = null, event = null, goal = null, level = null, turnTaskId = null, personToken = null, announce = true, answerInChat = true, notify = false }) {++ const bad = validate({ owner, agentId, task, kind, cronExpr, timezone, event, goal });if (bad) return { error: bad };const agent = await agentOf(owner, agentId);if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents (GET /api/v1/agents).') };@@@ -138,14 +192,16 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuconst active = await store.countActive(s, owner);if (active >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one, or delete one, before making another.`) };const at = nowMs();++ const scheduled = kind !== 'event';const row = await store.insert(s, {id: s.newId('trg'), owner, agent_id: agent.id, task: String(task).trim(), kind,-- schedule: kind === 'schedule' ? { cron: cronExpr, timezone } : null,++ schedule: scheduled ? { cron: cronExpr, timezone } : null,event: kind === 'event' ? { pattern: event.pattern, filter: event.filter || null } : null,-- timezone: kind === 'schedule' ? timezone : null,-- words: kind === 'schedule' ? parse.wordsFor(cronExpr, timezone) : (event.words || `When ${event.pattern}`),++ goal: kind === 'goal' ? goalRow(goal, at) : null,++ timezone: scheduled ? timezone : null,++ words: scheduled ? parse.wordsFor(cronExpr, timezone) : (event.words || `When ${event.pattern}`),level: levels.LEVELS.includes(level) ? level : 'ask',-- state: 'paused', turn_task_id: turnTaskId, created_at: at,++ state: 'paused', turn_task_id: turnTaskId, answer_in_chat: answerInChat !== false, notify: notify === true, created_at: at,});let made = null;try {@@@ -162,9 +218,9 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuconst created = await client.create(personToken, body);if (!created.ok) throw created;made = created.watch;-- if (kind === 'schedule') {-- const scheduled = await client.setSchedule(personToken, made.id, cronExpr, timezone);-- if (!scheduled.ok) throw scheduled;++ if (scheduled) {++ const withSchedule = await client.setSchedule(personToken, made.id, cronExpr, timezone);++ if (!withSchedule.ok) throw withSchedule;}const linked = await store.update(s, owner, row.id, { watch_id: made.id, updated_at: nowMs() });const resumed = await client.resume(personToken, made.id);@@@ -181,32 +237,108 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu}}++ /**++ * One routine run's answer, posted into the conversation that made the trigger, under its card++ * (plan HO4): the answer with the addresses it read and a link to the run, or — when the run did++ * not finish — the one line that says why. The routine's own switch decides (default on).++ */++ async function postRun(row, task, mapped) {++ if (!row || !row.turn_task_id || row.answer_in_chat === false) return;++ if (!TASK_ID.test(String(row.turn_task_id))) return;++ const result = tasks.parseJson(task.result) || {};++ const at = iso(nowMs());++ const ok = mapped === 'ok';++ const text = ok++ ? clip(result.answer || '', 8000)++ : mapped === 'needs_you'++ ? 'That run needs you: open it and answer, and the routine carries on.'++ : `That run did not finish: ${clip(task.error_detail || task.error_code || 'Actor did not say why', 400)}.`;++ const sources = ok && Array.isArray(result.sources)++ ? result.sources.filter((u) => typeof u === 'string' && u).slice(0, 8).map((u) => u.slice(0, 4000))++ : [];++ try {++ const event = {++ at, kind: 'output', step: 'trigger-run', agent: 'openvibe-runtime',++ trigger_id: row.id,++ run: { task_id: task.id, state: ok ? 'ok' : (mapped === 'needs_you' ? 'needs_you' : 'failed'), at },++ text,++ ...(sources.length ? { sources } : {}),++ };++ const saved = await tasks.appendEvent(s, row.turn_task_id, event);++ if (stream && typeof stream.publish === 'function') stream.publish(row.turn_task_id, saved);++ } catch (err) {++ log.warn && log.warn('[Actor] routine run post:', row.id, (err && err.message) || err);++ }++ }++++ /**++ * A goal ends itself (plan HO3): its done-test passed, or its runs, its money or its deadline is++ * spent. It says so in the thread, and the watch is paused with the person's token when the++ * caller has one; without one, Watch stops on the next firing (Actor answers it with++ * actor.trigger.paused / actor.trigger.done).++ */++ async function endGoal(row, { reason, line = null, token = null } = {}) {++ if (!row || row.state !== 'active') return row;++ const updated = await store.update(s, row.owner, row.id, {++ state: reason === 'done' ? 'done' : 'paused',++ paused_reason: reason,++ ended_at: nowMs(), updated_at: nowMs(),++ });++ if (line) await emit(updated, line);++ if (token && row.watch_id) await client.pause(token, row.watch_id);++ return updated;++ }++++ /** A goal whose deadline has passed is over, even between runs. Read lazily wherever the card is. */++ async function expire(row, { token = null } = {}) {++ if (!row || row.kind !== 'goal' || row.state !== 'active' || !row.goal) return row;++ const deadline = row.goal.deadline_at ? Date.parse(row.goal.deadline_at) : NaN;++ if (!Number.isFinite(deadline) || nowMs() < deadline) return row;++ const line = `Stopping here: ${iso(deadline).slice(0, 10)} passed and the goal is not done.${row.verdict && row.verdict.reason ? ` Last run: ${clip(row.verdict.reason, 200)}.` : ''}`;++ return await endGoal(row, { reason: 'deadline', line, token });++ }++async function get(owner, id, { token = null, live = false } = {}) {if (!ID.test(String(id || ''))) return null;-- const row = await store.get(s, owner, id);-- if (!row) return null;++ const found = await store.get(s, owner, id);++ if (!found) return null;++ const row = await expire(found, { token });return await card(row, { token, live });}async function list(owner) {const rows = await store.list(s, owner);-- return await Promise.all(rows.map((row) => card(row)));++ const live = [];++ for (const row of rows) live.push(await expire(row));++ return await Promise.all(live.map((row) => card(row)));}async function edit(owner, id, patch = {}, { token = null } = {}) {const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };++ // The routine's two switches (HO4) travel through the same PATCH: they are the person's own settings, so++ // they need no Watch subscription change and no person token.++ const switches = {};++ if (patch.answer_in_chat !== undefined) switches.answerInChat = patch.answer_in_chat !== false;++ if (patch.notify !== undefined) switches.notify = patch.notify === true;++ if (Object.keys(switches).length) {++ const onlySwitches = Object.keys(patch).every((k) => k === 'answer_in_chat' || k === 'notify');++ const doneSwitches = await setSwitch(owner, row.id, switches);++ if (doneSwitches.error || onlySwitches) return doneSwitches;++ }if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: changing a trigger uses your own OpenVibe session.') };const next = {agent_id: patch.agent_id !== undefined ? patch.agent_id : row.agent_id,task: patch.task !== undefined ? String(patch.task).trim() : row.task,-- kind: patch.kind !== undefined ? patch.kind : row.kind,++ // A goal stays a goal: what it works toward and the check that ends it are its kind's own fields.++ kind: row.kind === 'goal' ? 'goal' : (patch.kind !== undefined ? patch.kind : row.kind),cron: patch.cron !== undefined ? patch.cron : (row.schedule && row.schedule.cron),timezone: patch.timezone !== undefined ? patch.timezone : (row.timezone || (row.schedule && row.schedule.timezone) || 'UTC'),event: patch.event !== undefined ? patch.event : row.event,++ goal: row.kind === 'goal' && patch.goal && typeof patch.goal === 'object' ? { ...row.goal, ...patch.goal } : row.goal,level: row.level,};-- const bad = validate({ owner, agentId: next.agent_id, task: next.task, kind: next.kind, cronExpr: next.cron, timezone: next.timezone, event: next.event });++ const bad = validate({ owner, agentId: next.agent_id, task: next.task, kind: next.kind, cronExpr: next.cron, timezone: next.timezone, event: next.event, goal: next.goal });if (bad) return { error: bad };const agent = await agentOf(owner, next.agent_id);if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents.') };@@@ -221,22 +353,38 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuconst patched = await client.patch(token, row.watch_id, changes);if (!patched.ok) return { error: problem(patched.status || 502, patched.code || 'actor.trigger.watch_failed', patched.detail || 'OpenVibe.Watch refused the change.') };}-- if (next.kind === 'schedule' && (patch.cron !== undefined || patch.timezone !== undefined || patch.kind !== undefined)) {-- const scheduled = await client.setSchedule(token, row.watch_id, next.cron, next.timezone);-- if (!scheduled.ok) return { error: problem(scheduled.status || 502, scheduled.code || 'actor.trigger.watch_failed', scheduled.detail || 'OpenVibe.Watch refused the schedule.') };++ if (next.kind !== 'event' && (patch.cron !== undefined || patch.timezone !== undefined || patch.kind !== undefined)) {++ const rescheduled = await client.setSchedule(token, row.watch_id, next.cron, next.timezone);++ if (!rescheduled.ok) return { error: problem(rescheduled.status || 502, rescheduled.code || 'actor.trigger.watch_failed', rescheduled.detail || 'OpenVibe.Watch refused the schedule.') };}const updated = await store.update(s, owner, row.id, {task: next.task, agent_id: next.agent_id, kind: next.kind,-- schedule: next.kind === 'schedule' ? { cron: next.cron, timezone: next.timezone } : row.schedule,++ schedule: next.kind !== 'event' ? { cron: next.cron, timezone: next.timezone } : row.schedule,event: next.kind === 'event' ? { pattern: next.event.pattern, filter: next.event.filter || null } : row.event,-- timezone: next.kind === 'schedule' ? next.timezone : null,-- words: next.kind === 'schedule' ? parse.wordsFor(next.cron, next.timezone) : (next.event.words || row.words),++ goal: next.goal,++ timezone: next.kind !== 'event' ? next.timezone : null,++ words: next.kind !== 'event' ? parse.wordsFor(next.cron, next.timezone) : (next.event.words || row.words),updated_at: nowMs(),});await emit(updated, `Trigger edited: ${updated.words}.`);return { trigger: await card(updated) };}++ /**++ * The routine's own two switches (HO4): answer in this chat (default on) and also tell me through++ * OpenVibe's notifications (default off, because the answer in the chat is already the telling).++ */++ async function setSwitch(owner, id, { answerInChat = null, notify = null } = {}) {++ const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;++ if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };++ const patch = { updated_at: nowMs() };++ if (answerInChat != null) patch.answer_in_chat = answerInChat !== false;++ if (notify != null) patch.notify = notify === true;++ if (Object.keys(patch).length === 1) return { error: problem(400, 'actor.trigger.nothing', 'Say answer_in_chat or notify.') };++ const updated = await store.update(s, owner, row.id, patch);++ return { trigger: await card(updated) };++ }++async function pause(owner, id, { token = null } = {}) {const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };@@@ -254,6 +402,7 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nuasync function resume(owner, id, { token = null } = {}) {const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };++ if (row.state === 'done') return { error: problem(409, 'actor.trigger.done', 'This goal reached its check and is finished. Make a new one if you want it to keep going.') };if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: resuming a trigger uses your own OpenVibe session.') };if (await store.countActive(s, owner) >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one before resuming another.`) };if (row.watch_id) {@@@ -293,12 +442,17 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu}async function runNow(owner, id) {-- const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;-- if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };++ const found = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;++ if (!found) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };++ const row = await expire(found);++ if (row.state === 'done') return { error: problem(409, 'actor.trigger.done', 'This goal is done. Make a new one if you want it to keep going.') };++ // A goal that stopped for its budget or its deadline does not run again by accident: Resume (after raising++ // its limit or its deadline) is the person's deliberate move.++ if (row.state === 'paused' && row.kind === 'goal') return { error: problem(409, 'actor.trigger.paused', 'This goal has stopped. Resume it, or make a new one.') };const agent = await agentOf(owner, row.agent_id);if (!agent) return { error: problem(422, 'actor.agent.not_found', 'The agent this trigger runs is gone. Pick another agent first.') };if (!row.watch_id) return { error: problem(409, 'actor.trigger.no_watch', 'This trigger has no subscription. Delete it and make a new one.') };-- const out = await personal.run({ s, config, engine, webhooks }, agent, row.task, { origin: 'routine', level: row.level, triggerId: row.id });++ const out = await personal.run({ s, config, engine, webhooks }, agent, row.task, { origin: 'routine', level: row.level, triggerId: row.id, goal: row.goal });if (out && out.code) return { error: problem(out.status || 422, out.code, out.detail || 'The run could not start.') };await emit(row, `Run now: ${row.task}.`);return { task: out.task, trigger: await card(await store.get(s, owner, row.id)) };@@@ -307,45 +461,80 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu/** Resolve the trigger a Watch delivery is for, by its id or the subscription's id. */async function forDelivery(owner, { triggerId = null, watchId = null } = {}) {const subject = subjectOf(owner);-- if (triggerId && ID.test(String(triggerId))) {-- const row = await store.get(s, subject, triggerId);-- if (row) return row;-- }-- if (watchId && WATCH_ID.test(String(watchId))) return await store.byWatch(s, subject, watchId);-- return null;++ let row = null;++ if (triggerId && ID.test(String(triggerId))) row = await store.get(s, subject, triggerId);++ if (!row && watchId && WATCH_ID.test(String(watchId))) row = await store.byWatch(s, subject, watchId);++ if (!row) return null;++ // A goal whose deadline passed is over before this delivery runs anything.++ return await expire(row);}/*** A task moved (the engine calls this after every state change). A failed run grows the streak; five in a* row pause the trigger and say so on the card. A success clears it. A run waiting for the person is shown* as needs_you and is not a failure.++ *++ * Plan HO4: a run that finished posts its answer into the conversation that made the trigger, under its++ * card. Plan HO3: a goal counts the run, records its verdict, and ends itself when the done-test passed++ * or its runs, money or deadline is spent.*/async function taskState(task) {if (!task || !task.id) return null;const run = await store.runForTask(s, task.id);if (!run) return null;-- const row = await s.db.maybe('SELECT * FROM actor_triggers WHERE id = $1', [run.trigger_id]);-- if (!row || row.state === 'deleted') return null;++ const found = await s.db.maybe('SELECT * FROM actor_triggers WHERE id = $1', [run.trigger_id]);++ if (!found || found.state === 'deleted') return null;++ const row = store.wire(found);const mapped = task.state === 'succeeded' ? 'ok': task.state === 'failed' ? 'failed': task.state === 'needs_you' ? 'needs_you': task.state === 'cancelled' ? 'failed' : 'running';if (run.state === mapped) return row;-- await store.addRun(s, run.trigger_id, task.id, mapped, nowMs());++ const cost = Number(task.cost_usd) || 0;++ await store.addRun(s, run.trigger_id, task.id, mapped, nowMs(), cost);if (mapped === 'running') return row;-- let failures = Number(row.failures) || 0;++ let failures = Number(found.failures) || 0;if (mapped === 'failed' && task.state !== 'cancelled') failures += 1;if (mapped === 'ok') failures = 0;const patch = {last_task_id: task.id, last_state: mapped, last_run_at: nowMs(), failures, updated_at: nowMs(),...(failures >= 5 ? { state: 'paused', paused_reason: 'failed' } : {}),};++++ // The answer in the thread comes first, so a goal's closing line reads after the run it ends.++ await postRun(row, task, mapped);++++ let ended = null;++ if (row.kind === 'goal' && (mapped === 'ok' || mapped === 'failed')) {++ const result = tasks.parseJson(task.result) || {};++ const verdict = mapped === 'ok' ? goalLib.verdictOf(result.answer) : { done: null, reason: '' };++ patch.runs_used = (Number(row.runs_used) || 0) + 1;++ patch.spent_usd = usd((Number(row.spent_usd) || 0) + cost);++ if (verdict.done != null) patch.verdict = { done: verdict.done, reason: verdict.reason, at: iso(nowMs()) };++ if (row.state === 'active') {++ ended = goalLib.endingOf({ goal: row.goal, verdict, runsUsed: patch.runs_used, spentUsd: patch.spent_usd, at: nowMs() });++ if (ended) Object.assign(patch, { state: ended.reason === 'done' ? 'done' : 'paused', paused_reason: ended.reason, ended_at: nowMs() });++ }++ }const updated = await store.update(s, row.owner, row.id, patch);if (failures >= 5) await emit(updated, `Paused after 5 failed runs in a row: ${row.task}.`);++ if (ended) await emit(updated, ended.line);return updated;}-- return { create, get, list, edit, pause, resume, undo, remove, runNow, forDelivery, taskState, card, MAX_ACTIVE, UNDO_MS };++ /**++ * Whether the engine should tell the person itself (its one-per-task-per-state notification), for a++ * routine run. null when the task is not a routine run: the engine decides as it always has.++ */++ async function tellsPerson(task) {++ if (!task || !task.id) return null;++ const run = await store.runForTask(s, task.id);++ if (!run) return null;++ const found = await s.db.maybe('SELECT notify FROM actor_triggers WHERE id = $1', [run.trigger_id]);++ return found ? found.notify === true : null;++ }++++ return { create, get, list, edit, pause, resume, undo, remove, runNow, forDelivery, taskState, tellsPerson, setSwitch, card, expire, endGoal, MAX_ACTIVE, UNDO_MS };}module.exports = { createTriggers, MAX_ACTIVE, UNDO_MS };@@@ -13,6 +13,12 @@ function wire(row) {kind: row.kind,schedule: parse(row.schedule),event: parse(row.event),++ goal: parse(row.goal),++ verdict: parse(row.verdict),++ runs_used: Number(row.runs_used) || 0,++ spent_usd: Number(row.spent_usd) || 0,++ answer_in_chat: row.answer_in_chat !== false,++ notify: row.notify === true,timezone: row.timezone || null,words: row.words,level: row.level || 'ask',@@@ -24,6 +30,7 @@ function wire(row) {last_task_id: row.last_task_id || null,last_state: row.last_state || null,last_run_at: row.last_run_at == null ? null : Number(row.last_run_at),++ ended_at: row.ended_at == null ? null : Number(row.ended_at),created_at: Number(row.created_at),updated_at: Number(row.updated_at),};@@@ -41,20 +48,22 @@ const countForAgent = async (s, owner, agentId) => Number(await s.db.value('SELEasync function insert(s, rec) {await s.db.query(`INSERT INTO actor_triggers-- (id, owner, agent_id, task, kind, schedule, event, timezone, words, level, state, paused_reason, failures, watch_id, turn_task_id, created_at, updated_at)-- VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9,$10,$11,$12,$13,$14,$15,$16,$16)`,++ (id, owner, agent_id, task, kind, schedule, event, goal, timezone, words, level, state, paused_reason, failures, watch_id, turn_task_id, answer_in_chat, notify, created_at, updated_at)++ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)`,[rec.id, rec.owner, rec.agent_id, rec.task, rec.kind, rec.schedule ? JSON.stringify(rec.schedule) : null,-- rec.event ? JSON.stringify(rec.event) : null, rec.timezone || null, rec.words, rec.level || 'ask',-- rec.state || 'paused', rec.paused_reason || null, rec.failures || 0, rec.watch_id || null, rec.turn_task_id || null, rec.created_at]);++ rec.event ? JSON.stringify(rec.event) : null, rec.goal ? JSON.stringify(rec.goal) : null,++ rec.timezone || null, rec.words, rec.level || 'ask',++ rec.state || 'paused', rec.paused_reason || null, rec.failures || 0, rec.watch_id || null, rec.turn_task_id || null,++ rec.answer_in_chat !== false, rec.notify === true, rec.created_at]);return await get(s, rec.owner, rec.id);}async function update(s, owner, id, patch) {const cols = [];const args = [id, owner];-- const json = new Set(['schedule', 'event']);++ const json = new Set(['schedule', 'event', 'goal', 'verdict']);for (const [k, v] of Object.entries(patch)) {-- if (!['agent_id', 'task', 'schedule', 'event', 'timezone', 'words', 'level', 'state', 'paused_reason', 'failures', 'watch_id', 'last_task_id', 'last_state', 'last_run_at', 'updated_at', 'deleted_at'].includes(k)) continue;++ if (!['agent_id', 'task', 'schedule', 'event', 'goal', 'verdict', 'runs_used', 'spent_usd', 'answer_in_chat', 'notify', 'timezone', 'words', 'level', 'state', 'paused_reason', 'failures', 'watch_id', 'last_task_id', 'last_state', 'last_run_at', 'ended_at', 'updated_at', 'deleted_at'].includes(k)) continue;args.push(json.has(k) && v != null ? JSON.stringify(v) : v);cols.push(`${k} = $${args.length}${json.has(k) ? '::jsonb' : ''}`);}@@@ -63,10 +72,10 @@ async function update(s, owner, id, patch) {return await get(s, owner, id);}--async function addRun(s, triggerId, taskId, state, at) {-- await s.db.query(`INSERT INTO actor_trigger_runs (trigger_id, task_id, state, created_at, updated_at)-- VALUES ($1,$2,$3,$4,$4) ON CONFLICT (task_id) DO UPDATE SET state = EXCLUDED.state, updated_at = EXCLUDED.updated_at`,-- [triggerId, taskId, state, at]);++async function addRun(s, triggerId, taskId, state, at, costUsd = null) {++ await s.db.query(`INSERT INTO actor_trigger_runs (trigger_id, task_id, state, cost_usd, created_at, updated_at)++ VALUES ($1,$2,$3,$4,$5,$5) ON CONFLICT (task_id) DO UPDATE SET state = EXCLUDED.state, cost_usd = COALESCE(EXCLUDED.cost_usd, actor_trigger_runs.cost_usd), updated_at = EXCLUDED.updated_at`,++ [triggerId, taskId, state, costUsd, at]);}const runForTask = (s, taskId) => s.db.maybe('SELECT * FROM actor_trigger_runs WHERE task_id = $1', [taskId]);const runs = (s, triggerId, limit = 20) => s.db.many('SELECT * FROM actor_trigger_runs WHERE trigger_id = $1 ORDER BY created_at DESC LIMIT $2', [triggerId, limit]);nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++/**++ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops under €120".++ * The sentence parses into a goal (or asks the one question when the check is not something a run can see), the++ * goal's runs answer its done-test, and it ends itself — saying so in the conversation — when the test passes,++ * when its runs or money are spent, and at its deadline. The card shows "Run 4 of at most 12 · not yet: €134".++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const contacts = require('../server/agents/contacts');++const { parseRequest } = require('../server/triggers/parse');++const { verdictOf, endingOf } = require('../server/triggers/goal');++const { createRegistry } = require('../server/runtime/tools');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */++async function watchStub() {++ const calls = [];++ const server = http.createServer(async (req, res) => {++ const chunks = [];++ for await (const c of req) chunks.push(c);++ const text = Buffer.concat(chunks).toString('utf8');++ let body = null;++ try { body = text ? JSON.parse(text) : null; } catch { body = text; }++ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });++ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };++ if (req.method === 'POST' && req.url === '/api/v1/watches') {++ const id = ids.newId('watch');++ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });++ }++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });++ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });++ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });++ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });++ return send(404, { code: 'watch.not_found' });++ });++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const watch = await watchStub();++ let answer = 'The price is €134.\n\nVERDICT: no — €134 on the maker\'s store';++ const loop = { close: async () => {}, run: async () => ({ text: answer, sources: [], evidence: [], model: 'fake' }) };++ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop });++ try {++ const ada = t.network.addUser('ada');++ const owner = `user:${ada.subject}`;++ const s = t.ctx.s;++ const vibe = await contacts.ensureVibe(s, t.config, owner);++ const as = (method, p, o = {}) => t.get(p, { as: ada, method, headers: SAME, ...o });++ const json = (method, p, body) => as(method, p, { json: body });++ const turn = async (said) => {++ const made = await json('POST', '/api/v1/tasks', { task: said });++ const id = (made.json().task && made.json().task.id) || made.json().id;++ await t.waitFor(id);++ return id;++ };++ const goal = async (extra = {}) => {++ const made = await json('POST', '/api/v1/triggers', {++ agent_id: vibe.id, task: 'check the price of the Framework 13', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',++ goal: { text: 'the price of the Framework 13 drops under €120', done_test: 'the price is under €120', runs_max: 12 }, ...extra,++ });++ assert.strictEqual(made.status, 201, made.text);++ return made.json().trigger;++ };++ const runIt = async (id) => {++ const res = await as('POST', `/api/v1/triggers/${id}/run`);++ if (res.status === 409) return { refused: res.json() };++ assert.strictEqual(res.status, 201, res.text);++ return await t.waitFor(res.json().task.id);++ };++ const fetchCard = async (id) => (await as('GET', `/api/v1/triggers/${id}`)).json().trigger;++ const linesOf = async (turnId) => (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [turnId]))++ .map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body));++++ await check('"until …" sentences become a goal, and a check no run can see is one question', async () => {++ const price = parseRequest('keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me', { timezone: 'Europe/Berlin' });++ assert.strictEqual(price.status, 'ok');++ assert.strictEqual(price.kind, 'goal');++ assert.strictEqual(price.cron, '*/30 * * * *');++ assert.strictEqual(price.task, 'check the price of the Framework 13');++ assert.strictEqual(price.goal.done_test, 'the price of the Framework 13 drops under €120');++ assert.strictEqual(price.tell, true);++++ const tests = parseRequest('keep working on this until the tests pass', { timezone: 'Europe/Berlin' });++ assert.strictEqual(tests.status, 'ok');++ assert.strictEqual(tests.kind, 'goal');++ assert.strictEqual(tests.cron, '*/15 * * * *', 'no interval given: every fifteen minutes, Watch\'s floor');++ assert.strictEqual(tests.task, 'work on this');++ assert.strictEqual(tests.goal.done_test, 'the tests pass');++ assert.strictEqual(tests.tell, false);++++ const vague = parseRequest('keep checking the price every hour until I am happy', { timezone: 'Europe/Berlin' });++ assert.strictEqual(vague.status, 'ask');++ assert.match(vague.question, /how will i know it is done/i);++++ const noZone = parseRequest('keep checking the price until it drops under €120', {});++ assert.strictEqual(noZone.status, 'ask');++ assert.match(noZone.question, /timezone/i);++++ // A plain routine is still a routine.++ assert.strictEqual(parseRequest('every weekday at 9, summarise my inbox', { timezone: 'Europe/Berlin' }).kind, 'schedule');++ });++++ await check('the done-test is read out of the answer, and the goal\'s ends are its own limits', async () => {++ assert.deepStrictEqual(verdictOf('€134 today.\n\nVERDICT: no — €134 on the maker\'s store'), { done: false, reason: '€134 on the maker\'s store', answer: '€134 today.' });++ assert.deepStrictEqual(verdictOf('All good.\nVERDICT: yes — €112 shipped').done, true);++ assert.strictEqual(verdictOf('I could not read the page.').done, null, 'a run that does not say is not a pass');++ const g = { done_test: 'the price is under €120', runs_max: 12, budget_usd: 1.5, deadline_at: null };++ assert.strictEqual(endingOf({ goal: g, verdict: { done: true, reason: '€112' }, runsUsed: 4, spentUsd: 0.02 }).reason, 'done');++ assert.strictEqual(endingOf({ goal: g, verdict: { done: false }, runsUsed: 4, spentUsd: 0.02 }), null);++ assert.strictEqual(endingOf({ goal: { ...g, runs_max: 4 }, verdict: { done: false }, runsUsed: 4, spentUsd: 0.02 }).reason, 'budget');++ assert.strictEqual(endingOf({ goal: { ...g, runs_max: null, budget_usd: 0.5 }, verdict: { done: null }, runsUsed: 2, spentUsd: 0.6 }).reason, 'budget');++ assert.strictEqual(endingOf({ goal: { ...g, budget_usd: null, deadline_at: '2020-01-01T00:00:00.000Z' }, verdict: { done: false }, runsUsed: 2, spentUsd: 0.1, at: Date.now() }).reason, 'deadline');++ });++++ await check('the tool makes a goal from the sentence, with its limits and the ask to be told', async () => {++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });++ const events = [];++ const ctx = {++ config: t.config, requester: owner, store: s, triggers: t.ctx.triggers,++ personToken: 'a-person-token', timezone: 'Europe/Berlin', agentId: vibe.id, taskId: null, level: 'ask',++ emit: async (e) => events.push(e),++ };++ const out = await registry.run('schedule_trigger', { request: 'keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me' }, ctx);++ assert.ok(!out.isError, out.text);++ const made = events.find((e) => e.step === 'trigger').trigger;++ assert.strictEqual(made.kind, 'goal');++ assert.strictEqual(made.when.cron, '*/30 * * * *');++ assert.strictEqual(made.goal.runs_used, 0);++ assert.strictEqual(made.goal.runs_max, 12);++ assert.ok(made.goal.budget_usd > 0, 'a money budget from the person\'s allowance');++ assert.ok(made.goal.deadline_at, 'a deadline');++ assert.strictEqual(made.notify, true, '"then tell me" turns the telling on');++ assert.match(out.text, /It ends when the price of the Framework 13 drops under €120/);++++ const asked = await registry.run('schedule_trigger', { request: 'keep working on this until it is good' }, ctx);++ assert.match(asked.text, /how will i know it is done/i);++ });++++ await check('a goal ends itself when its done-test passes, and says so in the thread', async () => {++ const turnId = await turn('keep checking the price of the Framework 13 until it drops under €120');++ const made = await json('POST', '/api/v1/triggers', {++ agent_id: vibe.id, task: 'check the price of the Framework 13', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',++ goal: { text: 'the price drops under €120', done_test: 'the price is under €120' }, turn_task_id: turnId,++ });++ assert.strictEqual(made.status, 201, made.text);++ const id = made.json().trigger.id;++++ answer = 'The price is €134.\n\nVERDICT: no — €134';++ const first = await runIt(id);++ assert.strictEqual(first.state, 'succeeded');++ // The run was asked the goal and its done-test, so its answer carries a verdict Actor can read.++ assert.ok(String(first.task).includes('This is one run of a goal the person set'), 'the goal is in the run');++ assert.ok(String(first.task).includes('The goal is done only when this is true: "the price is under €120"'));++ assert.ok(String(first.task).includes('VERDICT: yes'));++ let card = await fetchCard(id);++ assert.deepStrictEqual([card.state, card.goal.runs_used, card.goal.not_yet], ['active', 1, '€134']);++++ answer = 'It dropped: €112 shipped.\n\nVERDICT: yes — €112 shipped';++ const second = await runIt(id);++ assert.strictEqual(second.state, 'succeeded');++ card = await fetchCard(id);++ assert.deepStrictEqual([card.state, card.paused_reason, card.goal.runs_used], ['done', 'done', 2]);++ assert.strictEqual(card.goal.verdict.done, true);++++ const lines = await linesOf(turnId);++ assert.ok(lines.some((e) => e.step === 'trigger-run' && /It dropped: €112 shipped/.test(e.text)), 'the run\'s answer is in the thread');++ assert.ok(lines.some((e) => e.step === 'trigger' && /Goal reached: the price is under €120 \(€112 shipped\)/.test(e.text)), 'and so is the goal saying it is done');++ const page = await as('GET', `/chat/turn/${turnId}`);++ assert.match(page.text, /class="ct-trig-state">Done</);++ assert.match(page.text, /The goal is reached: the price is under €120\./);++++ // A finished goal does not run again, and cannot be resumed.++ assert.strictEqual((await runIt(id)).refused.error !== undefined, true);++ assert.strictEqual((await as('POST', `/api/v1/triggers/${id}/resume`)).status, 409);++ });++++ await check('a goal ends itself when its runs are spent, and says so', async () => {++ const turnId = await turn('keep checking the price until it drops under €120');++ const made = await json('POST', '/api/v1/triggers', {++ agent_id: vibe.id, task: 'check the price', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',++ goal: { text: 'the price drops under €120', done_test: 'the price is under €120', runs_max: 2 }, turn_task_id: turnId,++ });++ const id = made.json().trigger.id;++ answer = 'Still €134.\n\nVERDICT: no — €134';++ await runIt(id);++ let card = await fetchCard(id);++ assert.deepStrictEqual([card.state, card.goal.runs_used], ['active', 1]);++ const page = await as('GET', `/chat/turn/${turnId}`);++ assert.match(page.text, /Run 1 of at most 2/);++ assert.match(page.text, /not yet: €134/);++++ await runIt(id);++ card = await fetchCard(id);++ assert.deepStrictEqual([card.state, card.paused_reason, card.goal.runs_used], ['paused', 'budget', 2]);++ const lines = await linesOf(turnId);++ assert.ok(lines.some((e) => e.step === 'trigger' && /Stopping here: the goal has used its 2 runs and is not done/.test(e.text)));++ assert.strictEqual((await runIt(id)).refused.code, 'actor.trigger.paused');++ });++++ await check('a goal ends itself at its deadline, even between runs', async () => {++ const turnId = await turn('keep checking the price until it drops under €120');++ const made = await json('POST', '/api/v1/triggers', {++ agent_id: vibe.id, task: 'check the price', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',++ goal: { text: 'the price drops under €120', done_test: 'the price is under €120', deadline_at: '2020-01-01T00:00:00.000Z' }, turn_task_id: turnId,++ });++ const id = made.json().trigger.id;++ // Nothing ran: the deadline passed, and reading the card is enough to end it.++ const card = await fetchCard(id);++ assert.deepStrictEqual([card.state, card.paused_reason], ['paused', 'deadline']);++ const lines = await linesOf(turnId);++ assert.ok(lines.some((e) => e.step === 'trigger' && /Stopping here: 2020-01-01 passed and the goal is not done/.test(e.text)));++ assert.strictEqual((await runIt(id)).refused.code, 'actor.trigger.paused');++ });++++ await check('one person\'s goal is another person\'s 404', async () => {++ const made = await goal();++ const bo = t.network.addUser('bo');++ assert.strictEqual((await t.get(`/api/v1/triggers/${made.id}`, { as: bo })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/triggers/${made.id}/run`, { as: bo, method: 'POST', headers: SAME })).status, 404);++ });++ } finally {++ await t.close();++ await watch.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++/**++ * A routine answers in its own thread (plan HO4): when a trigger's run finishes, its answer is posted into the++ * conversation that made the trigger, under the routine's card, with the addresses it read and a link to the++ * run. Switched off per routine, it posts nothing; a run that did not finish posts one line that says why. Only++ * the person whose conversation it is sees the posts, and the person is told through notifications only when++ * the routine asked for it.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const contacts = require('../server/agents/contacts');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */++async function watchStub() {++ const calls = [];++ const server = http.createServer(async (req, res) => {++ const chunks = [];++ for await (const c of req) chunks.push(c);++ const text = Buffer.concat(chunks).toString('utf8');++ let body = null;++ try { body = text ? JSON.parse(text) : null; } catch { body = text; }++ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });++ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };++ if (req.method === 'POST' && req.url === '/api/v1/watches') {++ const id = ids.newId('watch');++ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });++ }++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });++ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });++ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });++ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });++ return send(404, { code: 'watch.not_found' });++ });++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const watch = await watchStub();++ // The run's own answer, which each check sets: the loop is the only model Actor talks to here.++ let answer = 'Today your inbox has three things worth doing.';++ let sources = ['https://mail.example.com/inbox'];++ let fail = null;++ const told = [];++ const loop = {++ close: async () => {},++ run: async () => {++ if (fail) throw fail;++ return { text: answer, sources, evidence: [], model: 'fake' };++ },++ };++ const notifier = { enabled: true, needsYou: async (n) => { told.push(n); return { sent: true }; }, taskEnded: async (n) => { told.push(n); return { sent: true }; } };++ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop, notifier });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const s = t.ctx.s;++ const vibe = await contacts.ensureVibe(s, t.config, owner);++ const as = (u, method, p, o = {}) => t.get(p, { as: u, method, headers: SAME, ...o });++ const json = (u, method, p, body) => as(u, method, p, { json: body });++ const runsOf = async (turnId) => (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [turnId]))++ .map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body))++ .filter((e) => e.step === 'trigger-run');++++ // A turn that made a routine, as the chat does (the tool passes its own task id).++ const turnId = async (said = 'every weekday at 9, summarise my inbox') => {++ const made = await json(ada, 'POST', '/api/v1/tasks', { task: said });++ assert.ok(made.status === 201 || made.status === 202, made.text);++ const id = (made.json().task && made.json().task.id) || made.json().id;++ await t.waitFor(id);++ return id;++ };++ const routine = async (turn, extra = {}) => {++ const made = await json(ada, 'POST', '/api/v1/triggers', {++ agent_id: vibe.id, task: 'summarise my inbox', kind: 'schedule', cron: '0 9 * * 1-5', timezone: 'Europe/Berlin', turn_task_id: turn, ...extra,++ });++ assert.strictEqual(made.status, 201, made.text);++ return made.json().trigger;++ };++ const runIt = async (id) => {++ const res = await as(ada, 'POST', `/api/v1/triggers/${id}/run`);++ assert.strictEqual(res.status, 201, res.text);++ return await t.waitFor(res.json().task.id);++ };++++ await check('a routine\'s run posts its answer under its card in the chat that made it', async () => {++ const turn = await turnId();++ const card = await routine(turn);++ const task = await runIt(card.id);++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));++ const posts = await runsOf(turn);++ assert.strictEqual(posts.length, 1, 'one post for one run');++ assert.strictEqual(posts[0].trigger_id, card.id);++ assert.strictEqual(posts[0].run.task_id, task.id);++ assert.strictEqual(posts[0].run.state, 'ok');++ assert.strictEqual(posts[0].text, answer);++ assert.deepStrictEqual(posts[0].sources, sources);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes(`data-ct-trigger="${card.id}"`), 'the card is still there');++ assert.ok(page.text.includes('Today your inbox has three things worth doing.'), 'the answer is in the chat');++ assert.ok(page.text.includes('https://mail.example.com/inbox'), 'with the address it read');++ assert.ok(page.text.includes(`/tasks/${task.id}`), 'and a link to the run');++ assert.match(page.text, /class="ct-trig-run/);++ });++++ await check('switched off per routine, a run posts nothing in the chat', async () => {++ const turn = await turnId();++ const card = await routine(turn);++ // The card's own switch, as its form posts it.++ const off = await as(ada, 'POST', `/chat/${turn}/trigger/${card.id}`, { form: { do: 'answer-off' } });++ assert.strictEqual(off.status, 303, off.text);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.match(page.text, /Answer in this chat: off/);++ const before = (await runsOf(turn)).length;++ answer = 'This one stays out of the chat.';++ await runIt(card.id);++ assert.strictEqual((await runsOf(turn)).length, before, 'nothing was posted');++ const after = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.ok(!after.text.includes('This one stays out of the chat.'));++ // And back on: the API's switch, the same field the card uses.++ const on = await json(ada, 'PATCH', `/api/v1/triggers/${card.id}`, { answer_in_chat: true });++ assert.strictEqual(on.status, 200, on.text);++ assert.strictEqual(on.json().trigger.answer_in_chat, true);++ answer = 'Back in the chat.';++ await runIt(card.id);++ assert.strictEqual((await runsOf(turn)).length, before + 1);++ });++++ await check('a run that did not finish posts one line that says why', async () => {++ const turn = await turnId();++ const card = await routine(turn);++ fail = Object.assign(new Error('the provider fell over'), { code: 'actor.provider.error', detail: 'OpenVibe.AI did not answer in time.' });++ const task = await runIt(card.id);++ fail = null;++ assert.strictEqual(task.state, 'failed');++ // The line is posted as the failed run settles, a beat after its state: wait for it (CI is slower).++ let posts = [];++ for (const end = Date.now() + 5000; Date.now() < end; await new Promise((r) => setTimeout(r, 50))) {++ posts = await runsOf(turn);++ if (posts.length) break;++ }++ assert.strictEqual(posts.length, 1);++ assert.strictEqual(posts[0].run.state, 'failed');++ assert.match(posts[0].text, /^That run did not finish: .*fell over/);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.ok(page.text.includes('That run did not finish'), 'the line is in the chat');++ assert.match(page.text, /ct-trig-run is-bad/);++ });++++ await check('another person never sees a routine\'s posts', async () => {++ const turn = await turnId();++ const card = await routine(turn);++ answer = 'Only Ada may read this.';++ const task = await runIt(card.id);++ assert.strictEqual(task.state, 'succeeded');++ const theirs = await as(bo, 'GET', `/chat/turn/${turn}`);++ assert.ok(!theirs.text.includes('Only Ada may read this.'), 'a stranger is not shown it');++ assert.ok(!theirs.text.includes(`data-ct-trigger="${card.id}"`));++ });++++ await check('the person is told through notifications only when the routine asked', async () => {++ const turn = await turnId();++ const card = await routine(turn);++ // The turn itself is a normal task and tells the person as any task does; count from here.++ told.length = 0;++ answer = 'Quiet run.';++ await runIt(card.id);++ assert.strictEqual(told.length, 0, 'the answer in the chat is the telling');++ const on = await json(ada, 'PATCH', `/api/v1/triggers/${card.id}`, { notify: true });++ assert.strictEqual(on.status, 200, on.text);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.match(page.text, /Tell me too: on/);++ answer = 'Loud run.';++ await runIt(card.id);++ assert.strictEqual(told.length, 1, 'one notification for one run');++ assert.strictEqual(told[0].state, 'succeeded');++ assert.ok(String(told[0].url).includes('/tasks/'));++ });++++ await check('a routine made in a turn with no card has nowhere to post, and says nothing', async () => {++ // The API allows a trigger with no turn (a card made elsewhere): a run must not crash on it.++ const made = await json(ada, 'POST', '/api/v1/triggers', { agent_id: vibe.id, task: 'no thread', kind: 'schedule', cron: '0 9 * * *', timezone: 'UTC' });++ assert.strictEqual(made.status, 201, made.text);++ const task = await runIt(made.json().trigger.id);++ assert.strictEqual(task.state, 'succeeded');++ });++ } finally {++ await t.close();++ await watch.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+-- phase: expand
+-- HO3 (a goal that keeps going) and HO4 (the routine answers in its thread).
+--
+-- A goal is a trigger of kind goal: it keeps working toward GOAL->>'text' on its schedule, and each
+-- run answers its done-test GOAL->>'done_test'. GOAL holds what the person set (the text, the
+-- done-test, the run limit, the money budget and the deadline); the progress columns hold how far it
+-- has got. A goal ends itself (state 'done' or paused with budget/deadline) and says so in the thread,
+-- so the state check has to admit 'done'.
+--
+-- ANSWER_IN_CHAT is the routine's own switch (default on): each run's answer is posted into the
+-- conversation that made the trigger, under its card. NOTIFY is off by default: the answer in the
+-- chat is the telling, unless the person also asked to be told through OpenVibe's notifications.
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS goal jsonb;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS runs_used integer NOT NULL DEFAULT 0;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS spent_usd double precision NOT NULL DEFAULT 0;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS verdict jsonb;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS answer_in_chat boolean NOT NULL DEFAULT true;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT false;
+ALTER TABLE actor_triggers ADD COLUMN IF NOT EXISTS ended_at bigint;
+
+-- 0037 wrote the state check inline, so PostgreSQL named it actor_triggers_state_check. A goal that
+-- reached its done-test is 'done', not paused: it is over, and resuming it would make no sense.
+ALTER TABLE actor_triggers DROP CONSTRAINT IF EXISTS actor_triggers_state_check;
+ALTER TABLE actor_triggers ADD CONSTRAINT actor_triggers_state_check CHECK (state IN ('active','paused','done','deleted'));
+
+-- The third kind: a goal that keeps going until its own check passes.
+ALTER TABLE actor_triggers DROP CONSTRAINT IF EXISTS actor_triggers_kind_check;
+ALTER TABLE actor_triggers ADD CONSTRAINT actor_triggers_kind_check CHECK (kind IN ('schedule','event','goal'));
+
+-- A goal's own kind, for the queries that only look at live goals.
+CREATE INDEX IF NOT EXISTS actor_triggers_goal ON actor_triggers (owner) WHERE kind = 'goal' AND deleted_at IS NULL;
+
+-- What a run cost, so a goal's money budget is the sum of its runs and not a guess.
+ALTER TABLE actor_trigger_runs ADD COLUMN IF NOT EXISTS cost_usd double precision;
@@ -71,6 +71,23 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -71,6 +71,23 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.ct-trig-del[open] > summary { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger-text, #f87171) 55%, var(--ovt-line)); }
.ct-trig-del[open] > summary { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger-text, #f87171) 55%, var(--ovt-line)); }
.ct-trig-del > p { flex-basis: 100%; margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: 13.5px; }
.ct-trig-del > p { flex-basis: 100%; margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: 13.5px; }
.ovt-card-btn.is-danger { background: var(--danger-text, #f87171); color: #1a0505; }
.ovt-card-btn.is-danger { background: var(--danger-text, #f87171); color: #1a0505; }
+/* A goal (plan HO3): "Run 4 of at most 12 · not yet: €134", and the goal in the person's own words. */
+.ct-trig.is-done .ct-trig-state { background: color-mix(in srgb, var(--ovt-accent, #f97316) 18%, transparent); color: var(--ovt-accent, #f97316); }
+.ct-trig-goal { font-variant-numeric: tabular-nums; }
+.ct-trig-goal-text { color: var(--text-secondary, #96a7c2); font-size: 13.5px; font-style: italic; overflow-wrap: anywhere; }
+/* The routine's two switches (plan HO4): answer in this chat, and also tell me. */
+.ct-trig-switches { margin-top: 2px; }
+.ct-trig-switches .ovt-card-btn.is-on { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, #f97316) 55%, transparent); }
+/* What a routine's run said, posted into its chat under the card (plan HO4). */
+.ct-trig-runs { display: grid; gap: 8px; margin-top: 8px; }
+.ct-trig-run { border-left: 2px solid var(--line, rgba(255,255,255,.12)); padding: 2px 0 2px 12px; display: grid; gap: 4px; }
+.ct-trig-run.is-bad { border-left-color: color-mix(in srgb, var(--warning-text, #fbbf24) 60%, transparent); }
+.ct-trig-run p { margin: 0; }
+.ct-trig-run-h { color: var(--text-secondary, #96a7c2); font-size: 13px; }
+.ct-trig-run-h a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
+.ct-trig-run-line { color: var(--warning-text, #fbbf24); font-size: 14px; }
+.ct-trig-sources { color: var(--text-secondary, #96a7c2); font-size: 12.5px; overflow-wrap: anywhere; }
+.ct-trig-sources a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 480px) { .ct-trig .ovt-card-btn { min-height: 44px; } }
@media (max-width: 480px) { .ct-trig .ovt-card-btn { min-height: 44px; } }
/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
@@ -234,15 +234,18 @@ function stepFor(name, input) {
@@ -234,15 +234,18 @@ function stepFor(name, input) {
// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──
// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──
case 'schedule_trigger': {
case 'schedule_trigger': {
const what = text(a.task || a.request, 160);
const what = text(a.task || a.request, 160);
+ const goal = a.goal && typeof a.goal === 'object' && a.goal.done_test ? text(a.goal.done_test, 200) : null;
const when = a.cron ? `${text(a.cron, 40)}${a.timezone ? `, ${text(a.timezone, 40)}` : ''}`
const when = a.cron ? `${text(a.cron, 40)}${a.timezone ? `, ${text(a.timezone, 40)}` : ''}`
: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');
: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');
return {
return {
act: 'act',
act: 'act',
kind: 'routine',
kind: 'routine',
- summary: `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,
+ summary: goal
+ ? `Keep working${when ? ` (${when})` : ''} until ${quoted(goal, 200)}, then stop: ${quoted(what, 160) || 'the goal you asked for'}`
+ : `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,
// What repeats and when, both: allowing a schedule must not allow whatever task rides on it.
// What repeats and when, both: allowing a schedule must not allow whatever task rides on it.
target: routineTarget(a),
target: routineTarget(a),
- payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id }),
+ payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id, goal: a.goal, notify: a.notify }),
};
};
}
}
@@ -194,12 +194,15 @@ function personText(task) {
@@ -194,12 +194,15 @@ function personText(task) {
return end < 0 ? t : t.slice(0, end);
return end < 0 ? t : t.slice(0, end);
}
}
-async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null } = {}) {
+async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
+ // A goal's run (plan HO3) does a piece of the work and then answers the goal's done-test, in one line
+ // the trigger records: the agent judges, with what the run actually saw.
+ const runInput = goal && goal.done_test ? require('../triggers/goal').promptFor(goal, input) : input;
// The person's own active memory and this agent's active notes; a suggestion is not put in a run.
// The person's own active memory and this agent's active notes; a suggestion is not put in a run.
const notes = await memoryStore.forRun(s, agent.owner, agent.id);
const notes = await memoryStore.forRun(s, agent.owner, agent.id);
- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, beforeInsert: async () => {
+ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, runInput), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, beforeInsert: async () => {
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
@@ -111,6 +111,10 @@ function load(env = process.env) {
@@ -111,6 +111,10 @@ function load(env = process.env) {
},
},
// The per-call liability ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.
// The per-call liability ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.
maxCostUsd: optNum(env.ACTOR_AI_MAX_COST_USD),
maxCostUsd: optNum(env.ACTOR_AI_MAX_COST_USD),
+ // Latency classes (plan BA/HO): a routine's or a goal's run may wait, so it asks AI's router for the
+ // "background" class (a Flex tier, off-peak, or the host's own idle model). Contracts 0.336.0 carries
+ // options.latency (BA3); off until AI is deployed with it, because an older AI refuses an unknown option.
+ latencyClasses: bool(env.ACTOR_AI_LATENCY_CLASSES),
// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
// the plain one it was before.
// the plain one it was before.
@@ -325,10 +325,12 @@ function createApi(ctx) {
@@ -325,10 +325,12 @@ function createApi(ctx) {
? await triggers.forDelivery(row.owner, { triggerId: body.trigger_id, watchId: body.watch_id })
? await triggers.forDelivery(row.owner, { triggerId: body.trigger_id, watchId: body.watch_id })
: null;
: null;
if (trigger && trigger.state === 'paused') return problem(req, res, 409, 'actor.trigger.paused', 'This trigger is paused, so nothing ran. Resume it on its card.');
if (trigger && trigger.state === 'paused') return problem(req, res, 409, 'actor.trigger.paused', 'This trigger is paused, so nothing ran. Resume it on its card.');
+ if (trigger && trigger.state === 'done') return problem(req, res, 409, 'actor.trigger.done', 'This goal is done, so nothing ran.');
const out = await personal.run(ctx, row, body.input || '', {
const out = await personal.run(ctx, row, body.input || '', {
origin: viaWatch ? 'routine' : 'person',
origin: viaWatch ? 'routine' : 'person',
level: trigger ? trigger.level : null,
level: trigger ? trigger.level : null,
triggerId: trigger ? trigger.id : null,
triggerId: trigger ? trigger.id : null,
+ goal: trigger ? trigger.goal : null,
});
});
if (out.code) return problem(req, res, out.status, out.code, out.detail);
if (out.code) return problem(req, res, out.status, out.code, out.detail);
res.set('Location', `/api/v1/tasks/${out.task.id}`);
res.set('Location', `/api/v1/tasks/${out.task.id}`);
@@ -642,9 +644,10 @@ function createApi(ctx) {
@@ -642,9 +644,10 @@ function createApi(ctx) {
const b = req.body || {};
const b = req.body || {};
const out = await triggers.create({
const out = await triggers.create({
owner: triggerOwner(req), agentId: b.agent_id, task: b.task, kind: b.kind,
owner: triggerOwner(req), agentId: b.agent_id, task: b.task, kind: b.kind,
- cron: b.cron, timezone: b.timezone, event: b.event,
+ cron: b.cron, timezone: b.timezone, event: b.event, goal: b.goal,
level: 'ask', turnTaskId: TASK_ID.test(String(b.turn_task_id || '')) ? b.turn_task_id : null,
level: 'ask', turnTaskId: TASK_ID.test(String(b.turn_task_id || '')) ? b.turn_task_id : null,
personToken: watchToken(req),
personToken: watchToken(req),
+ answerInChat: b.answer_in_chat !== false, notify: b.notify === true,
});
});
if (out.error) return triggerFail(req, res, out);
if (out.error) return triggerFail(req, res, out);
res.status(201).json({ trigger: out.trigger });
res.status(201).json({ trigger: out.trigger });
@@ -307,15 +307,22 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
@@ -307,15 +307,22 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
}
}
/**
/**
- * The routines this turn made (plan §12 A-4): one card each, its latest state. What runs and when in words, the
- * next run in the reader's own zone, how the last run went, and the person's moves as plain forms (they work with
- * no script): Pause or Resume, Run now, and Undo for ten minutes, then Delete with a confirmation. A shared, read-only
- * turn shows the card as it was made, with no moves and nothing read beyond the turn itself.
+ * The routines this turn made (plan §12 A-4): one card each, its latest state, and under it the runs'
+ * answers (plan HO4) — each run posts its answer, its sources and a link to the run into this chat.
+ * What runs and when in words, the next run in the reader's own zone, how the last run went, and the
+ * person's moves as plain forms (they work with no script): Pause or Resume, Run now, and Undo for ten
+ * minutes, then Delete with a confirmation. A shared, read-only turn shows the card as it was made, with
+ * no moves and nothing read beyond the turn itself.
*/
*/
async function triggerBlocks(row, events, readOnly = false) {
async function triggerBlocks(row, events, readOnly = false) {
const latest = new Map();
const latest = new Map();
+ const runs = new Map();
for (const e of events || []) {
for (const e of events || []) {
if (e && e.kind === 'output' && e.step === 'trigger' && e.trigger && TRIGGER_ID.test(String(e.trigger.id || ''))) latest.set(e.trigger.id, e.trigger);
if (e && e.kind === 'output' && e.step === 'trigger' && e.trigger && TRIGGER_ID.test(String(e.trigger.id || ''))) latest.set(e.trigger.id, e.trigger);
+ if (e && e.kind === 'output' && e.step === 'trigger-run' && TRIGGER_ID.test(String(e.trigger_id || ''))) {
+ if (!runs.has(e.trigger_id)) runs.set(e.trigger_id, []);
+ runs.get(e.trigger_id).push(e);
+ }
}
}
if (!latest.size) return '';
if (!latest.size) return '';
const owner = String(row.requester || '').replace(/^user:/, '');
const owner = String(row.requester || '').replace(/^user:/, '');
@@ -327,37 +334,85 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
@@ -327,37 +334,85 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
const live = await ctx.triggers.get(owner, id).catch(() => null);
const live = await ctx.triggers.get(owner, id).catch(() => null);
if (live) card = live; else gone = true;
if (live) card = live; else gone = true;
}
}
- cards.push(triggerCard(row, card, { gone, readOnly }));
+ cards.push(triggerCard(row, card, { gone, readOnly, runs: runs.get(id) || [] }));
}
}
return html`${cards}`;
return html`${cards}`;
}
}
- function triggerCard(row, card, { gone = false, readOnly = false } = {}) {
+ /**
+ * One run of a routine, as it was posted into this chat (plan HO4): when it ran, its answer, the addresses
+ * it read and a link to the run. A run that did not finish is one line that says why. A goal's run also
+ * shows the run's own verdict on the done-test when there is one.
+ */
+ function runPost(run) {
+ const at = run.at ? new Date(run.at) : null;
+ const when = at && !Number.isNaN(at.getTime())
+ ? html`<time datetime="${at.toISOString()}" data-ct-local>${at.toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`
+ : 'A run';
+ const taskId = run.run && run.run.task_id;
+ const failed = run.run && run.run.state !== 'ok';
+ const sources = Array.isArray(run.sources) ? run.sources.slice(0, 8) : [];
+ const body = failed || !run.text
+ ? html`<p class="ct-trig-run-line">${String(run.text || 'That run did not finish.')}</p>`
+ : html`<div class="prose">${raw(markdown(String(run.text).slice(0, 8000), { headingOffset: 3 }))}</div>`;
+ return html`<div class="ct-trig-run${failed ? ' is-bad' : ''}">
+<p class="ct-trig-run-h">${when}${taskId ? html` · <a href="/tasks/${taskId}">open the run</a>` : ''}</p>
+${body}
+${sources.length ? html`<p class="ct-trig-sources">${sources.map((u, i) => html`${i ? ' · ' : ''}<a href="${u}" target="_blank" rel="noopener nofollow">${clip(String(u).replace(/^https?:\/\//, ''), 60)}</a>`)}</p>` : ''}
+</div>`;
+ }
+
+ function triggerCard(row, card, { gone = false, readOnly = false, runs = [] } = {}) {
const when = card.when || {};
const when = card.when || {};
+ const goal = card.goal || null;
const paused = !gone && card.state === 'paused';
const paused = !gone && card.state === 'paused';
- const state = gone ? 'Deleted' : paused ? 'Paused' : 'On';
+ const done = !gone && card.state === 'done';
+ const state = gone ? 'Deleted' : done ? 'Done' : paused ? 'Paused' : 'On';
const last = card.last_run && LAST_RUN[card.last_run.state] ? card.last_run : null;
const last = card.last_run && LAST_RUN[card.last_run.state] ? card.last_run : null;
const undoable = !gone && card.undo_until && Date.parse(card.undo_until) > now();
const undoable = !gone && card.undo_until && Date.parse(card.undo_until) > now();
- const why = paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.` : 'Paused by you.') : '';
- const icon = card.kind === 'event' ? 'zap' : 'calendar';
+ const why = done
+ ? (card.paused_reason === 'done' && goal ? `The goal is reached: ${goal.done_test}.` : 'This goal is finished.')
+ : paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.`
+ : card.paused_reason === 'budget' ? 'Stopped: its runs or its money ran out.'
+ : card.paused_reason === 'deadline' ? 'Stopped: its deadline passed.'
+ : 'Paused by you.') : '';
+ const icon = card.kind === 'event' ? 'zap' : card.kind === 'goal' ? 'target' : 'calendar';
const words = String(when.words || (card.kind === 'event' ? 'When it happens' : 'On a schedule'));
const words = String(when.words || (card.kind === 'event' ? 'When it happens' : 'On a schedule'));
- const zone = card.kind === 'schedule' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';
+ const zone = card.kind !== 'event' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';
const meta = [];
const meta = [];
- if (!gone && !paused && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);
+ // A goal's progress in one line (plan HO3): "Run 4 of at most 12 · not yet: €134".
+ if (goal) {
+ const used = Number(goal.runs_used) || 0;
+ const cap = goal.runs_max == null ? null : Number(goal.runs_max);
+ const spend = goal.budget_usd == null ? '' : ` · $${Number(goal.spent_usd || 0).toFixed(2)} of $${Number(goal.budget_usd).toFixed(2)}`;
+ const nobody = done || paused || gone ? '' : (goal.not_yet ? ` · not yet: ${clip(goal.not_yet, 120)}` : '');
+ meta.push(html`<span class="ct-trig-goal">Run ${used}${cap == null ? '' : ` of at most ${cap}`}${spend}${nobody}</span>`);
+ if (goal.deadline_at && !done && !gone) meta.push(html`until <time datetime="${new Date(goal.deadline_at).toISOString()}" data-ct-local>${new Date(goal.deadline_at).toISOString().slice(0, 10)}</time>`);
+ }
+ if (!gone && !paused && !done && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);
if (last) meta.push(html`<a href="/tasks/${last.task_id}">Last run ${LAST_RUN[last.state]}</a>`);
if (last) meta.push(html`<a href="/tasks/${last.task_id}">Last run ${LAST_RUN[last.state]}</a>`);
if (card.agent_name) meta.push(html`Runs as ${card.agent_name}`);
if (card.agent_name) meta.push(html`Runs as ${card.agent_name}`);
const moves = gone || readOnly ? '' : html`<div class="ovt-card-acts">
const moves = gone || readOnly ? '' : html`<div class="ovt-card-acts">
-${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>`}
-<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>
+${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : ''}
+${!paused && !done ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>` : ''}
+${done ? '' : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>`}
${undoable ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="undo">Undo</button>`
${undoable ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="undo">Undo</button>`
: html`<details class="ct-trig-del"><summary class="ovt-card-btn is-quiet">Delete</summary><p>Delete it for good? No future run happens.</p><button type="submit" class="ovt-card-btn is-danger" name="do" value="delete">Delete it</button></details>`}
: html`<details class="ct-trig-del"><summary class="ovt-card-btn is-quiet">Delete</summary><p>Delete it for good? No future run happens.</p><button type="submit" class="ovt-card-btn is-danger" name="do" value="delete">Delete it</button></details>`}
</div>`;
</div>`;
- return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}">
+ // The two switches (plan HO4): answer in this chat, and also tell me through notifications.
+ const switches = gone || readOnly ? '' : html`<div class="ovt-card-acts ct-trig-switches">
+<button type="submit" class="ovt-card-btn is-quiet${card.answer_in_chat === false ? '' : ' is-on'}" name="do" value="${card.answer_in_chat === false ? 'answer-on' : 'answer-off'}" aria-pressed="${card.answer_in_chat === false ? 'false' : 'true'}">Answer in this chat: ${card.answer_in_chat === false ? 'off' : 'on'}</button>
+<button type="submit" class="ovt-card-btn is-quiet${card.notify ? ' is-on' : ''}" name="do" value="${card.notify ? 'notify-off' : 'notify-on'}" aria-pressed="${card.notify ? 'true' : 'false'}">Tell me too: ${card.notify ? 'on' : 'off'}</button>
+</div>`;
+ const shown = runs.slice(-8).map(runPost);
+ return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}${done ? ' is-done' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}">
<p class="ovt-card-h">${raw(glyphs.svg(icon, { size: 18 }))}<span class="ct-trig-when"><b>${words}</b>${zone}</span><span class="ct-trig-state">${state}</span></p>
<p class="ovt-card-h">${raw(glyphs.svg(icon, { size: 18 }))}<span class="ct-trig-when"><b>${words}</b>${zone}</span><span class="ct-trig-state">${state}</span></p>
<p class="ct-trig-task">${String(card.task || '')}</p>
<p class="ct-trig-task">${String(card.task || '')}</p>
+${goal && goal.text ? html`<p class="ct-trig-goal-text">${clip(goal.text, 400)}</p>` : ''}
${meta.length ? html`<p class="ct-trig-meta">${meta.map((m, i) => html`${i ? ' · ' : ''}${m}`)}</p>` : ''}
${meta.length ? html`<p class="ct-trig-meta">${meta.map((m, i) => html`${i ? ' · ' : ''}${m}`)}</p>` : ''}
${why ? html`<p class="ct-trig-why">${why}</p>` : ''}
${why ? html`<p class="ct-trig-why">${why}</p>` : ''}
-${moves}</form>`;
+${moves}${switches}</form>
+${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
}
}
/** The files beside the question (thread.files draws them). */
/** The files beside the question (thread.files draws them). */
@@ -712,7 +767,10 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -712,7 +767,10 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
: move === 'run' ? await t.runNow(owner, req.params.tid)
: move === 'run' ? await t.runNow(owner, req.params.tid)
: move === 'undo' ? await t.undo(owner, req.params.tid, { token })
: move === 'undo' ? await t.undo(owner, req.params.tid, { token })
: move === 'delete' ? await t.remove(owner, req.params.tid, { token, confirm: true })
: move === 'delete' ? await t.remove(owner, req.params.tid, { token, confirm: true })
- : { error: { status: 400, detail: 'Say pause, resume, run, undo or delete.' } };
+ // The routine's two switches (HO4): answer in this chat, and also tell me.
+ : move === 'answer-on' || move === 'answer-off' ? await t.setSwitch(owner, req.params.tid, { answerInChat: move === 'answer-on' })
+ : move === 'notify-on' || move === 'notify-off' ? await t.setSwitch(owner, req.params.tid, { notify: move === 'notify-on' })
+ : { error: { status: 400, detail: 'Say pause, resume, run, undo, delete, answer-on or notify-on.' } };
if (out && out.error) {
if (out && out.error) {
const status = out.error.status || 409;
const status = out.error.status || 409;
if (wantsJson) return res.status(status).json({ error: out.error.detail || 'That did not change.', code: out.error.code || null });
if (wantsJson) return res.status(status).json({ error: out.error.detail || 'That did not change.', code: out.error.code || null });
@@ -302,6 +302,8 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -302,6 +302,8 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
maxTokens: MAX_ANSWER_TOKENS,
maxTokens: MAX_ANSWER_TOKENS,
signal: state.signal,
signal: state.signal,
...(onDelta ? { onDelta } : {}),
...(onDelta ? { onDelta } : {}),
+ // A routine's run may wait (plan BA): the class it asks AI's router for, when the caller named one.
+ ...(ctx.latency ? { latency: ctx.latency } : {}),
});
});
} catch (err) {
} catch (err) {
await spanModel(ctx.trace, { tier: state.tier, family: state.family, startedAt, ms: now() - startedAt, error: err, step: state.calls + 1 });
await spanModel(ctx.trace, { tier: state.tier, family: state.family, startedAt, ms: now() - startedAt, error: err, step: state.calls + 1 });
@@ -856,7 +858,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -856,7 +858,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered
* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered
* without another model call.
* without another model call.
*/
*/
- async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null, personToken = null, timezone = null, triggers = null, level = null }) {
+ async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null }) {
const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);
const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);
const restored = restoreState(source);
const restored = restoreState(source);
if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });
if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });
@@ -877,7 +879,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -877,7 +879,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
// delivers a saved final answer without calling the model again.
// delivers a saved final answer without calling the model again.
if (fork) state.answered = false;
if (fork) state.answered = false;
const ctx = {
const ctx = {
- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency,
store, now, agentId, personText: personText == null ? String(task) : String(personText),
store, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
readOutside: () => state.readOutside === true, notifyTask,
@@ -1014,9 +1016,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1014,9 +1016,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
}
}
}
}
- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null }) {
+ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null }) {
if (resume || fork) {
if (resume || fork) {
- return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });
+ return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta, latency });
}
}
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
@@ -1040,7 +1042,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1040,7 +1042,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
},
},
};
};
const ctx = {
const ctx = {
- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency,
store, now, agentId, personText: personText == null ? String(task) : String(personText),
store, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
readOutside: () => state.readOutside === true, notifyTask,
@@ -1,17 +1,21 @@
@@ -1,17 +1,21 @@
'use strict';
'use strict';
/**
/**
- * A trigger made in the conversation (plan §12 A-4): "every weekday at 9, summarise my inbox".
+ * A trigger made in the conversation (plan §12 A-4, HO3, HO4): "every weekday at 9, summarise my inbox", "when a
+ * new issue lands in my repo, triage it", or "keep checking the price of X every 30 minutes until it drops
+ * under €120, then tell me" — a goal that keeps going until its own check passes.
*
*
- * The sentence is parsed here, deterministically. When it is ambiguous (no time, no timezone, two
- * times, "every so often", no event it can name) the tool creates nothing and returns the one
- * question to ask in plain words. When it is clear, the subscription is created in OpenVibe.Watch
- * with the person's own token, linked to this thread, and the card goes onto the turn as a trigger
- * step. The model may also pass the fields explicitly (`cron`, `timezone`, `task`, `event`) after the
- * person answered.
+ * The sentence is parsed here, deterministically. When it is ambiguous (no time, no timezone, two times, "every
+ * so often", no event it can name, an "until" no run can check) the tool creates nothing and returns the one
+ * question to ask in plain words. When it is clear, the subscription is created in OpenVibe.Watch with the
+ * person's own token, linked to this thread, and the card goes onto the turn as a trigger step. The model may
+ * also pass the fields explicitly (`cron`, `timezone`, `task`, `event`, `goal`) after the person answered.
+ *
+ * A goal (kind goal) carries the goal in words, the done-test each run answers yes or no, and its limits: runs,
+ * money and a deadline. Its runs post their answers in this chat (unless the person switches that off).
*/
*/
const { personOf } = require('./_openvibe');
const { personOf } = require('./_openvibe');
const contacts = require('../../agents/contacts');
const contacts = require('../../agents/contacts');
-const { parseRequest } = require('../../triggers/parse');
+const { parseRequest, wordsFor } = require('../../triggers/parse');
const cron = require('../../triggers/cron');
const cron = require('../../triggers/cron');
const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;
const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;
@@ -27,7 +31,7 @@ async function namedAgent(s, config, owner, text) {
@@ -27,7 +31,7 @@ async function namedAgent(s, config, owner, text) {
module.exports = {
module.exports = {
name: 'schedule_trigger',
name: 'schedule_trigger',
- description: 'Make something run on a schedule or when an OpenVibe event happens: "every weekday at 9, summarise my inbox" or "when a new issue lands in my repo, triage it". It parses the sentence, creates the Watch subscription as the person, and puts a live trigger card on this thread. If the request is ambiguous it creates nothing and tells you the one question to ask them first (the time, the timezone, what event, or what to do). Money, passwords, deletions and new recipients still always ask when the trigger runs.',
+ description: 'Make something run on a schedule, when an OpenVibe event happens, or keep working toward a goal until it is done: "every weekday at 9, summarise my inbox", "when a new issue lands in my repo, triage it", or "keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me". It parses the sentence, creates the Watch subscription as the person, and puts a live trigger card on this thread. If the request is ambiguous it creates nothing and tells you the one question to ask them first (the time, the timezone, what event, what to do, or — for a goal — the check that says it is done). A goal ends itself when its check passes, or when its runs, money or deadline is spent, and each run posts its answer in this chat. Money, passwords, deletions and new recipients still always ask when the trigger runs.',
scope: 'person',
scope: 'person',
acts: true,
acts: true,
costUsd: 0,
costUsd: 0,
@@ -49,6 +53,19 @@ module.exports = {
@@ -49,6 +53,19 @@ module.exports = {
},
},
required: ['pattern'],
required: ['pattern'],
},
},
+ goal: {
+ type: 'object',
+ description: 'A goal to keep working toward, when the person asked for one ("until …"). Give it when the sentence did not parse into one after you asked, or to set its limits.',
+ properties: {
+ text: { type: 'string', description: 'The goal in the person\'s words.' },
+ done_test: { type: 'string', description: 'The check a run answers yes or no, such as "the price is under €120" or "the tests pass".' },
+ runs_max: { type: 'integer', description: 'The most runs allowed (default 12).' },
+ budget_usd: { type: 'number', description: 'The most its runs may spend (default: the person\'s daily allowance).' },
+ deadline_at: { type: 'string', description: 'A date-time after which the goal ends (default: seven days).' },
+ },
+ required: ['text', 'done_test'],
+ },
+ notify: { type: 'boolean', description: 'Also tell the person through OpenVibe\'s notifications when a run finishes. Default: only when their sentence asked to be told.' },
},
},
required: [],
required: [],
},
},
@@ -59,23 +76,29 @@ module.exports = {
@@ -59,23 +76,29 @@ module.exports = {
const said = String(input.request || '').trim();
const said = String(input.request || '').trim();
const explicitCron = typeof input.cron === 'string' && input.cron.trim() ? input.cron.trim() : null;
const explicitCron = typeof input.cron === 'string' && input.cron.trim() ? input.cron.trim() : null;
const explicitEvent = input.event && typeof input.event === 'object' && EVENT_PATTERN.test(String(input.event.pattern || '')) ? input.event : null;
const explicitEvent = input.event && typeof input.event === 'object' && EVENT_PATTERN.test(String(input.event.pattern || '')) ? input.event : null;
+ const explicitGoal = input.goal && typeof input.goal === 'object' && String(input.goal.text || '').trim() && String(input.goal.done_test || '').trim() ? input.goal : null;
+ const timezone = String(input.timezone || ctx.timezone || '').trim();
let parsed;
let parsed;
- if (explicitCron || explicitEvent) {
- const kind = explicitCron ? 'schedule' : 'event';
- const timezone = String(input.timezone || ctx.timezone || '').trim();
+ if (explicitCron || explicitEvent || explicitGoal) {
+ const kind = explicitGoal ? 'goal' : explicitCron ? 'schedule' : 'event';
const task = String(input.task || said || '').trim();
const task = String(input.task || said || '').trim();
if (!task) return { text: 'Before I make it: what should I do each time?', data: { question: 'What should I do each time?' } };
if (!task) return { text: 'Before I make it: what should I do each time?', data: { question: 'What should I do each time?' } };
- if (kind === 'schedule') {
+ if (kind !== 'event') {
if (!timezone || !cron.isZone(timezone)) return { text: 'Before I make it: which timezone should the schedule use? (For example Europe/Berlin.)', data: { question: 'Which timezone?' } };
if (!timezone || !cron.isZone(timezone)) return { text: 'Before I make it: which timezone should the schedule use? (For example Europe/Berlin.)', data: { question: 'Which timezone?' } };
- const verdict = cron.validate(explicitCron, timezone);
+ const expr = explicitCron || '*/15 * * * *';
+ const verdict = cron.validate(expr, timezone);
if (!verdict.valid) return { text: `That schedule cannot run: ${verdict.error}. What time should it run?`, data: { question: `That schedule cannot run: ${verdict.error}. What time should it run?` } };
if (!verdict.valid) return { text: `That schedule cannot run: ${verdict.error}. What time should it run?`, data: { question: `That schedule cannot run: ${verdict.error}. What time should it run?` } };
- parsed = { status: 'ok', kind, cron: explicitCron, timezone, task, words: require('../../triggers/parse').wordsFor(explicitCron, timezone) };
+ parsed = {
+ status: 'ok', kind, cron: expr, timezone, task, words: wordsFor(expr, timezone),
+ ...(explicitGoal ? { goal: { text: String(explicitGoal.text).trim(), done_test: String(explicitGoal.done_test).trim(), runs_max: explicitGoal.runs_max, budget_usd: explicitGoal.budget_usd, deadline_at: explicitGoal.deadline_at } } : {}),
+ tell: false,
+ };
} else {
} else {
- parsed = { status: 'ok', kind, task, event: { pattern: input.event.pattern, filter: input.event.filter || null }, words: `When ${input.event.pattern}` };
+ parsed = { status: 'ok', kind, task, event: { pattern: input.event.pattern, filter: input.event.filter || null }, words: `When ${input.event.pattern}`, tell: false };
}
}
} else {
} else {
- parsed = parseRequest(said, { timezone: String(input.timezone || ctx.timezone || '').trim() || null, task: input.task || null });
+ parsed = parseRequest(said, { timezone: timezone || null, task: input.task || null });
}
}
if (parsed.status !== 'ok') {
if (parsed.status !== 'ok') {
return { text: `I need one thing before I make it: ${parsed.question}`, data: { needs_you: true, question: parsed.question }, sources: [] };
return { text: `I need one thing before I make it: ${parsed.question}`, data: { needs_you: true, question: parsed.question }, sources: [] };
@@ -94,16 +117,19 @@ module.exports = {
@@ -94,16 +117,19 @@ module.exports = {
}
}
if (!agentId) return { text: 'Before I make it: which of your agents should run it?', data: { question: 'Which agent?' } };
if (!agentId) return { text: 'Before I make it: which of your agents should run it?', data: { question: 'Which agent?' } };
+ const notify = input.notify === true || (input.notify === undefined && parsed.tell === true);
const out = await ctx.triggers.create({
const out = await ctx.triggers.create({
owner, agentId, task: parsed.task,
owner, agentId, task: parsed.task,
kind: parsed.kind,
kind: parsed.kind,
cron: parsed.cron || null,
cron: parsed.cron || null,
timezone: parsed.timezone || null,
timezone: parsed.timezone || null,
event: parsed.event || null,
event: parsed.event || null,
+ goal: parsed.goal || null,
level: ctx.level || 'ask',
level: ctx.level || 'ask',
turnTaskId: ctx.taskId || null,
turnTaskId: ctx.taskId || null,
personToken: ctx.personToken || null,
personToken: ctx.personToken || null,
announce: false,
announce: false,
+ notify,
});
});
if (out.error) {
if (out.error) {
return {
return {
@@ -112,7 +138,8 @@ module.exports = {
@@ -112,7 +138,8 @@ module.exports = {
};
};
}
}
const card = out.trigger;
const card = out.trigger;
- const line = `It is on: ${card.when.words}. ${clip(card.task, 160)}${card.next_run_at ? ` Next run ${card.next_run_at}.` : ''}`;
+ const goal = card.goal ? ` It ends when ${card.goal.done_test}, or after ${card.goal.runs_max} runs${card.goal.deadline_at ? ` or on ${String(card.goal.deadline_at).slice(0, 10)}` : ''}.` : '';
+ const line = `It is on: ${card.when.words}. ${clip(card.task, 160)}${card.next_run_at ? ` Next run ${card.next_run_at}.` : ''}${goal}${notify ? ' I will tell you when a run finishes.' : ''}`;
if (ctx.emit) await ctx.emit({ step: 'trigger', text: line, trigger: card });
if (ctx.emit) await ctx.emit({ step: 'trigger', text: line, trigger: card });
return { text: line, data: { trigger: card } };
return { text: line, data: { trigger: card } };
},
},
@@ -59,6 +59,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -59,6 +59,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
let waits = waitsArg;
let waits = waitsArg;
// Triggers are created after the engine (they need it to run now); the app sets them once.
// Triggers are created after the engine (they need it to run now); the app sets them once.
let triggers = triggersArg;
let triggers = triggersArg;
+
+ /**
+ * The latency class a run asks AI's router for (plan BA track, HO3/HO4). A routine's run — a schedule, an
+ * event or a goal — may wait, so it asks for "background": AI then picks the cheapest tier that can wait (a
+ * Flex service tier, off-peak, or the host's own idle model). An interactive task asks for nothing and keeps
+ * the router's normal choice. server/runtime/models.js sends the class only when ACTOR_AI_LATENCY_CLASSES is
+ * on, so a server pointed at an AI still on the older contract (before options.latency) is never refused.
+ */
+ async function latencyClass(taskId) {
+ const root = await s.db.maybe('SELECT origin FROM actor_assignments WHERE task_id = $1 AND depth = 0', [taskId]).catch(() => null);
+ return root && root.origin === 'routine' ? 'background' : null;
+ }
const setTriggers = (t) => { triggers = t || null; };
const setTriggers = (t) => { triggers = t || null; };
const controls = new Map(); // task id → the run's control object (live assumptions)
const controls = new Map(); // task id → the run's control object (live assumptions)
let stopped = false;
let stopped = false;
@@ -158,7 +170,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -158,7 +170,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (ended) runTokens.drop(id);
if (ended) runTokens.drop(id);
if (ended) stream.end(id);
if (ended) stream.end(id);
// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.
// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.
- if (ended && row && (state === 'succeeded' || state === 'failed')) await tellPerson(row, state);
+ // A routine's run is the exception (plan HO4): its answer already goes into the conversation that made the
+ // trigger, so the person is told through notifications only when the routine asked for it.
+ if (ended && row && (state === 'succeeded' || state === 'failed')) {
+ const routine = triggers && typeof triggers.tellsPerson === 'function' ? await triggers.tellsPerson(row).catch(() => null) : null;
+ if (routine !== false) await tellPerson(row, state);
+ }
// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.
// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.
if (webhooks && row && row.webhooks) {
if (webhooks && row && row.webhooks) {
try {
try {
@@ -470,6 +487,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -470,6 +487,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
personText, agentId: ownAgent, notifyTask,
personText, agentId: ownAgent, notifyTask,
askPerson, control, resume, fork, spent: () => spent,
askPerson, control, resume, fork, spent: () => spent,
waits, waitEvent: wakePayload, attachments,
waits, waitEvent: wakePayload, attachments,
+ // A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.
+ latency: await latencyClass(id),
}));
}));
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// ('running' is the closest state there is today), its wall clock waits with it, and its
// ('running' is the closest state there is today), its wall clock waits with it, and its
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops under €120".
+ *
+ * The goal is a trigger of kind goal. Its schedule is the check interval (Watch's five-field cron, the
+ * same 15-minute floor), and each run does a piece of the work and then answers the goal's done-test:
+ * one short yes or no with its reason, on a line of its own. The answer is the agent's judgement, made
+ * with what the run actually saw; a run that never says is not a done-test passing.
+ *
+ * promptFor(goal, task) the run input: the work, then what to answer
+ * verdictOf(answer) { done, reason, answer } — the machine line taken out of the answer
+ * endingOf({ goal, runsUsed, spentUsd, at }) { reason, line } — how the goal ends now, or null
+ */
+
+/** "VERDICT: yes — €112 on the maker's store" (an em dash, a colon or a hyphen all do). */
+const VERDICT_SOURCE = String.raw`^[ \t]*VERDICT[ \t]*:[ \t]*(yes|no|true|false)[ \t]*(?:[—–:-]+[ \t]*)?(.*)$`;
+const verdictRe = () => new RegExp(VERDICT_SOURCE, 'gim');
+const MAX_REASON = 300;
+
+const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+const clip = (s, n) => { const t = clean(s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+
+/** What one run is asked: do a piece of the work, then answer the done-test in one line. */
+function promptFor(goal, task) {
+ const text = clip(goal && goal.text, 500);
+ const test = clip(goal && goal.done_test, 300);
+ if (!test) return String(task || '');
+ return [
+ `This is one run of a goal the person set: "${text}".`,
+ `The goal is done only when this is true: "${test}". Each run you do a piece of the work, then check it.`,
+ 'End your answer with one line of exactly this shape:',
+ 'VERDICT: yes — <the one fact that settles it>',
+ 'or',
+ 'VERDICT: no — <what is still missing, in as few words as possible>',
+ 'Use what this run actually saw. Never say yes on a guess, and never write anything after that line.',
+ '',
+ String(task || ''),
+ ].join('\n');
+}
+
+/**
+ * The answer with its verdict: { done: true|false|null, reason, answer }. A run that did not answer the
+ * done-test is done: null, so the goal keeps going and says the agent did not say.
+ */
+function verdictOf(answer) {
+ const text = String(answer == null ? '' : answer);
+ let found = null;
+ const re = verdictRe();
+ for (const m of text.matchAll(re)) found = m;
+ if (!found) return { done: null, reason: '', answer: text.trim() };
+ const done = /^(yes|true)$/i.test(found[1]);
+ const reason = clip(found[2].replace(/^[\s—–:-]+/, ''), MAX_REASON);
+ const kept = text.replace(verdictRe(), '').replace(/\n{3,}/g, '\n\n').trim();
+ return { done, reason, answer: kept };
+}
+
+/**
+ * How a goal ends after a run, from its own limits: the done-test passed, or the last run, the money or
+ * the deadline is spent. → { reason, line } or null. `reason` is the card's paused_reason.
+ */
+function endingOf({ goal, verdict, runsUsed, spentUsd, at = Date.now() }) {
+ const g = goal || {};
+ if (verdict && verdict.done === true) {
+ return { reason: 'done', line: `Goal reached: ${clip(g.done_test, 200)}${verdict.reason ? ` (${verdict.reason})` : ''}.` };
+ }
+ const runsMax = g.runs_max == null ? NaN : Number(g.runs_max);
+ if (Number.isFinite(runsMax) && runsMax > 0 && runsUsed >= runsMax) {
+ return { reason: 'budget', line: `Stopping here: the goal has used its ${runsMax} run${runsMax === 1 ? '' : 's'} and is not done.` };
+ }
+ const budget = g.budget_usd == null ? NaN : Number(g.budget_usd);
+ if (Number.isFinite(budget) && spentUsd >= budget) {
+ return { reason: 'budget', line: `Stopping here: the goal has spent its $${budget.toFixed(2)} budget and is not done.` };
+ }
+ const deadline = g.deadline_at ? Date.parse(g.deadline_at) : NaN;
+ if (Number.isFinite(deadline) && at >= deadline) {
+ return { reason: 'deadline', line: `Stopping here: ${new Date(deadline).toISOString().slice(0, 10)} passed and the goal is not done.` };
+ }
+ return null;
+}
+
+module.exports = { promptFor, verdictOf, endingOf, VERDICT_SOURCE, MAX_REASON };
@@ -163,6 +163,56 @@ function scheduleIn(text) {
@@ -163,6 +163,56 @@ function scheduleIn(text) {
return best;
return best;
}
}
+/**
+ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops
+ * under €120, then tell me". The check that ends it is the clause after "until"; the interval is the
+ * schedule phrase when there is one and every fifteen minutes (Watch's floor) when there is not. A
+ * check that cannot be seen ("until it's done", "until I'm happy") is a question, never a guess.
+ */
+const GOAL_EVERY = '*/15 * * * *';
+/** A done-test must name something a run can see: a number, or a change of state. */
+const CHECKABLE = /\b(pass(?:es|ed)?|fail(?:s|ed)?|green|resolv(?:ed|es)?|fixed|below|above|under|over|less than|more than|at least|at most|cheaper|cheapest|drop(?:s|ped)?|fall(?:s|en)?|rose?|rises?|reach(?:es|ed)?|hit(?:s)?|in stock|sold out|available|publish(?:ed|es)?|approv(?:ed|es)?|repl(?:y|ies|ied)|answer(?:s|ed)?|arriv(?:es|ed)|exceed(?:s|ed)?|match(?:es|ed)?|merge(?:s|d)?|fix(?:ed|es)?|return(?:s|ed)?|starts?|ends?|opens?|closes?)\b/i;
+const TELLING = /[,;]?\s*(?:and\s+|then\s+|so\s+)?(?:please\s+)?(?:tell me|let me know|notify me|message me|text me|ping me|email me|remind me|call me)\b.*$/i;
+/** The sentence itself asks to be told, at its end: "… under €120, then tell me" (not "email me the summary"). */
+const WANTS_TELLING = /(?:^|[,;])\s*(?:and\s+|then\s+|so\s+)?(?:please\s+)?(?:tell me|let me know|notify me|message me|text me|ping me)\b[^.]{0,120}$/i;
+const VERB_FIX = [[/^checking\b/i, 'check'], [/^watching\b/i, 'watch'], [/^monitoring\b/i, 'monitor'], [/^tracking\b/i, 'track'], [/^following\b/i, 'follow'], [/^working on\b/i, 'work on'], [/^looking at\b/i, 'look at'], [/^keeping an eye on\b/i, 'check']];
+
+/** The thing the person keeps checking, when the sentence names it. */
+function objectOf(lead) {
+ const m = String(lead || '').match(/\b(?:check(?:ing)?|watch(?:ing)?|monitor(?:ing)?|track(?:ing)?|follow(?:ing)?|work(?:ing)?\s+on|look(?:ing)?\s+at|keep(?:ing)?\s+an\s+eye\s+on)\s+(.+?)(?:\s+every\b|\s+until\b|\s*$)/i);
+ return m ? clean(m[1]) : null;
+}
+
+/** The sentence's "until …" clause, the check it names and the work before it, or null. */
+function untilIn(text) {
+ const source = clean(text);
+ const m = source.match(/\buntil\b\s+(.+)$/i);
+ if (!m) return null;
+ const lead = clean(source.slice(0, m.index));
+ const clause = clean(m[1].replace(TELLING, '')).replace(/[.;]+$/, '').trim();
+ if (!clause) return { lead, clause: '', ambiguous: true, reason: 'the sentence says "until" but not what ends it' };
+ // "it" in the clause is the thing the sentence names: "check the price of X … until it drops under €120".
+ const object = objectOf(lead);
+ const pronoun = /^it['’]s\b/i.test(clause) ? (object ? `${object} is${clause.slice(4)}` : null)
+ : /^it\b/i.test(clause) ? (object ? `${object}${clause.slice(2)}` : null) : null;
+ const test = pronoun && (CHECKABLE.test(pronoun) || /\d/.test(pronoun)) ? pronoun : clause;
+ if (!(CHECKABLE.test(test) || /\d/.test(test))) {
+ return { lead, clause, ambiguous: true, reason: 'the "until" clause says how it should feel, not what a run can check' };
+ }
+ return { lead, clause: test, test };
+}
+
+/** The work each check does: the sentence's lead, without "keep", its schedule phrase or the goal. */
+function taskFrom(lead, schedule) {
+ let t = clean(lead).replace(/^(?:and|then|please)\s+/i, '').replace(/^keep\s+an\s+eye\s+on\s+/i, 'check ').replace(/^keep\s+/i, '');
+ for (const [re, to] of VERB_FIX) { if (re.test(t)) { t = t.replace(re, to); break; } }
+ if (schedule && schedule.phrase) {
+ const i = t.toLowerCase().indexOf(schedule.phrase.toLowerCase());
+ if (i >= 0) t = clean(`${t.slice(0, i)} ${t.slice(i + schedule.phrase.length)}`);
+ }
+ return t.replace(/\s+(?:every|until)\b.*$/i, '').replace(/[,;:.\s]+$/, '').trim();
+}
+
/** A Watch event source from "when …", or null. */
/** A Watch event source from "when …", or null. */
function eventIn(text) {
function eventIn(text) {
const source = clean(text);
const source = clean(text);
@@ -209,6 +259,28 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = null
@@ -209,6 +259,28 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = null
if (AMBIGUOUS.test(source)) {
if (AMBIGUOUS.test(source)) {
return { status: 'ask', question: 'How often should this run, exactly? For example: every weekday at 9, or every hour.' };
return { status: 'ask', question: 'How often should this run, exactly? For example: every weekday at 9, or every hour.' };
}
}
+ // "keep … until …" is a goal that keeps going (plan HO3), not a one-off routine. The check that
+ // ends it is the clause after "until"; a clause no run can see is the one question to ask.
+ const until = untilIn(source);
+ if (until) {
+ const when = scheduleIn(source);
+ const cronExpr = when ? when.cron : GOAL_EVERY;
+ const zone = knownZone || zoneFromText(source);
+ if (!zone) return { status: 'ask', question: `I will check ${when ? when.phrase.replace(/^every\s+/i, 'every ') : 'every 15 minutes'} in which timezone?` };
+ const verdict = cron.validate(cronExpr, zone);
+ if (!verdict.valid) return { status: 'ask', question: `That check interval cannot run: ${verdict.error}. How often should I check?` };
+ if (until.ambiguous) {
+ return { status: 'ask', question: 'How will I know it is done? Say the check in one line, for example "the price is under €120" or "the tests pass".' };
+ }
+ const work = taskFrom(until.lead, when);
+ if (!work) return { status: 'ask', question: 'What should I do on each check, before I test whether it is done?' };
+ return {
+ status: 'ok', kind: 'goal', cron: cronExpr, timezone: zone, task: clean(givenTask || work).slice(0, 8000),
+ goal: { text: clean(source.replace(TELLING, '').slice(0, 500)), done_test: until.test },
+ words: when ? wordsFor(cronExpr, zone) : wordsFor(GOAL_EVERY, zone),
+ tell: WANTS_TELLING.test(source),
+ };
+ }
const schedule = scheduleIn(source);
const schedule = scheduleIn(source);
const event = eventIn(source);
const event = eventIn(source);
if (schedule && event) {
if (schedule && event) {
@@ -236,13 +308,15 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = null
@@ -236,13 +308,15 @@ function parseRequest(text, { timezone: knownZone = null, task: givenTask = null
return {
return {
status: 'ok', kind: 'schedule', cron: schedule.cron, timezone: zone, task,
status: 'ok', kind: 'schedule', cron: schedule.cron, timezone: zone, task,
words: wordsFor(schedule.cron, zone),
words: wordsFor(schedule.cron, zone),
+ tell: WANTS_TELLING.test(source),
};
};
}
}
return {
return {
status: 'ok', kind: 'event', task,
status: 'ok', kind: 'event', task,
event: { pattern: event.pattern, filter: event.filter },
event: { pattern: event.pattern, filter: event.filter },
words: event.words,
words: event.words,
+ tell: WANTS_TELLING.test(source),
};
};
}
}
-module.exports = { parseRequest, scheduleIn, eventIn, zoneFromText, wordsFor, AMBIGUOUS };
+module.exports = { parseRequest, scheduleIn, eventIn, untilIn, zoneFromText, wordsFor, AMBIGUOUS, GOAL_EVERY, WANTS_TELLING };
@@ -17,9 +17,11 @@ const cron = require('./cron');
@@ -17,9 +17,11 @@ const cron = require('./cron');
const store = require('./store');
const store = require('./store');
const watchClient = require('./watch');
const watchClient = require('./watch');
const parse = require('./parse');
const parse = require('./parse');
+const goalLib = require('./goal');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
const personal = require('../agents/personal');
const personal = require('../agents/personal');
+const tasks = require('../tasks/store');
const ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;
const ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;
const WATCH_ID = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/;
const WATCH_ID = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -28,9 +30,17 @@ const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;
@@ -28,9 +30,17 @@ const EVENT_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_*]+){1,}$/;
const MAX_ACTIVE = 20;
const MAX_ACTIVE = 20;
const UNDO_MS = 10 * 60_000;
const UNDO_MS = 10 * 60_000;
const MAX_TASK = 8000;
const MAX_TASK = 8000;
+const KINDS = ['schedule', 'event', 'goal'];
+const MAX_GOAL_TEXT = 500;
+const MAX_DONE_TEST = 300;
+const MAX_GOAL_RUNS = 200;
+/** A goal with no run limit given gets this many; with no deadline, this long. */
+const GOAL_RUNS = 12;
+const GOAL_DAYS = 7;
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+const usd = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;
const problem = (status, code, detail) => ({ status, code, detail });
const problem = (status, code, detail) => ({ status, code, detail });
function createTriggers({ s, config, engine = null, webhooks = null, stream = null, watch = null, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
function createTriggers({ s, config, engine = null, webhooks = null, stream = null, watch = null, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
@@ -48,10 +58,22 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -48,10 +58,22 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
async function card(row, { token = null, live = false } = {}) {
async function card(row, { token = null, live = false } = {}) {
if (!row) return null;
if (!row) return null;
const agent = await agentOf(row.owner, row.agent_id).catch(() => null);
const agent = await agentOf(row.owner, row.agent_id).catch(() => null);
+ const scheduled = row.kind === 'schedule' || row.kind === 'goal';
let next = null;
let next = null;
- if (row.state === 'active' && row.kind === 'schedule' && row.schedule && row.schedule.cron) {
+ if (row.state === 'active' && scheduled && row.schedule && row.schedule.cron) {
try { const at = cron.next(row.schedule.cron, row.schedule.timezone || row.timezone || 'UTC', nowMs()); if (at) next = iso(at); } catch { /* no next */ }
try { const at = cron.next(row.schedule.cron, row.schedule.timezone || row.timezone || 'UTC', nowMs()); if (at) next = iso(at); } catch { /* no next */ }
}
}
+ const goal = row.kind === 'goal' && row.goal ? {
+ text: String(row.goal.text || ''),
+ done_test: String(row.goal.done_test || ''),
+ runs_used: Number(row.runs_used) || 0,
+ runs_max: row.goal.runs_max == null ? null : Number(row.goal.runs_max),
+ spent_usd: usd(row.spent_usd),
+ budget_usd: row.goal.budget_usd == null ? null : Number(row.goal.budget_usd),
+ deadline_at: row.goal.deadline_at || null,
+ not_yet: row.verdict && row.verdict.done === false ? String(row.verdict.reason || '') || null : null,
+ verdict: row.verdict && row.verdict.at ? { done: Boolean(row.verdict.done), reason: String(row.verdict.reason || ''), at: row.verdict.at } : null,
+ } : null;
const out = {
const out = {
id: row.id,
id: row.id,
owner: row.owner,
owner: row.owner,
@@ -61,15 +83,18 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -61,15 +83,18 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
kind: row.kind,
kind: row.kind,
when: {
when: {
words: row.words,
words: row.words,
- cron: row.kind === 'schedule' && row.schedule ? row.schedule.cron : null,
- timezone: row.kind === 'schedule' ? (row.timezone || (row.schedule && row.schedule.timezone) || null) : null,
+ cron: scheduled && row.schedule ? row.schedule.cron : null,
+ timezone: scheduled ? (row.timezone || (row.schedule && row.schedule.timezone) || null) : null,
event: row.kind === 'event' && row.event ? { pattern: row.event.pattern, filter: row.event.filter || null } : null,
event: row.kind === 'event' && row.event ? { pattern: row.event.pattern, filter: row.event.filter || null } : null,
},
},
+ goal,
+ answer_in_chat: row.answer_in_chat !== false,
+ notify: row.notify === true,
next_run_at: next,
next_run_at: next,
last_run: row.last_task_id && row.last_state && row.last_run_at
last_run: row.last_task_id && row.last_state && row.last_run_at
? { task_id: row.last_task_id, state: row.last_state, at: iso(row.last_run_at) }
? { task_id: row.last_task_id, state: row.last_state, at: iso(row.last_run_at) }
: null,
: null,
- state: row.state === 'paused' ? 'paused' : 'active',
+ state: row.state === 'paused' ? 'paused' : row.state === 'done' ? 'done' : 'active',
paused_reason: row.paused_reason || null,
paused_reason: row.paused_reason || null,
failures: Number(row.failures) || 0,
failures: Number(row.failures) || 0,
watch: row.watch_id ? { id: row.watch_id, status: 'active' } : null,
watch: row.watch_id ? { id: row.watch_id, status: 'active' } : null,
@@ -104,19 +129,19 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -104,19 +129,19 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
try {
try {
const cardNow = await card(row);
const cardNow = await card(row);
const event = { at: new Date(nowMs()).toISOString(), kind: 'output', step: 'trigger', agent: 'openvibe-runtime', text: clip(text, 500), trigger: cardNow };
const event = { at: new Date(nowMs()).toISOString(), kind: 'output', step: 'trigger', agent: 'openvibe-runtime', text: clip(text, 500), trigger: cardNow };
- const saved = await require('../tasks/store').appendEvent(s, row.turn_task_id, event);
+ const saved = await tasks.appendEvent(s, row.turn_task_id, event);
if (stream && typeof stream.publish === 'function') stream.publish(row.turn_task_id, saved);
if (stream && typeof stream.publish === 'function') stream.publish(row.turn_task_id, saved);
} catch (err) {
} catch (err) {
log.warn && log.warn('[Actor] trigger card:', row.id, (err && err.message) || err);
log.warn && log.warn('[Actor] trigger card:', row.id, (err && err.message) || err);
}
}
}
}
- function validate({ owner, agentId, task, kind, cronExpr, timezone, event }) {
+ function validate({ owner, agentId, task, kind, cronExpr, timezone, event, goal }) {
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || ''))) return problem(401, 'actor.trigger.sign_in', 'Sign in to make a trigger.');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || ''))) return problem(401, 'actor.trigger.sign_in', 'Sign in to make a trigger.');
if (!agentId) return problem(422, 'actor.trigger.agent', 'Choose which of your agents should run it.');
if (!agentId) return problem(422, 'actor.trigger.agent', 'Choose which of your agents should run it.');
if (typeof task !== 'string' || !task.trim() || task.length > MAX_TASK) return problem(422, 'actor.trigger.task', 'Say what the agent should do each time, in a sentence.');
if (typeof task !== 'string' || !task.trim() || task.length > MAX_TASK) return problem(422, 'actor.trigger.task', 'Say what the agent should do each time, in a sentence.');
- if (!['schedule', 'event'].includes(kind)) return problem(422, 'actor.trigger.kind', 'A trigger is a schedule or a Watch event.');
- if (kind === 'schedule') {
+ if (!KINDS.includes(kind)) return problem(422, 'actor.trigger.kind', 'A trigger is a schedule, a Watch event, or a goal.');
+ if (kind !== 'event') {
if (!timezone || !cron.isZone(timezone)) return problem(422, 'actor.trigger.timezone', `Unknown time zone "${timezone}". Say the city or the IANA name.`);
if (!timezone || !cron.isZone(timezone)) return problem(422, 'actor.trigger.timezone', `Unknown time zone "${timezone}". Say the city or the IANA name.`);
const verdict = cron.validate(cronExpr, timezone);
const verdict = cron.validate(cronExpr, timezone);
if (!verdict.valid) return problem(422, 'actor.trigger.schedule', verdict.error);
if (!verdict.valid) return problem(422, 'actor.trigger.schedule', verdict.error);
@@ -125,11 +150,40 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -125,11 +150,40 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
if (!event || !EVENT_PATTERN.test(String(event.pattern || ''))) return problem(422, 'actor.trigger.event', 'An event source is an Events topic pattern such as github.issue.opened.');
if (!event || !EVENT_PATTERN.test(String(event.pattern || ''))) return problem(422, 'actor.trigger.event', 'An event source is an Events topic pattern such as github.issue.opened.');
if (event.filter != null && (typeof event.filter !== 'object' || Array.isArray(event.filter))) return problem(422, 'actor.trigger.event', 'event.filter must be an object of field values.');
if (event.filter != null && (typeof event.filter !== 'object' || Array.isArray(event.filter))) return problem(422, 'actor.trigger.event', 'event.filter must be an object of field values.');
}
}
+ if (kind === 'goal') {
+ const g = goal || {};
+ const text = String(g.text == null ? '' : g.text).trim();
+ const test = String(g.done_test == null ? '' : g.done_test).trim();
+ if (!text) return problem(422, 'actor.trigger.goal', 'Say what the goal is working toward, in a sentence.');
+ if (text.length > MAX_GOAL_TEXT) return problem(422, 'actor.trigger.goal', `The goal is at most ${MAX_GOAL_TEXT} characters; say it in a sentence.`);
+ if (!test) return problem(422, 'actor.trigger.done_test', 'A goal needs the check that ends it: what a run answers yes or no, such as "the price is under €120".');
+ if (test.length > MAX_DONE_TEST) return problem(422, 'actor.trigger.done_test', `The done-test is at most ${MAX_DONE_TEST} characters; say the check in one line.`);
+ if (g.runs_max != null) {
+ const n = Number(g.runs_max);
+ if (!Number.isInteger(n) || n < 1 || n > MAX_GOAL_RUNS) return problem(422, 'actor.trigger.goal', `The run limit is a whole number from 1 to ${MAX_GOAL_RUNS}.`);
+ }
+ if (g.budget_usd != null && !(Number(g.budget_usd) >= 0)) return problem(422, 'actor.trigger.goal', 'The money budget is a number of dollars, zero or more.');
+ if (g.deadline_at != null && !Number.isFinite(Date.parse(String(g.deadline_at)))) return problem(422, 'actor.trigger.goal', 'The deadline is a date, such as 2026-11-14T09:00:00Z.');
+ }
return null;
return null;
}
}
- async function create({ owner, agentId, task, kind, cron: cronExpr = null, timezone = null, event = null, level = null, turnTaskId = null, personToken = null, announce = true }) {
- const bad = validate({ owner, agentId, task, kind, cronExpr, timezone, event });
+ /** A goal's own fields: the text and the done-test as said, its limits and its deadline, defaulted. */
+ function goalRow(goal, at) {
+ const g = goal || {};
+ const perDay = Number(config && config.allowance && config.allowance.perDayUsd);
+ return {
+ text: String(g.text).trim().slice(0, MAX_GOAL_TEXT),
+ done_test: String(g.done_test).trim().slice(0, MAX_DONE_TEST),
+ runs_max: g.runs_max == null ? GOAL_RUNS : Number(g.runs_max),
+ // With no money budget of its own, a goal spends what the person's day allows and no more.
+ budget_usd: g.budget_usd == null ? (Number.isFinite(perDay) ? perDay : null) : Number(g.budget_usd),
+ deadline_at: g.deadline_at == null ? iso(at + GOAL_DAYS * 86_400_000) : new Date(Date.parse(String(g.deadline_at))).toISOString(),
+ };
+ }
+
+ async function create({ owner, agentId, task, kind, cron: cronExpr = null, timezone = null, event = null, goal = null, level = null, turnTaskId = null, personToken = null, announce = true, answerInChat = true, notify = false }) {
+ const bad = validate({ owner, agentId, task, kind, cronExpr, timezone, event, goal });
if (bad) return { error: bad };
if (bad) return { error: bad };
const agent = await agentOf(owner, agentId);
const agent = await agentOf(owner, agentId);
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents (GET /api/v1/agents).') };
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents (GET /api/v1/agents).') };
@@ -138,14 +192,16 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -138,14 +192,16 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
const active = await store.countActive(s, owner);
const active = await store.countActive(s, owner);
if (active >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one, or delete one, before making another.`) };
if (active >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one, or delete one, before making another.`) };
const at = nowMs();
const at = nowMs();
+ const scheduled = kind !== 'event';
const row = await store.insert(s, {
const row = await store.insert(s, {
id: s.newId('trg'), owner, agent_id: agent.id, task: String(task).trim(), kind,
id: s.newId('trg'), owner, agent_id: agent.id, task: String(task).trim(), kind,
- schedule: kind === 'schedule' ? { cron: cronExpr, timezone } : null,
+ schedule: scheduled ? { cron: cronExpr, timezone } : null,
event: kind === 'event' ? { pattern: event.pattern, filter: event.filter || null } : null,
event: kind === 'event' ? { pattern: event.pattern, filter: event.filter || null } : null,
- timezone: kind === 'schedule' ? timezone : null,
- words: kind === 'schedule' ? parse.wordsFor(cronExpr, timezone) : (event.words || `When ${event.pattern}`),
+ goal: kind === 'goal' ? goalRow(goal, at) : null,
+ timezone: scheduled ? timezone : null,
+ words: scheduled ? parse.wordsFor(cronExpr, timezone) : (event.words || `When ${event.pattern}`),
level: levels.LEVELS.includes(level) ? level : 'ask',
level: levels.LEVELS.includes(level) ? level : 'ask',
- state: 'paused', turn_task_id: turnTaskId, created_at: at,
+ state: 'paused', turn_task_id: turnTaskId, answer_in_chat: answerInChat !== false, notify: notify === true, created_at: at,
});
});
let made = null;
let made = null;
try {
try {
@@ -162,9 +218,9 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -162,9 +218,9 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
const created = await client.create(personToken, body);
const created = await client.create(personToken, body);
if (!created.ok) throw created;
if (!created.ok) throw created;
made = created.watch;
made = created.watch;
- if (kind === 'schedule') {
- const scheduled = await client.setSchedule(personToken, made.id, cronExpr, timezone);
- if (!scheduled.ok) throw scheduled;
+ if (scheduled) {
+ const withSchedule = await client.setSchedule(personToken, made.id, cronExpr, timezone);
+ if (!withSchedule.ok) throw withSchedule;
}
}
const linked = await store.update(s, owner, row.id, { watch_id: made.id, updated_at: nowMs() });
const linked = await store.update(s, owner, row.id, { watch_id: made.id, updated_at: nowMs() });
const resumed = await client.resume(personToken, made.id);
const resumed = await client.resume(personToken, made.id);
@@ -181,32 +237,108 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -181,32 +237,108 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
}
}
}
}
+ /**
+ * One routine run's answer, posted into the conversation that made the trigger, under its card
+ * (plan HO4): the answer with the addresses it read and a link to the run, or — when the run did
+ * not finish — the one line that says why. The routine's own switch decides (default on).
+ */
+ async function postRun(row, task, mapped) {
+ if (!row || !row.turn_task_id || row.answer_in_chat === false) return;
+ if (!TASK_ID.test(String(row.turn_task_id))) return;
+ const result = tasks.parseJson(task.result) || {};
+ const at = iso(nowMs());
+ const ok = mapped === 'ok';
+ const text = ok
+ ? clip(result.answer || '', 8000)
+ : mapped === 'needs_you'
+ ? 'That run needs you: open it and answer, and the routine carries on.'
+ : `That run did not finish: ${clip(task.error_detail || task.error_code || 'Actor did not say why', 400)}.`;
+ const sources = ok && Array.isArray(result.sources)
+ ? result.sources.filter((u) => typeof u === 'string' && u).slice(0, 8).map((u) => u.slice(0, 4000))
+ : [];
+ try {
+ const event = {
+ at, kind: 'output', step: 'trigger-run', agent: 'openvibe-runtime',
+ trigger_id: row.id,
+ run: { task_id: task.id, state: ok ? 'ok' : (mapped === 'needs_you' ? 'needs_you' : 'failed'), at },
+ text,
+ ...(sources.length ? { sources } : {}),
+ };
+ const saved = await tasks.appendEvent(s, row.turn_task_id, event);
+ if (stream && typeof stream.publish === 'function') stream.publish(row.turn_task_id, saved);
+ } catch (err) {
+ log.warn && log.warn('[Actor] routine run post:', row.id, (err && err.message) || err);
+ }
+ }
+
+ /**
+ * A goal ends itself (plan HO3): its done-test passed, or its runs, its money or its deadline is
+ * spent. It says so in the thread, and the watch is paused with the person's token when the
+ * caller has one; without one, Watch stops on the next firing (Actor answers it with
+ * actor.trigger.paused / actor.trigger.done).
+ */
+ async function endGoal(row, { reason, line = null, token = null } = {}) {
+ if (!row || row.state !== 'active') return row;
+ const updated = await store.update(s, row.owner, row.id, {
+ state: reason === 'done' ? 'done' : 'paused',
+ paused_reason: reason,
+ ended_at: nowMs(), updated_at: nowMs(),
+ });
+ if (line) await emit(updated, line);
+ if (token && row.watch_id) await client.pause(token, row.watch_id);
+ return updated;
+ }
+
+ /** A goal whose deadline has passed is over, even between runs. Read lazily wherever the card is. */
+ async function expire(row, { token = null } = {}) {
+ if (!row || row.kind !== 'goal' || row.state !== 'active' || !row.goal) return row;
+ const deadline = row.goal.deadline_at ? Date.parse(row.goal.deadline_at) : NaN;
+ if (!Number.isFinite(deadline) || nowMs() < deadline) return row;
+ const line = `Stopping here: ${iso(deadline).slice(0, 10)} passed and the goal is not done.${row.verdict && row.verdict.reason ? ` Last run: ${clip(row.verdict.reason, 200)}.` : ''}`;
+ return await endGoal(row, { reason: 'deadline', line, token });
+ }
+
async function get(owner, id, { token = null, live = false } = {}) {
async function get(owner, id, { token = null, live = false } = {}) {
if (!ID.test(String(id || ''))) return null;
if (!ID.test(String(id || ''))) return null;
- const row = await store.get(s, owner, id);
- if (!row) return null;
+ const found = await store.get(s, owner, id);
+ if (!found) return null;
+ const row = await expire(found, { token });
return await card(row, { token, live });
return await card(row, { token, live });
}
}
async function list(owner) {
async function list(owner) {
const rows = await store.list(s, owner);
const rows = await store.list(s, owner);
- return await Promise.all(rows.map((row) => card(row)));
+ const live = [];
+ for (const row of rows) live.push(await expire(row));
+ return await Promise.all(live.map((row) => card(row)));
}
}
async function edit(owner, id, patch = {}, { token = null } = {}) {
async function edit(owner, id, patch = {}, { token = null } = {}) {
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
+ // The routine's two switches (HO4) travel through the same PATCH: they are the person's own settings, so
+ // they need no Watch subscription change and no person token.
+ const switches = {};
+ if (patch.answer_in_chat !== undefined) switches.answerInChat = patch.answer_in_chat !== false;
+ if (patch.notify !== undefined) switches.notify = patch.notify === true;
+ if (Object.keys(switches).length) {
+ const onlySwitches = Object.keys(patch).every((k) => k === 'answer_in_chat' || k === 'notify');
+ const doneSwitches = await setSwitch(owner, row.id, switches);
+ if (doneSwitches.error || onlySwitches) return doneSwitches;
+ }
if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: changing a trigger uses your own OpenVibe session.') };
if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: changing a trigger uses your own OpenVibe session.') };
const next = {
const next = {
agent_id: patch.agent_id !== undefined ? patch.agent_id : row.agent_id,
agent_id: patch.agent_id !== undefined ? patch.agent_id : row.agent_id,
task: patch.task !== undefined ? String(patch.task).trim() : row.task,
task: patch.task !== undefined ? String(patch.task).trim() : row.task,
- kind: patch.kind !== undefined ? patch.kind : row.kind,
+ // A goal stays a goal: what it works toward and the check that ends it are its kind's own fields.
+ kind: row.kind === 'goal' ? 'goal' : (patch.kind !== undefined ? patch.kind : row.kind),
cron: patch.cron !== undefined ? patch.cron : (row.schedule && row.schedule.cron),
cron: patch.cron !== undefined ? patch.cron : (row.schedule && row.schedule.cron),
timezone: patch.timezone !== undefined ? patch.timezone : (row.timezone || (row.schedule && row.schedule.timezone) || 'UTC'),
timezone: patch.timezone !== undefined ? patch.timezone : (row.timezone || (row.schedule && row.schedule.timezone) || 'UTC'),
event: patch.event !== undefined ? patch.event : row.event,
event: patch.event !== undefined ? patch.event : row.event,
+ goal: row.kind === 'goal' && patch.goal && typeof patch.goal === 'object' ? { ...row.goal, ...patch.goal } : row.goal,
level: row.level,
level: row.level,
};
};
- const bad = validate({ owner, agentId: next.agent_id, task: next.task, kind: next.kind, cronExpr: next.cron, timezone: next.timezone, event: next.event });
+ const bad = validate({ owner, agentId: next.agent_id, task: next.task, kind: next.kind, cronExpr: next.cron, timezone: next.timezone, event: next.event, goal: next.goal });
if (bad) return { error: bad };
if (bad) return { error: bad };
const agent = await agentOf(owner, next.agent_id);
const agent = await agentOf(owner, next.agent_id);
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents.') };
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'agent_id names one of your own agents.') };
@@ -221,22 +353,38 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -221,22 +353,38 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
const patched = await client.patch(token, row.watch_id, changes);
const patched = await client.patch(token, row.watch_id, changes);
if (!patched.ok) return { error: problem(patched.status || 502, patched.code || 'actor.trigger.watch_failed', patched.detail || 'OpenVibe.Watch refused the change.') };
if (!patched.ok) return { error: problem(patched.status || 502, patched.code || 'actor.trigger.watch_failed', patched.detail || 'OpenVibe.Watch refused the change.') };
}
}
- if (next.kind === 'schedule' && (patch.cron !== undefined || patch.timezone !== undefined || patch.kind !== undefined)) {
- const scheduled = await client.setSchedule(token, row.watch_id, next.cron, next.timezone);
- if (!scheduled.ok) return { error: problem(scheduled.status || 502, scheduled.code || 'actor.trigger.watch_failed', scheduled.detail || 'OpenVibe.Watch refused the schedule.') };
+ if (next.kind !== 'event' && (patch.cron !== undefined || patch.timezone !== undefined || patch.kind !== undefined)) {
+ const rescheduled = await client.setSchedule(token, row.watch_id, next.cron, next.timezone);
+ if (!rescheduled.ok) return { error: problem(rescheduled.status || 502, rescheduled.code || 'actor.trigger.watch_failed', rescheduled.detail || 'OpenVibe.Watch refused the schedule.') };
}
}
const updated = await store.update(s, owner, row.id, {
const updated = await store.update(s, owner, row.id, {
task: next.task, agent_id: next.agent_id, kind: next.kind,
task: next.task, agent_id: next.agent_id, kind: next.kind,
- schedule: next.kind === 'schedule' ? { cron: next.cron, timezone: next.timezone } : row.schedule,
+ schedule: next.kind !== 'event' ? { cron: next.cron, timezone: next.timezone } : row.schedule,
event: next.kind === 'event' ? { pattern: next.event.pattern, filter: next.event.filter || null } : row.event,
event: next.kind === 'event' ? { pattern: next.event.pattern, filter: next.event.filter || null } : row.event,
- timezone: next.kind === 'schedule' ? next.timezone : null,
- words: next.kind === 'schedule' ? parse.wordsFor(next.cron, next.timezone) : (next.event.words || row.words),
+ goal: next.goal,
+ timezone: next.kind !== 'event' ? next.timezone : null,
+ words: next.kind !== 'event' ? parse.wordsFor(next.cron, next.timezone) : (next.event.words || row.words),
updated_at: nowMs(),
updated_at: nowMs(),
});
});
await emit(updated, `Trigger edited: ${updated.words}.`);
await emit(updated, `Trigger edited: ${updated.words}.`);
return { trigger: await card(updated) };
return { trigger: await card(updated) };
}
}
+ /**
+ * The routine's own two switches (HO4): answer in this chat (default on) and also tell me through
+ * OpenVibe's notifications (default off, because the answer in the chat is already the telling).
+ */
+ async function setSwitch(owner, id, { answerInChat = null, notify = null } = {}) {
+ const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
+ if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
+ const patch = { updated_at: nowMs() };
+ if (answerInChat != null) patch.answer_in_chat = answerInChat !== false;
+ if (notify != null) patch.notify = notify === true;
+ if (Object.keys(patch).length === 1) return { error: problem(400, 'actor.trigger.nothing', 'Say answer_in_chat or notify.') };
+ const updated = await store.update(s, owner, row.id, patch);
+ return { trigger: await card(updated) };
+ }
+
async function pause(owner, id, { token = null } = {}) {
async function pause(owner, id, { token = null } = {}) {
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
@@ -254,6 +402,7 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -254,6 +402,7 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
async function resume(owner, id, { token = null } = {}) {
async function resume(owner, id, { token = null } = {}) {
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
+ if (row.state === 'done') return { error: problem(409, 'actor.trigger.done', 'This goal reached its check and is finished. Make a new one if you want it to keep going.') };
if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: resuming a trigger uses your own OpenVibe session.') };
if (!token) return { error: problem(401, 'actor.trigger.sign_in', 'Sign in again: resuming a trigger uses your own OpenVibe session.') };
if (await store.countActive(s, owner) >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one before resuming another.`) };
if (await store.countActive(s, owner) >= MAX_ACTIVE) return { error: problem(409, 'actor.trigger.limit', `You already have ${MAX_ACTIVE} active triggers. Pause one before resuming another.`) };
if (row.watch_id) {
if (row.watch_id) {
@@ -293,12 +442,17 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -293,12 +442,17 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
}
}
async function runNow(owner, id) {
async function runNow(owner, id) {
- const row = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
- if (!row) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
+ const found = ID.test(String(id || '')) ? await store.get(s, owner, id) : null;
+ if (!found) return { error: problem(404, 'actor.trigger.not_found', 'No such trigger.') };
+ const row = await expire(found);
+ if (row.state === 'done') return { error: problem(409, 'actor.trigger.done', 'This goal is done. Make a new one if you want it to keep going.') };
+ // A goal that stopped for its budget or its deadline does not run again by accident: Resume (after raising
+ // its limit or its deadline) is the person's deliberate move.
+ if (row.state === 'paused' && row.kind === 'goal') return { error: problem(409, 'actor.trigger.paused', 'This goal has stopped. Resume it, or make a new one.') };
const agent = await agentOf(owner, row.agent_id);
const agent = await agentOf(owner, row.agent_id);
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'The agent this trigger runs is gone. Pick another agent first.') };
if (!agent) return { error: problem(422, 'actor.agent.not_found', 'The agent this trigger runs is gone. Pick another agent first.') };
if (!row.watch_id) return { error: problem(409, 'actor.trigger.no_watch', 'This trigger has no subscription. Delete it and make a new one.') };
if (!row.watch_id) return { error: problem(409, 'actor.trigger.no_watch', 'This trigger has no subscription. Delete it and make a new one.') };
- const out = await personal.run({ s, config, engine, webhooks }, agent, row.task, { origin: 'routine', level: row.level, triggerId: row.id });
+ const out = await personal.run({ s, config, engine, webhooks }, agent, row.task, { origin: 'routine', level: row.level, triggerId: row.id, goal: row.goal });
if (out && out.code) return { error: problem(out.status || 422, out.code, out.detail || 'The run could not start.') };
if (out && out.code) return { error: problem(out.status || 422, out.code, out.detail || 'The run could not start.') };
await emit(row, `Run now: ${row.task}.`);
await emit(row, `Run now: ${row.task}.`);
return { task: out.task, trigger: await card(await store.get(s, owner, row.id)) };
return { task: out.task, trigger: await card(await store.get(s, owner, row.id)) };
@@ -307,45 +461,80 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
@@ -307,45 +461,80 @@ function createTriggers({ s, config, engine = null, webhooks = null, stream = nu
/** Resolve the trigger a Watch delivery is for, by its id or the subscription's id. */
/** Resolve the trigger a Watch delivery is for, by its id or the subscription's id. */
async function forDelivery(owner, { triggerId = null, watchId = null } = {}) {
async function forDelivery(owner, { triggerId = null, watchId = null } = {}) {
const subject = subjectOf(owner);
const subject = subjectOf(owner);
- if (triggerId && ID.test(String(triggerId))) {
- const row = await store.get(s, subject, triggerId);
- if (row) return row;
- }
- if (watchId && WATCH_ID.test(String(watchId))) return await store.byWatch(s, subject, watchId);
- return null;
+ let row = null;
+ if (triggerId && ID.test(String(triggerId))) row = await store.get(s, subject, triggerId);
+ if (!row && watchId && WATCH_ID.test(String(watchId))) row = await store.byWatch(s, subject, watchId);
+ if (!row) return null;
+ // A goal whose deadline passed is over before this delivery runs anything.
+ return await expire(row);
}
}
/**
/**
* A task moved (the engine calls this after every state change). A failed run grows the streak; five in a
* A task moved (the engine calls this after every state change). A failed run grows the streak; five in a
* row pause the trigger and say so on the card. A success clears it. A run waiting for the person is shown
* row pause the trigger and say so on the card. A success clears it. A run waiting for the person is shown
* as needs_you and is not a failure.
* as needs_you and is not a failure.
+ *
+ * Plan HO4: a run that finished posts its answer into the conversation that made the trigger, under its
+ * card. Plan HO3: a goal counts the run, records its verdict, and ends itself when the done-test passed
+ * or its runs, money or deadline is spent.
*/
*/
async function taskState(task) {
async function taskState(task) {
if (!task || !task.id) return null;
if (!task || !task.id) return null;
const run = await store.runForTask(s, task.id);
const run = await store.runForTask(s, task.id);
if (!run) return null;
if (!run) return null;
- const row = await s.db.maybe('SELECT * FROM actor_triggers WHERE id = $1', [run.trigger_id]);
- if (!row || row.state === 'deleted') return null;
+ const found = await s.db.maybe('SELECT * FROM actor_triggers WHERE id = $1', [run.trigger_id]);
+ if (!found || found.state === 'deleted') return null;
+ const row = store.wire(found);
const mapped = task.state === 'succeeded' ? 'ok'
const mapped = task.state === 'succeeded' ? 'ok'
: task.state === 'failed' ? 'failed'
: task.state === 'failed' ? 'failed'
: task.state === 'needs_you' ? 'needs_you'
: task.state === 'needs_you' ? 'needs_you'
: task.state === 'cancelled' ? 'failed' : 'running';
: task.state === 'cancelled' ? 'failed' : 'running';
if (run.state === mapped) return row;
if (run.state === mapped) return row;
- await store.addRun(s, run.trigger_id, task.id, mapped, nowMs());
+ const cost = Number(task.cost_usd) || 0;
+ await store.addRun(s, run.trigger_id, task.id, mapped, nowMs(), cost);
if (mapped === 'running') return row;
if (mapped === 'running') return row;
- let failures = Number(row.failures) || 0;
+ let failures = Number(found.failures) || 0;
if (mapped === 'failed' && task.state !== 'cancelled') failures += 1;
if (mapped === 'failed' && task.state !== 'cancelled') failures += 1;
if (mapped === 'ok') failures = 0;
if (mapped === 'ok') failures = 0;
const patch = {
const patch = {
last_task_id: task.id, last_state: mapped, last_run_at: nowMs(), failures, updated_at: nowMs(),
last_task_id: task.id, last_state: mapped, last_run_at: nowMs(), failures, updated_at: nowMs(),
...(failures >= 5 ? { state: 'paused', paused_reason: 'failed' } : {}),
...(failures >= 5 ? { state: 'paused', paused_reason: 'failed' } : {}),
};
};
+
+ // The answer in the thread comes first, so a goal's closing line reads after the run it ends.
+ await postRun(row, task, mapped);
+
+ let ended = null;
+ if (row.kind === 'goal' && (mapped === 'ok' || mapped === 'failed')) {
+ const result = tasks.parseJson(task.result) || {};
+ const verdict = mapped === 'ok' ? goalLib.verdictOf(result.answer) : { done: null, reason: '' };
+ patch.runs_used = (Number(row.runs_used) || 0) + 1;
+ patch.spent_usd = usd((Number(row.spent_usd) || 0) + cost);
+ if (verdict.done != null) patch.verdict = { done: verdict.done, reason: verdict.reason, at: iso(nowMs()) };
+ if (row.state === 'active') {
+ ended = goalLib.endingOf({ goal: row.goal, verdict, runsUsed: patch.runs_used, spentUsd: patch.spent_usd, at: nowMs() });
+ if (ended) Object.assign(patch, { state: ended.reason === 'done' ? 'done' : 'paused', paused_reason: ended.reason, ended_at: nowMs() });
+ }
+ }
const updated = await store.update(s, row.owner, row.id, patch);
const updated = await store.update(s, row.owner, row.id, patch);
if (failures >= 5) await emit(updated, `Paused after 5 failed runs in a row: ${row.task}.`);
if (failures >= 5) await emit(updated, `Paused after 5 failed runs in a row: ${row.task}.`);
+ if (ended) await emit(updated, ended.line);
return updated;
return updated;
}
}
- return { create, get, list, edit, pause, resume, undo, remove, runNow, forDelivery, taskState, card, MAX_ACTIVE, UNDO_MS };
+ /**
+ * Whether the engine should tell the person itself (its one-per-task-per-state notification), for a
+ * routine run. null when the task is not a routine run: the engine decides as it always has.
+ */
+ async function tellsPerson(task) {
+ if (!task || !task.id) return null;
+ const run = await store.runForTask(s, task.id);
+ if (!run) return null;
+ const found = await s.db.maybe('SELECT notify FROM actor_triggers WHERE id = $1', [run.trigger_id]);
+ return found ? found.notify === true : null;
+ }
+
+ return { create, get, list, edit, pause, resume, undo, remove, runNow, forDelivery, taskState, tellsPerson, setSwitch, card, expire, endGoal, MAX_ACTIVE, UNDO_MS };
}
}
module.exports = { createTriggers, MAX_ACTIVE, UNDO_MS };
module.exports = { createTriggers, MAX_ACTIVE, UNDO_MS };
@@ -13,6 +13,12 @@ function wire(row) {
@@ -13,6 +13,12 @@ function wire(row) {
kind: row.kind,
kind: row.kind,
schedule: parse(row.schedule),
schedule: parse(row.schedule),
event: parse(row.event),
event: parse(row.event),
+ goal: parse(row.goal),
+ verdict: parse(row.verdict),
+ runs_used: Number(row.runs_used) || 0,
+ spent_usd: Number(row.spent_usd) || 0,
+ answer_in_chat: row.answer_in_chat !== false,
+ notify: row.notify === true,
timezone: row.timezone || null,
timezone: row.timezone || null,
words: row.words,
words: row.words,
level: row.level || 'ask',
level: row.level || 'ask',
@@ -24,6 +30,7 @@ function wire(row) {
@@ -24,6 +30,7 @@ function wire(row) {
last_task_id: row.last_task_id || null,
last_task_id: row.last_task_id || null,
last_state: row.last_state || null,
last_state: row.last_state || null,
last_run_at: row.last_run_at == null ? null : Number(row.last_run_at),
last_run_at: row.last_run_at == null ? null : Number(row.last_run_at),
+ ended_at: row.ended_at == null ? null : Number(row.ended_at),
created_at: Number(row.created_at),
created_at: Number(row.created_at),
updated_at: Number(row.updated_at),
updated_at: Number(row.updated_at),
};
};
@@ -41,20 +48,22 @@ const countForAgent = async (s, owner, agentId) => Number(await s.db.value('SELE
@@ -41,20 +48,22 @@ const countForAgent = async (s, owner, agentId) => Number(await s.db.value('SELE
async function insert(s, rec) {
async function insert(s, rec) {
await s.db.query(`INSERT INTO actor_triggers
await s.db.query(`INSERT INTO actor_triggers
- (id, owner, agent_id, task, kind, schedule, event, timezone, words, level, state, paused_reason, failures, watch_id, turn_task_id, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9,$10,$11,$12,$13,$14,$15,$16,$16)`,
+ (id, owner, agent_id, task, kind, schedule, event, goal, timezone, words, level, state, paused_reason, failures, watch_id, turn_task_id, answer_in_chat, notify, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)`,
[rec.id, rec.owner, rec.agent_id, rec.task, rec.kind, rec.schedule ? JSON.stringify(rec.schedule) : null,
[rec.id, rec.owner, rec.agent_id, rec.task, rec.kind, rec.schedule ? JSON.stringify(rec.schedule) : null,
- rec.event ? JSON.stringify(rec.event) : null, rec.timezone || null, rec.words, rec.level || 'ask',
- rec.state || 'paused', rec.paused_reason || null, rec.failures || 0, rec.watch_id || null, rec.turn_task_id || null, rec.created_at]);
+ rec.event ? JSON.stringify(rec.event) : null, rec.goal ? JSON.stringify(rec.goal) : null,
+ rec.timezone || null, rec.words, rec.level || 'ask',
+ rec.state || 'paused', rec.paused_reason || null, rec.failures || 0, rec.watch_id || null, rec.turn_task_id || null,
+ rec.answer_in_chat !== false, rec.notify === true, rec.created_at]);
return await get(s, rec.owner, rec.id);
return await get(s, rec.owner, rec.id);
}
}
async function update(s, owner, id, patch) {
async function update(s, owner, id, patch) {
const cols = [];
const cols = [];
const args = [id, owner];
const args = [id, owner];
- const json = new Set(['schedule', 'event']);
+ const json = new Set(['schedule', 'event', 'goal', 'verdict']);
for (const [k, v] of Object.entries(patch)) {
for (const [k, v] of Object.entries(patch)) {
- if (!['agent_id', 'task', 'schedule', 'event', 'timezone', 'words', 'level', 'state', 'paused_reason', 'failures', 'watch_id', 'last_task_id', 'last_state', 'last_run_at', 'updated_at', 'deleted_at'].includes(k)) continue;
+ if (!['agent_id', 'task', 'schedule', 'event', 'goal', 'verdict', 'runs_used', 'spent_usd', 'answer_in_chat', 'notify', 'timezone', 'words', 'level', 'state', 'paused_reason', 'failures', 'watch_id', 'last_task_id', 'last_state', 'last_run_at', 'ended_at', 'updated_at', 'deleted_at'].includes(k)) continue;
args.push(json.has(k) && v != null ? JSON.stringify(v) : v);
args.push(json.has(k) && v != null ? JSON.stringify(v) : v);
cols.push(`${k} = $${args.length}${json.has(k) ? '::jsonb' : ''}`);
cols.push(`${k} = $${args.length}${json.has(k) ? '::jsonb' : ''}`);
}
}
@@ -63,10 +72,10 @@ async function update(s, owner, id, patch) {
@@ -63,10 +72,10 @@ async function update(s, owner, id, patch) {
return await get(s, owner, id);
return await get(s, owner, id);
}
}
-async function addRun(s, triggerId, taskId, state, at) {
- await s.db.query(`INSERT INTO actor_trigger_runs (trigger_id, task_id, state, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$4) ON CONFLICT (task_id) DO UPDATE SET state = EXCLUDED.state, updated_at = EXCLUDED.updated_at`,
- [triggerId, taskId, state, at]);
+async function addRun(s, triggerId, taskId, state, at, costUsd = null) {
+ await s.db.query(`INSERT INTO actor_trigger_runs (trigger_id, task_id, state, cost_usd, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$5) ON CONFLICT (task_id) DO UPDATE SET state = EXCLUDED.state, cost_usd = COALESCE(EXCLUDED.cost_usd, actor_trigger_runs.cost_usd), updated_at = EXCLUDED.updated_at`,
+ [triggerId, taskId, state, costUsd, at]);
}
}
const runForTask = (s, taskId) => s.db.maybe('SELECT * FROM actor_trigger_runs WHERE task_id = $1', [taskId]);
const runForTask = (s, taskId) => s.db.maybe('SELECT * FROM actor_trigger_runs WHERE task_id = $1', [taskId]);
const runs = (s, triggerId, limit = 20) => s.db.many('SELECT * FROM actor_trigger_runs WHERE trigger_id = $1 ORDER BY created_at DESC LIMIT $2', [triggerId, limit]);
const runs = (s, triggerId, limit = 20) => s.db.many('SELECT * FROM actor_trigger_runs WHERE trigger_id = $1 ORDER BY created_at DESC LIMIT $2', [triggerId, limit]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+/**
+ * A goal that keeps going (plan HO3): "keep checking the price of X every 30 minutes until it drops under €120".
+ * The sentence parses into a goal (or asks the one question when the check is not something a run can see), the
+ * goal's runs answer its done-test, and it ends itself — saying so in the conversation — when the test passes,
+ * when its runs or money are spent, and at its deadline. The card shows "Run 4 of at most 12 · not yet: €134".
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const contacts = require('../server/agents/contacts');
+const { parseRequest } = require('../server/triggers/parse');
+const { verdictOf, endingOf } = require('../server/triggers/goal');
+const { createRegistry } = require('../server/runtime/tools');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */
+async function watchStub() {
+ const calls = [];
+ const server = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const text = Buffer.concat(chunks).toString('utf8');
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
+ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });
+ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };
+ if (req.method === 'POST' && req.url === '/api/v1/watches') {
+ const id = ids.newId('watch');
+ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });
+ }
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });
+ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });
+ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });
+ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });
+ return send(404, { code: 'watch.not_found' });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const watch = await watchStub();
+ let answer = 'The price is €134.\n\nVERDICT: no — €134 on the maker\'s store';
+ const loop = { close: async () => {}, run: async () => ({ text: answer, sources: [], evidence: [], model: 'fake' }) };
+ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop });
+ try {
+ const ada = t.network.addUser('ada');
+ const owner = `user:${ada.subject}`;
+ const s = t.ctx.s;
+ const vibe = await contacts.ensureVibe(s, t.config, owner);
+ const as = (method, p, o = {}) => t.get(p, { as: ada, method, headers: SAME, ...o });
+ const json = (method, p, body) => as(method, p, { json: body });
+ const turn = async (said) => {
+ const made = await json('POST', '/api/v1/tasks', { task: said });
+ const id = (made.json().task && made.json().task.id) || made.json().id;
+ await t.waitFor(id);
+ return id;
+ };
+ const goal = async (extra = {}) => {
+ const made = await json('POST', '/api/v1/triggers', {
+ agent_id: vibe.id, task: 'check the price of the Framework 13', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',
+ goal: { text: 'the price of the Framework 13 drops under €120', done_test: 'the price is under €120', runs_max: 12 }, ...extra,
+ });
+ assert.strictEqual(made.status, 201, made.text);
+ return made.json().trigger;
+ };
+ const runIt = async (id) => {
+ const res = await as('POST', `/api/v1/triggers/${id}/run`);
+ if (res.status === 409) return { refused: res.json() };
+ assert.strictEqual(res.status, 201, res.text);
+ return await t.waitFor(res.json().task.id);
+ };
+ const fetchCard = async (id) => (await as('GET', `/api/v1/triggers/${id}`)).json().trigger;
+ const linesOf = async (turnId) => (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [turnId]))
+ .map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body));
+
+ await check('"until …" sentences become a goal, and a check no run can see is one question', async () => {
+ const price = parseRequest('keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me', { timezone: 'Europe/Berlin' });
+ assert.strictEqual(price.status, 'ok');
+ assert.strictEqual(price.kind, 'goal');
+ assert.strictEqual(price.cron, '*/30 * * * *');
+ assert.strictEqual(price.task, 'check the price of the Framework 13');
+ assert.strictEqual(price.goal.done_test, 'the price of the Framework 13 drops under €120');
+ assert.strictEqual(price.tell, true);
+
+ const tests = parseRequest('keep working on this until the tests pass', { timezone: 'Europe/Berlin' });
+ assert.strictEqual(tests.status, 'ok');
+ assert.strictEqual(tests.kind, 'goal');
+ assert.strictEqual(tests.cron, '*/15 * * * *', 'no interval given: every fifteen minutes, Watch\'s floor');
+ assert.strictEqual(tests.task, 'work on this');
+ assert.strictEqual(tests.goal.done_test, 'the tests pass');
+ assert.strictEqual(tests.tell, false);
+
+ const vague = parseRequest('keep checking the price every hour until I am happy', { timezone: 'Europe/Berlin' });
+ assert.strictEqual(vague.status, 'ask');
+ assert.match(vague.question, /how will i know it is done/i);
+
+ const noZone = parseRequest('keep checking the price until it drops under €120', {});
+ assert.strictEqual(noZone.status, 'ask');
+ assert.match(noZone.question, /timezone/i);
+
+ // A plain routine is still a routine.
+ assert.strictEqual(parseRequest('every weekday at 9, summarise my inbox', { timezone: 'Europe/Berlin' }).kind, 'schedule');
+ });
+
+ await check('the done-test is read out of the answer, and the goal\'s ends are its own limits', async () => {
+ assert.deepStrictEqual(verdictOf('€134 today.\n\nVERDICT: no — €134 on the maker\'s store'), { done: false, reason: '€134 on the maker\'s store', answer: '€134 today.' });
+ assert.deepStrictEqual(verdictOf('All good.\nVERDICT: yes — €112 shipped').done, true);
+ assert.strictEqual(verdictOf('I could not read the page.').done, null, 'a run that does not say is not a pass');
+ const g = { done_test: 'the price is under €120', runs_max: 12, budget_usd: 1.5, deadline_at: null };
+ assert.strictEqual(endingOf({ goal: g, verdict: { done: true, reason: '€112' }, runsUsed: 4, spentUsd: 0.02 }).reason, 'done');
+ assert.strictEqual(endingOf({ goal: g, verdict: { done: false }, runsUsed: 4, spentUsd: 0.02 }), null);
+ assert.strictEqual(endingOf({ goal: { ...g, runs_max: 4 }, verdict: { done: false }, runsUsed: 4, spentUsd: 0.02 }).reason, 'budget');
+ assert.strictEqual(endingOf({ goal: { ...g, runs_max: null, budget_usd: 0.5 }, verdict: { done: null }, runsUsed: 2, spentUsd: 0.6 }).reason, 'budget');
+ assert.strictEqual(endingOf({ goal: { ...g, budget_usd: null, deadline_at: '2020-01-01T00:00:00.000Z' }, verdict: { done: false }, runsUsed: 2, spentUsd: 0.1, at: Date.now() }).reason, 'deadline');
+ });
+
+ await check('the tool makes a goal from the sentence, with its limits and the ask to be told', async () => {
+ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
+ const events = [];
+ const ctx = {
+ config: t.config, requester: owner, store: s, triggers: t.ctx.triggers,
+ personToken: 'a-person-token', timezone: 'Europe/Berlin', agentId: vibe.id, taskId: null, level: 'ask',
+ emit: async (e) => events.push(e),
+ };
+ const out = await registry.run('schedule_trigger', { request: 'keep checking the price of the Framework 13 every 30 minutes until it drops under €120, then tell me' }, ctx);
+ assert.ok(!out.isError, out.text);
+ const made = events.find((e) => e.step === 'trigger').trigger;
+ assert.strictEqual(made.kind, 'goal');
+ assert.strictEqual(made.when.cron, '*/30 * * * *');
+ assert.strictEqual(made.goal.runs_used, 0);
+ assert.strictEqual(made.goal.runs_max, 12);
+ assert.ok(made.goal.budget_usd > 0, 'a money budget from the person\'s allowance');
+ assert.ok(made.goal.deadline_at, 'a deadline');
+ assert.strictEqual(made.notify, true, '"then tell me" turns the telling on');
+ assert.match(out.text, /It ends when the price of the Framework 13 drops under €120/);
+
+ const asked = await registry.run('schedule_trigger', { request: 'keep working on this until it is good' }, ctx);
+ assert.match(asked.text, /how will i know it is done/i);
+ });
+
+ await check('a goal ends itself when its done-test passes, and says so in the thread', async () => {
+ const turnId = await turn('keep checking the price of the Framework 13 until it drops under €120');
+ const made = await json('POST', '/api/v1/triggers', {
+ agent_id: vibe.id, task: 'check the price of the Framework 13', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',
+ goal: { text: 'the price drops under €120', done_test: 'the price is under €120' }, turn_task_id: turnId,
+ });
+ assert.strictEqual(made.status, 201, made.text);
+ const id = made.json().trigger.id;
+
+ answer = 'The price is €134.\n\nVERDICT: no — €134';
+ const first = await runIt(id);
+ assert.strictEqual(first.state, 'succeeded');
+ // The run was asked the goal and its done-test, so its answer carries a verdict Actor can read.
+ assert.ok(String(first.task).includes('This is one run of a goal the person set'), 'the goal is in the run');
+ assert.ok(String(first.task).includes('The goal is done only when this is true: "the price is under €120"'));
+ assert.ok(String(first.task).includes('VERDICT: yes'));
+ let card = await fetchCard(id);
+ assert.deepStrictEqual([card.state, card.goal.runs_used, card.goal.not_yet], ['active', 1, '€134']);
+
+ answer = 'It dropped: €112 shipped.\n\nVERDICT: yes — €112 shipped';
+ const second = await runIt(id);
+ assert.strictEqual(second.state, 'succeeded');
+ card = await fetchCard(id);
+ assert.deepStrictEqual([card.state, card.paused_reason, card.goal.runs_used], ['done', 'done', 2]);
+ assert.strictEqual(card.goal.verdict.done, true);
+
+ const lines = await linesOf(turnId);
+ assert.ok(lines.some((e) => e.step === 'trigger-run' && /It dropped: €112 shipped/.test(e.text)), 'the run\'s answer is in the thread');
+ assert.ok(lines.some((e) => e.step === 'trigger' && /Goal reached: the price is under €120 \(€112 shipped\)/.test(e.text)), 'and so is the goal saying it is done');
+ const page = await as('GET', `/chat/turn/${turnId}`);
+ assert.match(page.text, /class="ct-trig-state">Done</);
+ assert.match(page.text, /The goal is reached: the price is under €120\./);
+
+ // A finished goal does not run again, and cannot be resumed.
+ assert.strictEqual((await runIt(id)).refused.error !== undefined, true);
+ assert.strictEqual((await as('POST', `/api/v1/triggers/${id}/resume`)).status, 409);
+ });
+
+ await check('a goal ends itself when its runs are spent, and says so', async () => {
+ const turnId = await turn('keep checking the price until it drops under €120');
+ const made = await json('POST', '/api/v1/triggers', {
+ agent_id: vibe.id, task: 'check the price', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',
+ goal: { text: 'the price drops under €120', done_test: 'the price is under €120', runs_max: 2 }, turn_task_id: turnId,
+ });
+ const id = made.json().trigger.id;
+ answer = 'Still €134.\n\nVERDICT: no — €134';
+ await runIt(id);
+ let card = await fetchCard(id);
+ assert.deepStrictEqual([card.state, card.goal.runs_used], ['active', 1]);
+ const page = await as('GET', `/chat/turn/${turnId}`);
+ assert.match(page.text, /Run 1 of at most 2/);
+ assert.match(page.text, /not yet: €134/);
+
+ await runIt(id);
+ card = await fetchCard(id);
+ assert.deepStrictEqual([card.state, card.paused_reason, card.goal.runs_used], ['paused', 'budget', 2]);
+ const lines = await linesOf(turnId);
+ assert.ok(lines.some((e) => e.step === 'trigger' && /Stopping here: the goal has used its 2 runs and is not done/.test(e.text)));
+ assert.strictEqual((await runIt(id)).refused.code, 'actor.trigger.paused');
+ });
+
+ await check('a goal ends itself at its deadline, even between runs', async () => {
+ const turnId = await turn('keep checking the price until it drops under €120');
+ const made = await json('POST', '/api/v1/triggers', {
+ agent_id: vibe.id, task: 'check the price', kind: 'goal', cron: '*/30 * * * *', timezone: 'Europe/Berlin',
+ goal: { text: 'the price drops under €120', done_test: 'the price is under €120', deadline_at: '2020-01-01T00:00:00.000Z' }, turn_task_id: turnId,
+ });
+ const id = made.json().trigger.id;
+ // Nothing ran: the deadline passed, and reading the card is enough to end it.
+ const card = await fetchCard(id);
+ assert.deepStrictEqual([card.state, card.paused_reason], ['paused', 'deadline']);
+ const lines = await linesOf(turnId);
+ assert.ok(lines.some((e) => e.step === 'trigger' && /Stopping here: 2020-01-01 passed and the goal is not done/.test(e.text)));
+ assert.strictEqual((await runIt(id)).refused.code, 'actor.trigger.paused');
+ });
+
+ await check('one person\'s goal is another person\'s 404', async () => {
+ const made = await goal();
+ const bo = t.network.addUser('bo');
+ assert.strictEqual((await t.get(`/api/v1/triggers/${made.id}`, { as: bo })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/triggers/${made.id}/run`, { as: bo, method: 'POST', headers: SAME })).status, 404);
+ });
+ } finally {
+ await t.close();
+ await watch.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+/**
+ * A routine answers in its own thread (plan HO4): when a trigger's run finishes, its answer is posted into the
+ * conversation that made the trigger, under the routine's card, with the addresses it read and a link to the
+ * run. Switched off per routine, it posts nothing; a run that did not finish posts one line that says why. Only
+ * the person whose conversation it is sees the posts, and the person is told through notifications only when
+ * the routine asked for it.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const contacts = require('../server/agents/contacts');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */
+async function watchStub() {
+ const calls = [];
+ const server = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const text = Buffer.concat(chunks).toString('utf8');
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
+ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });
+ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };
+ if (req.method === 'POST' && req.url === '/api/v1/watches') {
+ const id = ids.newId('watch');
+ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });
+ }
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });
+ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });
+ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });
+ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });
+ return send(404, { code: 'watch.not_found' });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const watch = await watchStub();
+ // The run's own answer, which each check sets: the loop is the only model Actor talks to here.
+ let answer = 'Today your inbox has three things worth doing.';
+ let sources = ['https://mail.example.com/inbox'];
+ let fail = null;
+ const told = [];
+ const loop = {
+ close: async () => {},
+ run: async () => {
+ if (fail) throw fail;
+ return { text: answer, sources, evidence: [], model: 'fake' };
+ },
+ };
+ const notifier = { enabled: true, needsYou: async (n) => { told.push(n); return { sent: true }; }, taskEnded: async (n) => { told.push(n); return { sent: true }; } };
+ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop, notifier });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const s = t.ctx.s;
+ const vibe = await contacts.ensureVibe(s, t.config, owner);
+ const as = (u, method, p, o = {}) => t.get(p, { as: u, method, headers: SAME, ...o });
+ const json = (u, method, p, body) => as(u, method, p, { json: body });
+ const runsOf = async (turnId) => (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [turnId]))
+ .map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body))
+ .filter((e) => e.step === 'trigger-run');
+
+ // A turn that made a routine, as the chat does (the tool passes its own task id).
+ const turnId = async (said = 'every weekday at 9, summarise my inbox') => {
+ const made = await json(ada, 'POST', '/api/v1/tasks', { task: said });
+ assert.ok(made.status === 201 || made.status === 202, made.text);
+ const id = (made.json().task && made.json().task.id) || made.json().id;
+ await t.waitFor(id);
+ return id;
+ };
+ const routine = async (turn, extra = {}) => {
+ const made = await json(ada, 'POST', '/api/v1/triggers', {
+ agent_id: vibe.id, task: 'summarise my inbox', kind: 'schedule', cron: '0 9 * * 1-5', timezone: 'Europe/Berlin', turn_task_id: turn, ...extra,
+ });
+ assert.strictEqual(made.status, 201, made.text);
+ return made.json().trigger;
+ };
+ const runIt = async (id) => {
+ const res = await as(ada, 'POST', `/api/v1/triggers/${id}/run`);
+ assert.strictEqual(res.status, 201, res.text);
+ return await t.waitFor(res.json().task.id);
+ };
+
+ await check('a routine\'s run posts its answer under its card in the chat that made it', async () => {
+ const turn = await turnId();
+ const card = await routine(turn);
+ const task = await runIt(card.id);
+ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
+ const posts = await runsOf(turn);
+ assert.strictEqual(posts.length, 1, 'one post for one run');
+ assert.strictEqual(posts[0].trigger_id, card.id);
+ assert.strictEqual(posts[0].run.task_id, task.id);
+ assert.strictEqual(posts[0].run.state, 'ok');
+ assert.strictEqual(posts[0].text, answer);
+ assert.deepStrictEqual(posts[0].sources, sources);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes(`data-ct-trigger="${card.id}"`), 'the card is still there');
+ assert.ok(page.text.includes('Today your inbox has three things worth doing.'), 'the answer is in the chat');
+ assert.ok(page.text.includes('https://mail.example.com/inbox'), 'with the address it read');
+ assert.ok(page.text.includes(`/tasks/${task.id}`), 'and a link to the run');
+ assert.match(page.text, /class="ct-trig-run/);
+ });
+
+ await check('switched off per routine, a run posts nothing in the chat', async () => {
+ const turn = await turnId();
+ const card = await routine(turn);
+ // The card's own switch, as its form posts it.
+ const off = await as(ada, 'POST', `/chat/${turn}/trigger/${card.id}`, { form: { do: 'answer-off' } });
+ assert.strictEqual(off.status, 303, off.text);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.match(page.text, /Answer in this chat: off/);
+ const before = (await runsOf(turn)).length;
+ answer = 'This one stays out of the chat.';
+ await runIt(card.id);
+ assert.strictEqual((await runsOf(turn)).length, before, 'nothing was posted');
+ const after = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.ok(!after.text.includes('This one stays out of the chat.'));
+ // And back on: the API's switch, the same field the card uses.
+ const on = await json(ada, 'PATCH', `/api/v1/triggers/${card.id}`, { answer_in_chat: true });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.strictEqual(on.json().trigger.answer_in_chat, true);
+ answer = 'Back in the chat.';
+ await runIt(card.id);
+ assert.strictEqual((await runsOf(turn)).length, before + 1);
+ });
+
+ await check('a run that did not finish posts one line that says why', async () => {
+ const turn = await turnId();
+ const card = await routine(turn);
+ fail = Object.assign(new Error('the provider fell over'), { code: 'actor.provider.error', detail: 'OpenVibe.AI did not answer in time.' });
+ const task = await runIt(card.id);
+ fail = null;
+ assert.strictEqual(task.state, 'failed');
+ // The line is posted as the failed run settles, a beat after its state: wait for it (CI is slower).
+ let posts = [];
+ for (const end = Date.now() + 5000; Date.now() < end; await new Promise((r) => setTimeout(r, 50))) {
+ posts = await runsOf(turn);
+ if (posts.length) break;
+ }
+ assert.strictEqual(posts.length, 1);
+ assert.strictEqual(posts[0].run.state, 'failed');
+ assert.match(posts[0].text, /^That run did not finish: .*fell over/);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.ok(page.text.includes('That run did not finish'), 'the line is in the chat');
+ assert.match(page.text, /ct-trig-run is-bad/);
+ });
+
+ await check('another person never sees a routine\'s posts', async () => {
+ const turn = await turnId();
+ const card = await routine(turn);
+ answer = 'Only Ada may read this.';
+ const task = await runIt(card.id);
+ assert.strictEqual(task.state, 'succeeded');
+ const theirs = await as(bo, 'GET', `/chat/turn/${turn}`);
+ assert.ok(!theirs.text.includes('Only Ada may read this.'), 'a stranger is not shown it');
+ assert.ok(!theirs.text.includes(`data-ct-trigger="${card.id}"`));
+ });
+
+ await check('the person is told through notifications only when the routine asked', async () => {
+ const turn = await turnId();
+ const card = await routine(turn);
+ // The turn itself is a normal task and tells the person as any task does; count from here.
+ told.length = 0;
+ answer = 'Quiet run.';
+ await runIt(card.id);
+ assert.strictEqual(told.length, 0, 'the answer in the chat is the telling');
+ const on = await json(ada, 'PATCH', `/api/v1/triggers/${card.id}`, { notify: true });
+ assert.strictEqual(on.status, 200, on.text);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.match(page.text, /Tell me too: on/);
+ answer = 'Loud run.';
+ await runIt(card.id);
+ assert.strictEqual(told.length, 1, 'one notification for one run');
+ assert.strictEqual(told[0].state, 'succeeded');
+ assert.ok(String(told[0].url).includes('/tasks/'));
+ });
+
+ await check('a routine made in a turn with no card has nowhere to post, and says nothing', async () => {
+ // The API allows a trigger with no turn (a card made elsewhere): a run must not crash on it.
+ const made = await json(ada, 'POST', '/api/v1/triggers', { agent_id: vibe.id, task: 'no thread', kind: 'schedule', cron: '0 9 * * *', timezone: 'UTC' });
+ assert.strictEqual(made.status, 201, made.text);
+ const task = await runIt(made.json().trigger.id);
+ assert.strictEqual(task.state, 'succeeded');
+ });
+ } finally {
+ await t.close();
+ await watch.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });