openvibers/openvibe.actor Commits
HO7 an agent can spend the person's Vibes, always at their approval (#338)
Changes
nnew file mode 100644@@@ -0,0 +1,37 @@++-- phase: expand++-- HO7 (an agent that can spend): a weekly allowance per agent, in Vibes, and the payments made against it.++--++-- The allowance lives on the agent (spend_weekly_vibes): 0 means off, and an agent with no allowance is never offered++-- the spending tool at all. The payments are their own rows (actor_spends): one claim per approval, reserved before++-- Tips' checkout is called and settled when it answers, so the week can be counted and the person can see every tip++-- their agent sent. A payment Tips definitely refused is kept as a failed attempt: it never counts against the++-- allowance and never shows as a payment. A claim whose answer was unknown stays 'paying' and counts, because the++-- money may have moved; the same idempotency key retries it.++--++-- Expand-only: a new column with a default and a new table. Nothing renamed, nothing dropped, so the previous++-- release keeps writing rows and an old row reads as "no allowance".++ALTER TABLE actor_agents++ ADD COLUMN IF NOT EXISTS spend_weekly_vibes integer NOT NULL DEFAULT 0,++ ADD COLUMN IF NOT EXISTS spend_weekly_set_at text;++++CREATE TABLE IF NOT EXISTS actor_spends (++ id text COLLATE "C" PRIMARY KEY, -- spn_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_… the person whose Vibes paid++ agent_id text COLLATE "C" NOT NULL, -- agt_… the agent that asked++ approval_id text COLLATE "C", -- the approval it was paid at (the idempotency key)++ to_subject text COLLATE "C" NOT NULL, -- usr_… the creator who receives it++ to_label text COLLATE "C", -- what the person read on the card (a name or a handle)++ amount integer NOT NULL CHECK (amount > 0), -- Vibes (bits): whole Vibes, never a fraction++ reason text COLLATE "C", -- the person's own words for it++ target jsonb, -- what the money is for (a creator's page, a channel …)++ state text COLLATE "C" NOT NULL CHECK (state IN ('paying', 'paid', 'failed')),++ txn_id text COLLATE "C", -- Tips' interaction id for a paid tip++ error text COLLATE "C", -- why a refusal did not pay++ week_start text COLLATE "C" NOT NULL, -- the allowance week it counts in (a Monday, UTC)++ created_at text COLLATE "C" NOT NULL++);++-- One live claim per approval: a second click can never pay twice, even if two clicks race. A failed attempt is not++-- live, so the person may try again after topping up their Vibes.++CREATE UNIQUE INDEX IF NOT EXISTS actor_spends_approval ON actor_spends (approval_id) WHERE state IN ('paying', 'paid');++CREATE INDEX IF NOT EXISTS actor_spends_agent ON actor_spends (owner, agent_id, created_at DESC);++CREATE INDEX IF NOT EXISTS actor_spends_week ON actor_spends (owner, agent_id, week_start) WHERE state = 'paid';@@@ -121,11 +121,16 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =function approvalItem(row, { agentName = null, thread = null } = {}) {const bucket = bucketFor(row.state);const payload = parse(row.payload) || {};++ // A payment (plan HO7): once approved, the item says what was paid and who received it, not what it would do.++ // A pending tip keeps the ordinary words: nothing has been paid yet.++ const money = row.kind === 'money' && row.state === 'approved' && payload && payload.spend === 'tip' && payload.amount++ ? `Paid ${Number(payload.amount)} Vibes to ${payload.to_label || payload.to || 'a creator'}`++ : null;// The kind in plain words and the thread it came from (plan HO1): a raw agent id says nothing to a person,// so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own// (an old row) keeps its own words.const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);-- const about = [kinds.label(row.kind), detail].filter(Boolean).join(' · ');++ const about = money || [kinds.label(row.kind), detail].filter(Boolean).join(' · ');return {service: 'actor',id: row.id,@@@ -249,6 +249,26 @@ function stepFor(name, input) {};}++ // ── Money: a tip to a creator, with the person's own Vibes (plan HO7) ──++ case 'spend_vibes': {++ const to = text(a.to, 120);++ const amount = Number(a.amount);++ const reason = text(a.reason, 300);++ const vibes = Number.isInteger(amount) && amount > 0 ? amount : 0;++ return {++ act: 'act',++ kind: 'money',++ // The floor: money always asks, at every level, and is never always allowed (agents/kinds.js).++ category: 'money',++ // The marker that says this money is a tip (plan HO7) and not some other money step: only a tip is++ // prepared, capped and paid at the person's approval.++ spend: true,++ summary: `Tip ${vibes} Vibes to ${to || 'a creator'}${reason ? `: ${reason}` : ''}`,++ target: to ? exactTarget(`tip ${vibes} Vibes to ${word(to)}`) : null,++ payload: { to, amount: vibes, reason },++ };++ }++// ── Actor's own working state: never asks ──case 'make_file':case 'make_chart':@@@ -24,6 +24,7 @@ const { createTask } = require('../tasks/create');const levels = require('./levels');const kinds = require('./kinds');const allows = require('./allows');++const spend = require('./spend');const KINDS = kinds.KINDS;const EXPIRES_DAYS = 7;@@@ -85,8 +86,12 @@ function floorOf(row) {* refused for a floor step (money, a password, a deletion, a new recipient), which asks every time.* With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON* Action Center route); the HTML page keeps its 409.++ *++ * A payment (plan HO7, the kind 'money') is decided differently: the money moves now, with the person's own token,++ * and only from the person's own session. A service that decides for them (the Action Center) never pays: there is++ * no person behind the service token, and the approval stays pending until the person approves it themselves.*/--async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {++async function decide(ctx, owner, id, decision, { idempotent = false, personToken = null } = {}) {const { s, config, engine, webhooks } = ctx;const row = await get(s, owner, id);if (!row) return { error: 'No such request of yours.', status: 404 };@@@ -99,6 +104,13 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {return { row: done };}if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };++ // A tip (plan HO7): the person's own approval pays, now. Any other money step (a payment tool reached through++ // OpenVibe.Tools) keeps the old behaviour: it asks, and approving it means the person runs it again.++ const money = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : (row.payload || {});++ if (row.kind === 'money' && money.spend === 'tip') {++ if (decision === 'approve_always') return { error: 'This step always asks, so it can never be always allowed.', status: 422 };++ return await pay(ctx, row, { personToken });++ }const always = decision === 'approve_always';if (always) {const floored = floorOf(row);@@@ -142,4 +154,70 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };}--module.exports = { request, list, get, pendingCount, decide, floorOf, KINDS, EXPIRES_DAYS };++/**++ * A payment, at the person's own approval (plan HO7). The money moves through OpenVibe.Tips' checkout with the++ * person's own token: never a service token, never later from a worker. The approval id is the idempotency key, so a++ * second click (or two clicks at once) never pays twice. A definite refusal from Tips shows its reason and leaves the++ * approval pending, so the person can top up their Vibes and try again. An unknown answer keeps the claim 'paying':++ * the money may have moved, so it still counts against the week, and the retry after the stale window sends the same++ * key, which Tips replays.++ */++async function pay(ctx, row, { personToken = null } = {}) {++ const { s, tips } = ctx;++ const owner = row.owner;++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : (row.payload || {});++ const amount = Number(payload.amount);++ const to = String(payload.to || '');++ const agentId = row.agent_id || '';++ // Only the person may pay, with their own session. A service deciding for them never moves money.++ if (!personToken) {++ return { error: 'A payment is paid by you, on this page, with your own Vibes. Nothing was paid.', status: 409, pending: true };++ }++ if (!tips || typeof tips.tip !== 'function') {++ return { error: 'OpenVibe.Tips is not connected on this server, so nothing was paid.', status: 503, pending: true };++ }++ // Reserve the payment first (one claim per approval, one weekly cap per agent), then move the money with the++ // person's own token. The approval id is Tips' idempotency key, so a retry replays instead of paying twice.++ const reserved = await spend.claim(s, {++ owner, agentId, approvalId: row.id, to, toLabel: payload.to_label || null, amount,++ reason: payload.reason || null, target: payload.target || null,++ });++ if (reserved.paid) return { row: await approvePaid(s, owner, row), spend: reserved.paid, note: 'This tip was already paid.' };++ if (reserved.busy) return { error: 'This tip is already being paid. Refresh this page in a moment.', status: 409, pending: true };++ if (reserved.error) return { error: reserved.error, status: reserved.status || 422, pending: true };++ const out = await tips.tip({++ token: personToken, to, amount,++ message: payload.reason || null,++ key: row.id,++ });++ if (!out.ok) {++ if (out.unknown) {++ // The money may have moved: keep the claim 'paying'. It counts against the week, the card stays pending,++ // and the retry after the stale window sends the same Idempotency-Key, which Tips replays.++ return { error: out.error, status: 502, pending: true, unconfirmed: true };++ }++ await spend.fail(s, reserved.claim.id, out.error).catch(() => null);++ const status = [409, 422].includes(out.status) ? out.status : 502;++ return { error: out.error, status, pending: true };++ }++ const paid = await spend.settle(s, reserved.claim.id, out.id || null);++ if (!paid) {++ const recorded = await spend.paidFor(s, owner, row.id);++ if (!recorded) {++ return {++ error: 'OpenVibe.Tips paid the tip, but Actor could not record it. Reload this page; nothing will be paid twice.',++ status: 503, pending: true,++ };++ }++ return { row: await approvePaid(s, owner, row), spend: recorded, note: `Paid ${amount} Vibes to ${payload.to_label || to}.` };++ }++ return { row: await approvePaid(s, owner, row), spend: paid, note: `Paid ${amount} Vibes to ${payload.to_label || to}.` };++}++++/** Mark the approval approved, once; a concurrent click that already did answers the same row. */++async function approvePaid(s, owner, row) {++ return await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = 'once' WHERE id = $1 AND state = 'pending' RETURNING *", [row.id, s.iso(), owner])++ || await get(s, owner, row.id) || row;++}++++module.exports = { request, list, get, pendingCount, decide, pay, floorOf, KINDS, EXPIRES_DAYS };nnew file mode 100644@@@ -0,0 +1,326 @@++'use strict';++++/**++ * An agent's money (plan HO7, "an agent that can spend"): a weekly allowance in Vibes per agent, and the payments++ * made against it.++ *++ * allowance(s, owner, agentId) → { agent_id, weekly, spent, left, week }: what it may spend this week++ * setAllowance(s, owner, agentId, amount) → the person's own setting (0 = off: the tool is not offered at all)++ * check(s, { owner, agentId, amount }) → { ok, message }: an ask over what is left is refused with one plain line++ * claim(s, ...) / settle(s, id, txnId) / fail(s, id, error)++ * → the payment row, reserved before Tips is called, then settled or refused++ * resolve(config, to) → the creator a tip names: a person (usr_…) or a creator's OpenVibe page++ * listFor(s, owner, ...) → the payments and refused attempts (newest first)++ *++ * The money itself moves in OpenVibe.Tips (its checkout, which makes the Billing transfer): the person approves, and++ * the payment is made with THEIR token, at that moment, once (the approval id is the idempotency key). Nothing in++ * this file ever moves money by itself: an agent can only ask, and only the person's own approval pays.++ *++ * Vibes are whole (Billing's bits, one bit to the cent): an allowance and a tip are integers, never fractions.++ */++++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++// A week's allowance in Vibes: 100,000 Vibes is 1,000 USD of creator value at Billing's rate (100 bits to the dollar).++const MAX_ALLOWANCE = 100_000;++// The smallest tip that means anything: one Vibe.++const MIN_TIP = 1;++++const text = (v, n = 200) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);++const int = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : null; };++++/**++ * The allowance week: Monday 00:00 UTC to Sunday 23:59 UTC. The key is the Monday's date, so every row of a week++ * counts in the same week and the person reads one plain date range.++ */++function weekStart(at = new Date()) {++ const d = new Date(at);++ if (!Number.isFinite(d.getTime())) return new Date().toISOString().slice(0, 10);++ const day = (d.getUTCDay() + 6) % 7; // Monday is the first day of the week++ d.setUTCDate(d.getUTCDate() - day);++ d.setUTCHours(0, 0, 0, 0);++ return d.toISOString().slice(0, 10);++}++++/** The week a date belongs to, and the Sunday it ends on (for the card). */++function weekRange(at = new Date()) {++ const start = weekStart(at);++ const end = new Date(Date.parse(`${start}T00:00:00.000Z`) + 6 * 86_400_000).toISOString().slice(0, 10);++ return { start, end };++}++++/** What this agent may spend this week, and what is left of it. */++async function allowance(s, owner, agentId) {++ const week = weekStart(s.now());++ const row = await s.db.maybe('SELECT spend_weekly_vibes FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);++ const weekly = row ? Number(row.spend_weekly_vibes || 0) : 0;++ // A payment still 'paying' may already have moved (an unknown answer from Tips): it counts against the week,++ // exactly as the lock in claim() counts it. Only a definite refusal (a 'failed' row) never does.++ const spent = Number(await s.db.value(++ "SELECT COALESCE(SUM(amount), 0) FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND week_start = $3 AND state IN ('paid', 'paying')",++ [owner, agentId, week]));++ return { agent_id: agentId, weekly, spent: Number(spent) || 0, left: Math.max(0, weekly - (Number(spent) || 0)), week, ...weekRange(s.now()) };++}++++/**++ * The person's own setting: how many Vibes this agent may spend a week. 0 turns the spending tool off (it is then++ * never offered to the agent at all). The setting is the person's, never the agent's.++ */++async function setAllowance(s, owner, agentId, amount) {++ const value = int(amount);++ if (value == null) return { error: 'The weekly allowance must be a whole number of Vibes, 0 or more.' };++ if (value > MAX_ALLOWANCE) return { error: `The weekly allowance may be at most ${MAX_ALLOWANCE} Vibes.` };++ const row = await s.db.maybe('SELECT id FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);++ if (!row) return { error: 'No such agent of yours.' };++ await s.db.query('UPDATE actor_agents SET spend_weekly_vibes = $3, spend_weekly_set_at = $4, updated_at = $4 WHERE id = $1 AND owner = $2',++ [agentId, owner, value, s.iso()]);++ return { row: { id: agentId, spend_weekly_vibes: value } };++}++++/**++ * May this agent spend this much, right now? An ask over what is left is refused here, before the person ever sees++ * it, with one plain line the agent can say out loud.++ */++async function check(s, { owner, agentId, amount }) {++ const a = await allowance(s, owner, agentId);++ const want = int(amount);++ if (!agentId) return { ok: false, ...a, message: 'This agent may not spend: it has no allowance of its own.' };++ if (!a.weekly) {++ return { ok: false, ...a, message: 'This agent may not spend anything: no weekly allowance is set on its page. Ask your person to set one before a tip.' };++ }++ if (want == null || want < MIN_TIP) {++ return { ok: false, ...a, message: 'A tip is a whole number of Vibes, at least 1.' };++ }++ if (want > a.left) return { ok: false, ...a, message: overMessage(a.weekly, a.left, want) };++ return { ok: true, ...a };++}++++/**++ * The creator a tip names, and the profile that says what a tip may be. A person on OpenVibe is their account id++ * (usr_…); a creator's OpenVibe page is its handle (a channel). Tips resolves either, and its profile is also what++ * says the page's minimum tip: paying less would be refused there, so it is refused here before the person sees a++ * card. Nothing is guessed: a name that names nobody is refused, because money must never go to the wrong person.++ *++ * The label always carries the handle (a display name is not unique: anyone can call themselves "Josh"), and the++ * usr_ id too when the tip named an id. The subject is what the checkout names: a handle can be renamed, a subject++ * cannot.++ */++async function resolve(config, to, { fetchImpl = globalThis.fetch, signal = null } = {}) {++ const value = text(to, 120);++ const namedId = SUBJECT.test(value);++ const handle = value.replace(/^@/, '').trim().toLowerCase();++ if (!namedId && !handle) return { error: 'Who receives the tip? Give the creator\'s OpenVibe account id (usr_…) or their OpenVibe page name.' };++ const base = config && config.services ? config.services.tipsUrl : '';++ if (!base) return { error: 'This server cannot reach OpenVibe.Tips, so no tip can be paid.' };++ const ref = namedId ? value : handle;++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/profiles/${encodeURIComponent(ref)}`, {++ headers: { Accept: 'application/json' },++ signal: signal || AbortSignal.timeout(8_000),++ });++ } catch (err) {++ return { error: `OpenVibe could not be reached to look up "${ref}", so no tip was asked for.` };++ }++ if (!res.ok) {++ if (res.status === 404) return { error: `No OpenVibe page or person is called "${ref}". A tip needs the creator's account id (usr_…) or their OpenVibe page name.` };++ return { error: `OpenVibe could not look up "${ref}" (it answered ${res.status}), so no tip was asked for.` };++ }++ const data = await res.json().catch(() => null);++ const profile = data && data.profile ? data.profile : null;++ const creator = profile && profile.creator ? profile.creator : null;++ const subject = creator && typeof creator.id === 'string' ? creator.id : null;++ const foundHandle = text(profile && profile.handle, 40);++ if (!subject || !SUBJECT.test(subject) || !foundHandle) {++ return { error: `No OpenVibe page or person is called "${ref}". A tip needs the creator's account id (usr_…) or their OpenVibe page name.` };++ }++ const name = text(profile.display_name, 80) || foundHandle;++ const label = `${name} (@${foundHandle})${namedId ? ` · ${subject}` : ''}`;++ const target = { service: 'tips', type: 'creator', id: foundHandle, label: text(label, 200) };++ const tipMin = Number(profile.minimums && profile.minimums.tip);++ return {++ subject, label: text(label, 200), target, named_id: namedId,++ min: Number.isInteger(tipMin) && tipMin > 0 ? tipMin : MIN_TIP,++ };++}++++/**++ * A tip, ready to be asked for: the recipient is resolved to the person who receives the money (and what Tips says++ * is the page's minimum), and the agent's weekly allowance is checked. Either way the answer is one plain line, and++ * nothing is asked of the person when it says no.++ */++async function prepare({ config, s, owner, agentId, payload, fetchImpl = globalThis.fetch } = {}) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const amount = int(p.amount);++ if (amount == null || amount < MIN_TIP) return { ok: false, message: 'A tip is a whole number of Vibes, at least 1.' };++ if (!agentId) return { ok: false, message: 'This agent may not spend: it has no allowance of its own.' };++ const left = await check(s, { owner, agentId, amount });++ if (!left.ok) return { ok: false, message: left.message };++ const who = await resolve(config, p.to, { fetchImpl });++ if (who.error) return { ok: false, message: who.error };++ // Tips' own words for its profile's minimum: a smaller tip would be refused there, so it never reaches a card.++ if (amount < who.min) return { ok: false, message: `The minimum for a tip is ${who.min} Vibes.` };++ // Tips refuses a transfer to yourself; say so before the person sees a card that could never be paid.++ if (who.subject === String(owner || '').replace(/^user:/, '')) {++ return { ok: false, message: 'A tip to yourself would not go anywhere: name a creator on OpenVibe.' };++ }++ const reason = text(p.reason, 300);++ return {++ ok: true,++ // The marker says this is a tip (plan HO7): only a tip is paid at the person's approval.++ payload: { spend: 'tip', to: who.subject, to_label: who.label, amount, reason, target: who.target, named_id: who.named_id },++ summary: `Tip ${amount} Vibes to ${who.label}${reason ? `: ${reason}` : ''}`,++ left,++ };++}++++/**++ * The payment itself, through OpenVibe.Tips' checkout. It is made with the person's own token (the session they++ * approved with), never with a service token and never later from a worker. Tips records the interaction (so the++ * creator sees the tip like any other), mints the person's assertion and makes the Billing transfer. The approval id++ * is the idempotency key, so a second click (or a retry of an unknown one) never pays twice.++ *++ * A definite refusal (Tips' 409 refusals, or a 422 validation one) means no money moved. Anything else — a network++ * error, a timeout, a 5xx, or Tips still handling the first request — is unknown: the money may have moved, so the++ * caller must keep the claim counting and retry with this same key, which Tips replays.++ */++const UNCONFIRMED = 'We could not confirm the tip yet; nothing will be paid twice.';++++function createTips({ config, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {++ const base = config && config.services ? config.services.tipsUrl : '';++ const enabled = !!base;++ async function tip({ token, to, amount, message = null, key, signal = null }) {++ if (!enabled) return { ok: false, definite: true, status: 503, error: 'OpenVibe.Tips is not connected on this server, so no tip can be paid.' };++ if (!token) return { ok: false, definite: true, status: 401, error: 'This payment needs the person\'s own session.' };++ if (!key) return { ok: false, definite: true, status: 500, error: 'This payment has no idempotency key, so nothing was paid.' };++ // The resolved creator (a usr_ id) and what was read on the card; no URL or name from the model is sent.++ const body = { creator: String(to), amount, pay_with: 'credit' };++ if (message) body.message = message;++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/checkout`, {++ method: 'POST',++ headers: {++ Accept: 'application/json',++ 'Content-Type': 'application/json',++ Authorization: `Bearer ${token}`,++ 'Idempotency-Key': String(key),++ },++ body: JSON.stringify(body),++ signal: signal || AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ return { ok: false, unknown: true, error: UNCONFIRMED };++ }++ const data = await res.json().catch(() => null);++ if (res.ok) {++ const interaction = data && data.interaction ? data.interaction : null;++ return {++ ok: true, status: res.status, interaction,++ id: interaction && interaction.id ? interaction.id : null,++ replay: res.headers.get('idempotent-replayed') === 'true',++ };++ }++ const code = (data && data.code) || null;++ const detail = (data && (data.detail || data.title)) || `OpenVibe.Tips answered ${res.status}`;++ // Any 4xx is Tips refusing before it moved money (409 not enough Vibes or not accepting, 422 validation, 401/403++ // the session, 404 no such creator, 429 too many): definite. Except the idempotency claim still in progress,++ // where the first request may pay; that, a 5xx and no answer at all are unknown and keep counting.++ if (res.status >= 400 && res.status < 500 && !(res.status === 409 && code === 'idempotency.in_progress')) {++ return { ok: false, definite: true, status: res.status, code, error: String(detail).slice(0, 300) };++ }++ return { ok: false, unknown: true, status: res.status, code, error: UNCONFIRMED };++ }++ return { enabled, tip };++}++++/** One plain line for an ask over what is left this week. */++function overMessage(weekly, left, want) {++ return `This agent may spend ${weekly} Vibes a week; ${left} ${left === 1 ? 'is' : 'are'} left this week, and this tip is ${want}. Ask your person to raise the allowance on the agent's page.`;++}++++/**++ * Reserve one payment before Tips is called. The row is 'paying' until Tips answers, so:++ * - two clicks on the same approval cannot both call Tips (the partial unique index on approval_id);++ * - two tips of the same agent cannot both pass the weekly cap (the agent's row is locked while the week is summed).++ * Tips still holds the approval id as its idempotency key, so a claim abandoned by a crash (or left 'paying' by an++ * unknown answer) is safe to retry: the retry reserves the same row and Tips replays instead of moving money twice.++ */++async function claim(s, { owner, agentId = null, approvalId = null, to, toLabel = null, amount, reason = null, target = null } = {}) {++ const want = int(amount);++ if (!agentId) return { error: 'This agent may not spend: it has no allowance of its own.' };++ if (want == null || want < MIN_TIP) return { error: 'A tip is a whole number of Vibes, at least 1.' };++ if (!approvalId) return { error: 'This payment has no approval to key it to, so nothing was paid.' };++ try {++ return await s.tx(async () => {++ const paid = await paidFor(s, owner, approvalId);++ if (paid) return { paid };++ // Lock the live claim first: two retries of one unknown payment serialize, so only one sends the key.++ const inFlight = await s.db.maybe("SELECT * FROM actor_spends WHERE owner = $1 AND approval_id = $2 AND state = 'paying' FOR UPDATE", [owner, approvalId]);++ // Then this agent's allowance row: two different tips of one agent cannot both pass the cap.++ const agent = await s.db.maybe('SELECT spend_weekly_vibes FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agentId, owner]);++ const weekly = agent ? Number(agent.spend_weekly_vibes || 0) : 0;++ const now = s.iso();++ if (inFlight) {++ if (Date.parse(now) - Date.parse(inFlight.created_at) <= 5 * 60_000) return { busy: true, claim: inFlight };++ // The same payment, already reserved and cap-checked before Tips was called: retry it with the same++ // key, even if the allowance changed meanwhile. The money may already have moved; Tips replays it.++ const again = await s.db.one("UPDATE actor_spends SET created_at = $2 WHERE id = $1 AND state = 'paying' RETURNING *", [inFlight.id, now]);++ return again ? { claim: again } : { busy: true };++ }++ if (!weekly) {++ return { error: 'This agent may not spend anything: no weekly allowance is set on its page. Ask your person to set one before a tip.' };++ }++ const week = weekStart(s.now());++ // Paying claims count against the week too: the money may already have moved while the row is settled.++ const used = Number(await s.db.value(++ "SELECT COALESCE(SUM(amount), 0) FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND week_start = $3 AND state IN ('paid', 'paying') AND approval_id IS DISTINCT FROM $4",++ [owner, agentId, week, approvalId])) || 0;++ if (used + want > weekly) return { error: overMessage(weekly, Math.max(0, weekly - used), want) };++ const row = await s.db.one(`INSERT INTO actor_spends (id, owner, agent_id, approval_id, to_subject, to_label, amount, reason, target, state, week_start, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 'paying', $10, $11)++ ON CONFLICT DO NOTHING RETURNING *`,++ [s.newId('spn'), owner, agentId, approvalId, to, toLabel, want, reason, target ? JSON.stringify(target) : null, week, now]);++ if (row) return { claim: row };++ // Another click won the race: the first payment is the one that counts.++ const won = await paidFor(s, owner, approvalId);++ return won ? { paid: won } : { busy: true };++ });++ } catch (err) {++ return { error: 'The payment could not be reserved, so nothing was paid. Try again in a moment.', status: 503 };++ }++}++++/** Tips paid: the reserved row becomes the payment of the week (its interaction id is the record). */++async function settle(s, id, txnId = null) {++ return await s.db.one("UPDATE actor_spends SET state = 'paid', txn_id = $2, error = NULL WHERE id = $1 AND state = 'paying' RETURNING *", [id, txnId]);++}++++/** Tips definitely refused: the reserved row is kept as a failed attempt, and never counts against the week. */++async function fail(s, id, error) {++ return await s.db.one("UPDATE actor_spends SET state = 'failed', error = $2 WHERE id = $1 AND state = 'paying' RETURNING *", [id, String(error || '').slice(0, 300)]);++}++++/** The payment of one approval, when it was paid. */++async function paidFor(s, owner, approvalId) {++ if (!approvalId) return null;++ return await s.db.maybe("SELECT * FROM actor_spends WHERE owner = $1 AND approval_id = $2 AND state = 'paid'", [owner, approvalId]);++}++++/**++ * The payments and refused attempts of one agent (or of the whole person), newest first. A reservation still in++ * flight is not a payment yet, so it is not listed here.++ */++async function listFor(s, owner, { agentId = null, limit = 50 } = {}) {++ const rows = agentId++ ? await s.db.many("SELECT * FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND state IN ('paid', 'failed') ORDER BY created_at DESC LIMIT $3", [owner, agentId, limit])++ : await s.db.many("SELECT * FROM actor_spends WHERE owner = $1 AND state IN ('paid', 'failed') ORDER BY created_at DESC LIMIT $2", [owner, limit]);++ return rows;++}++++module.exports = {++ SUBJECT, MAX_ALLOWANCE, MIN_TIP,++ weekStart, weekRange,++ allowance, setAllowance, check, prepare,++ resolve, createTips,++ claim, settle, fail, paidFor, listFor,++};@@@ -160,9 +160,11 @@ async function createApp(opts = {}) {// The person's computers (agents/computers.js): one adapter for the task's still proxy, made here so a test can// stand Bot/Run in (opts.bot.computers) exactly as the Console pages do.const computers = (opts.bot && opts.bot.computers) || require('./agents/computers').createComputers({ config, fetchImpl: providerFetch, log });++ // An agent's money (plan HO7): the client that pays a tip through OpenVibe.Tips with the person's own token.++ const tips = require('./agents/spend').createTips({ config, fetchImpl });// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine// reports each run's end back here, and the approvals decide() asks it to send an approved draft.-- const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media };++ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media, tips };ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });engine.setMailReplies((task) => ctx.agentMail.answered(task));// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and@@@ -178,6 +178,10 @@ function load(env = process.env) {// OpenVibe.Watch: where a trigger's subscription lives (plan §12 A-4). The person's own token is// always the authority; empty turns triggers off.watchUrl: trim(env.ACTOR_WATCH_URL != null ? env.ACTOR_WATCH_URL : (selfHosted ? '' : 'http://127.0.0.1:4730')),++ // OpenVibe.Tips: where a tip is paid (its checkout makes the Billing transfer) and where a creator's++ // page is looked up to resolve the person who receives it (plan HO7). The person's own token pays, at++ // their own approval; empty turns the spending tool off.++ tipsUrl: trim(env.ACTOR_TIPS_URL != null ? env.ACTOR_TIPS_URL : (selfHosted ? '' : 'http://127.0.0.1:4610')),// OpenVibe.Mail (track HO6): an agent answers the mail sent to its own address. Actor reads one letter in// full text and sends the reply from the agent's inbox, over loopback under its own service token. Empty// turns the mail consumer off.@@@ -9,7 +9,7 @@ const path = require('path');const { createDb } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases'];++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases'];const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');@@@ -15,6 +15,7 @@ const shares = require('../agents/voice-shares');const levels = require('../agents/levels');const allows = require('../agents/allows');const kinds = require('../agents/kinds');++const spend = require('../agents/spend');const skills = require('../skills');/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */@@@ -162,15 +163,17 @@ ${importPanel()}<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${if (!signed(req)) return login(req, res);const rows = await approvals.list(s, owner(req));page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>--<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>++<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A tip your agent asks to send is different: approving it pays it at once, from your own Vibes, with your own session. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">${kinds.label(a.kind)} · Asked ${time(a.created_at)}</span></li>`)}</ul>`: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}<p><a href="/me/agents">Your agents</a></p>`);});-- r.get('/me/approvals/:id', async (req, res) => {-- if (!signed(req)) return login(req, res);-- const a = await approvals.get(s, owner(req), req.params.id);-- if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);++ /**++ * The approval card. A payment (plan HO7) says more than the other kinds: how much, who receives it, why, and++ * what is left of the agent's weekly allowance — the four things a person needs before they pay with their own++ * Vibes.++ */++ const approvalCard = async (req, res, a, { notice = null, status = 200 } = {}) => {const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;// The agent by its name (a person's own agent: another person's is never looked up here).const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;@@@ -179,6 +182,8 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c// say, and a raw command that deletes by its argv (rm, shred …), the same checks approvals.decide uses.const floor = approvals.floorOf(a).filter((c) => CAUTION[c]);const coding = a.kind === 'codes.task';++ // A tip (plan HO7) is the only money step with a card of its own: what it pays, who receives it and why.++ const money = a.kind === 'money' && p && p.spend === 'tip' ? p : null;const cmd = coding ? null : commandLine(p);const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;@@@ -191,27 +196,51 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c: floor.length ? html`<p class="pa-hint">This always asks, so it can never be always allowed.</p>`: canAllow ? html`<p class="pa-hint">Always allowing covers exactly this, and nothing else: ${kinds.phrase(a.kind, a.target)}. Anything different still asks. You can take it back on <a href="/me/agents/${a.agent_id}#always">${agentName}'s page</a>.</p>`: '';++ // What is left of this agent's allowance this week, and what the tip would take of it.++ const left = money && a.agent_id ? await spend.allowance(s, owner(req), a.agent_id) : null;++ const paid = money ? await spend.paidFor(s, owner(req), a.id) : null;const decided = a.state === 'approved'-- ? (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.')++ ? (paid ? `You approved this once and paid ${paid.amount} Vibes to ${paid.to_label || paid.to_subject}.`++ : (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.')): (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');const buttons = a.state === 'pending'-- ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : 'Approve for a retry'}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`++ ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : (money ? 'Approve and pay' : 'Approve for a retry')}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`: html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;++ const moneyCard = money ? html`<section class="pa-panel"><h2>What it would do</h2>++<p>It would send <strong>${Number(money.amount) || 0} Vibes</strong> to ${money.to_label || money.to}.</p>++${money.reason ? html`<p>Why: ${money.reason}</p>` : ''}++${left ? html`<p>Left this week for ${agentName}: <strong>${left.left} of ${left.weekly} Vibes</strong> (${left.start} to ${left.end} UTC).</p>` : ''}++<p class="pa-hint">The money moves when you approve, from your own Vibes and with your own session. It is never paid later and never by the agent itself.</p></section>` : '';page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1><p>${approvalPill(a)} <span class="pa-when">${kinds.label(a.kind)} · Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>++${notice ? html`<p class="pa-caution" role="alert">${notice}</p>` : ''}${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}--${!coding-- ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>++${money ? moneyCard : (coding++ ? html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>++<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`++ : html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}--<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`-- : html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>--<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}--${buttons}`);++<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`)}++${buttons}`, status);++ };++ r.get('/me/approvals/:id', async (req, res) => {++ if (!signed(req)) return login(req, res);++ const a = await approvals.get(s, owner(req), req.params.id);++ if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);++ return approvalCard(req, res, a);});r.post('/me/approvals/:id', async (req, res) => {if (!write(req, res)) return;-- const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''));-- if (out.error) return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);++ // A payment is paid with the person's own token: the session they are sitting in (plan HO7). A service++ // deciding for them never pays, and the request stays pending until they do it themselves.++ const personToken = req.viewer && req.viewer.token ? req.viewer.token : null;++ const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''), { personToken });++ if (out.error) {++ // A refusal that leaves the request pending: the reason is shown on the card and the person may try again.++ const row = await approvals.get(s, owner(req), req.params.id);++ if (out.pending && row) return approvalCard(req, res, row, { notice: out.error });++ return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);++ }res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);});@@@ -401,6 +430,9 @@ ${signed(req) ? html`<form method="post" action="/voices/${row.id}/borrow"><buttconst tidyChanges = await tidy.changes(s, a.owner, { agentId: a.id, limit: 10 });// What this agent may always do (plan HO1), and every decision the person made about its asks.const allowRules = await allows.list(s, a.owner, a.id);++ // What it may spend this week (plan HO7), and what it has spent.++ const allowance = await spend.allowance(s, a.owner, a.id);++ const payments = await spend.listFor(s, a.owner, { agentId: a.id, limit: 20 });const decisions = await s.db.many(`SELECT id, kind, summary, state, decision_scope, decided_by, decided_at, created_atFROM actor_approvals WHERE owner = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 20`, [a.owner, a.id]);// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.@@@ -443,6 +475,14 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r<p class="pa-hint">When you said "always allow this for this agent", the permission is here: one kind of step, on one target. Take any of them back and the next one asks again. Spending money, a password, a deletion and a new recipient always ask, whatever is allowed here.</p>${allowRules.length ? html`<ul class="pa-notes">${allowRules.map((r) => html`<li><p class="pa-note-text">${kinds.phrase(r.kind, r.target)}</p><p class="pa-note-meta">Allowed ${time(r.created_at)}</p><form method="post" action="/me/agents/${a.id}/allows/${r.id}/revoke" class="pa-actions"><button type="submit" class="pa-danger-link">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">Nothing is always allowed for this agent.</p>`}</section>++<section class="pa-panel" id="spend" aria-labelledby="ac-spend-h"><h2 id="ac-spend-h">What it may spend</h2>++<p class="pa-hint">An agent may tip a creator on OpenVibe with your Vibes, but only after you approve each tip, and only up to what is left of its weekly allowance. Zero means it may not spend at all: the spending tool is then not offered to it at all.</p>++<form method="post" action="/me/agents/${a.id}/spend"><label>Weekly allowance (Vibes) <input name="weekly_vibes" type="number" min="0" max="${spend.MAX_ALLOWANCE}" step="1" value="${allowance.weekly}"></label> <button type="submit" class="pa-btn">Save allowance</button></form>++<p>Left this week: <strong>${allowance.left} of ${allowance.weekly} Vibes</strong> (${allowance.start} to ${allowance.end} UTC). The week starts on Monday.</p>++<h3 class="pa-sub">What it spent</h3>++${payments.length ? html`<ul class="pa-notes">${payments.map((p2) => html`<li><p class="pa-note-text">${p2.state === 'paid' ? html`${p2.amount} Vibes to ${p2.to_label || p2.to_subject}` : html`${p2.amount} Vibes to ${p2.to_label || p2.to_subject} — not paid`}</p>++<p class="pa-note-meta">${p2.reason ? html`${p2.reason} · ` : ''}${p2.state === 'paid' ? 'Paid' : html`Refused: ${p2.error || 'Tips did not pay it'}`} · ${time(p2.created_at)}</p></li>`)}</ul>` : html`<p class="muted">It has not spent anything yet.</p>`}++</section><section class="pa-panel" id="decisions" aria-labelledby="pa-dec-h"><h2 id="pa-dec-h">What you decided</h2><p class="pa-hint">Every approval this agent asked for, and what you answered: once, always, or no.</p>${decisions.length ? html`<ul class="pa-notes">${decisions.map((d) => html`<li><p class="pa-note-text"><a href="/me/approvals/${d.id}">${d.summary}</a></p><p class="pa-note-meta">${kinds.label(d.kind)} · ${d.state === 'approved' ? (d.decision_scope === 'always' ? 'You approved it and allowed it always' : 'You approved it once') : d.state === 'declined' ? 'You declined it' : d.state === 'expired' ? 'It expired, nobody answered' : 'Waiting for you'} · ${time(d.decided_at || d.created_at)}</p></li>`)}</ul>` : html`<p class="muted">This agent has not asked for anything yet.</p>`}</section>@@@ -476,6 +516,15 @@ ${c.undoable ? html`<form method="post" action="/me/agents/${a.id}/changes/${c.i<form method="post" action="/me/agents/${a.id}/delete" class="pa-delete"><button type="submit" class="pa-danger-link">Delete this agent</button> <span class="pa-hint">Its memory goes with it; its past runs stay on your task list.</span></form></section>`);});r.post('/me/agents/:id/edit', async (req, res) => { if (!write(req, res)) return; const out = await personal.update(s, config, owner(req), req.params.id, fields(req.body)); if (!out) return page(req, res, 'Agent not found', html`<h1>Agent not found</h1>`, 404); if (!out.row) return page(req, res, 'Invalid agent', html`<h1>Invalid agent</h1><p>${out.error}</p>`, 422); res.redirect(303, `/me/agents/${req.params.id}`); });++ // The weekly allowance (plan HO7): the person's own setting, on their agent's page. 0 turns the spending++ // tool off for that agent.++ r.post('/me/agents/:id/spend', async (req, res) => {++ if (!write(req, res)) return;++ const a = await find(req, res); if (!a) return;++ const out = await spend.setAllowance(s, a.owner, a.id, (req.body || {}).weekly_vibes);++ if (out.error) return page(req, res, 'Not saved', html`<h1>Not saved</h1><p>${out.error}</p><p><a href="/me/agents/${a.id}">Back to the agent</a></p>`, 422);++ res.redirect(303, `/me/agents/${a.id}#spend`);++ });// Take a skill back from one of the person's agents (plan HO2).r.post('/me/agents/:id/skills/:listingId/remove', async (req, res) => {if (!write(req, res)) return;@@@ -112,10 +112,20 @@ function createApi(ctx) {agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,thread: a.task_id ? threadTitle.get(a.task_id) || null : null,}));++ // A payment that was made (plan HO7): the person sees it in their Action Center, with what it paid and who++ // received it. Only a recorded payment becomes a done item; a refused one stays on its pending approval card.++ const paidRows = await s.db.many("SELECT approval_id FROM actor_spends WHERE owner = $1 AND state = 'paid' AND approval_id IS NOT NULL", [requester]);++ const paidIds = new Set(paidRows.map((r) => r.approval_id));++ const paidAsks = (await approvals.list(s, requester))++ .filter((a) => a.state === 'approved' && a.kind === 'money' && paidIds.has(a.id))++ .map((a) => items.approvalItem(a, {++ agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,++ thread: a.task_id ? threadTitle.get(a.task_id) || null : null,++ }));const scheduled = (await personal.list(s, requester)).filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at).map(items.scheduledItem);-- const all = [...tasks, ...asks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));++ const all = [...tasks, ...asks, ...paidAsks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));res.json({ items: all.slice(0, 50) });});@@@ -109,6 +109,10 @@ const TABLES = [// What the person's agents asked them to approve (agents/approvals.js), and what they decided.{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },++ // The tips their agents sent (plan HO7): who received them, how much, why, and what Billing said. The row of a++ // payment that Billing refused is here too: it was their attempt, and it is their money either way.++ { table: 'actor_spends', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-spends.json',++ columns: ['id', 'owner', 'agent_id', 'approval_id', 'to_subject', 'to_label', 'amount', 'reason', 'target', 'state', 'txn_id', 'error', 'week_start', 'created_at'] },// The standing permissions the person gave their agents (agents/allows.js): what they always allow, and when.{ table: 'actor_approval_allows', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approval-allows.json' },// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused@@@ -870,7 +870,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, latency = 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, spend = 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' });@@@ -891,7 +891,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, latency,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -913,7 +913,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.// The live tool list, at most the checkpoint's allow-list. A tool the registry no longer offers is dropped; the// delegate is kept only for a "best" run that already had it (never added by a fork).// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.-- const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits });++ const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits, spend });// The connector tools of this person (stage V2-11), at most the checkpoint's allow-list.const dynamic = await connectorTools({ requester, mode });const installed = await installedSkillTools({ requester, agentId, mode });@@@ -1030,9 +1030,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, latency = 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, spend = 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, latency });++ 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, spend });}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' });@@@ -1056,7 +1056,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, latency,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -1108,7 +1108,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.// Which tools are offered first: the demoted ones last, never removed (stage V2-8). Below about 40 tools// the model sees them all — no LLM picker and no embedding shortlist here.// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.-- const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits });++ const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits, spend });// The connections of this person (stage V2-11): the MCP tools they turned on and ask_agent for their agents.const dynamic = await connectorTools({ requester, mode });const installed = await installedSkillTools({ requester, agentId, mode });@@@ -1202,7 +1202,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });// The run's own tools and allow-list (sub-agents included in "best" mode), so a revision can do no more than the run.-- const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts, store, agentId: state.agentId, taskId: state.taskId, now });++ const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts, store, agentId: state.agentId, taskId: state.taskId, now, spend: ctx.spend });if (!state.allow) state.allow = new Set(tools.map((t) => t.function.name));const r = await callModel(state, ctx, { tools });if (r) {@@@ -175,6 +175,15 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchIdata: ask, isError: true,});}++ // The gate refused the step outright (plan HO7: a tip over what is left of the agent's weekly++ // allowance, or a creator that names nobody). The step does not run, and the model is told why in++ // one plain line.++ if (ask && ask.refused) {++ return normalize({++ text: JSON.stringify({ error: String(ask.refused).slice(0, 300) }),++ data: { refused: String(ask.refused).slice(0, 300) }, isError: true,++ });++ }}}const signal = ctx.signal;nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * spend_vibes: tip a creator on OpenVibe with the person's own Vibes (plan HO7).++ *++ * The tool never pays. It asks: the step is money, so it always reaches the person's approval floor (agents/kinds.js++ * keeps 'money' out of what may be always allowed), and the payment is made at approval time with the person's own++ * token (agents/spend.js). A run that asks over what is left of the agent's weekly allowance is refused before the++ * person ever sees it.++ *++ * The tool is only offered to an agent whose person set a weekly allowance on its page (ctx.spend, read by the engine++ * from the agent that owns the run). With no allowance there is nothing to offer.++ */++++const spend = require('../../agents/spend');++++module.exports = {++ name: 'spend_vibes',++ description: 'Tip a creator on OpenVibe with your person\'s Vibes. The tip is not paid here: it goes to your person '++ + 'as an approval, and they pay it themselves with their own Vibes. Name the creator by their OpenVibe account '++ + 'id (usr_…, a person on OpenVibe) or by their OpenVibe page name (a channel). Say why, in one short line: '++ + 'your person reads it on the card. The tip may not be more than what is left of your weekly allowance.',++ input: {++ type: 'object',++ properties: {++ to: { type: 'string', description: 'The creator: their OpenVibe account id (usr_…) or their OpenVibe page name (a channel).' },++ amount: { type: 'integer', minimum: 1, description: 'How many whole Vibes to send.' },++ reason: { type: 'string', maxLength: 300, description: 'Why the tip is sent, in one short line: your person reads it before they pay.' },++ },++ required: ['to', 'amount'],++ },++ scope: 'person',++ acts: true,++ costUsd: 0,++ timeoutMs: 20_000,++++ /** Only an agent with a weekly allowance of its own, on a server that can pay it, is offered this tool. */++ available(ctx) {++ const s = ctx && ctx.spend ? ctx.spend : null;++ const tips = ctx && ctx.config && ctx.config.services ? ctx.config.services.tipsUrl : '';++ // A chat has its own consent card, not the approval floor; money is only offered behind the floor.++ return !!(s && Number(s.weekly) > 0 && tips && !ctx.chatPolicy);++ },++++ /**++ * Nothing is paid here, ever. The approval gate above stops the call with the person's card; if it somehow did++ * not, this repeats the allowance check and says plainly that no tip was sent, so a run can never spend by++ * another path.++ */++ async run(input, ctx) {++ const owner = String((ctx && ctx.requester) || '');++ const agentId = ctx && ctx.spend ? ctx.spend.agent_id : null;++ const amount = Number(input && input.amount);++ if (!agentId) return { text: 'This agent may not spend: it has no allowance of its own. Ask your person to set one on the agent\'s page.', isError: true };++ const left = await spend.check(ctx.store, { owner, agentId, amount }).catch(() => null);++ if (!left || !left.ok) {++ return { text: (left && left.message) || 'This agent may not spend that.', isError: true };++ }++ return {++ text: 'No tip was sent: a tip is only paid when your person approves it on their approvals page.',++ data: { asked: false, amount, to: String(input.to || '') },++ isError: true,++ };++ },++};@@@ -34,6 +34,7 @@ const contacts = require('../agents/contacts');const assignments = require('../agents/assignments');const runTokens = require('../triggers/tokens');const levels = require('../agents/levels');++const spend = require('../agents/spend');const { repoIn: codesRepoIn } = require('../agents/codes');const memory = require('../memory');const taskNotices = require('../notify/task-notices');@@@ -370,6 +371,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =} catch (err) {log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);}++ // HO7: what this agent may spend this week. The spending tool is offered only when the person set a++ // weekly allowance on the agent's page (0 = off: the tool is not there at all).++ const spendAllowance = taskAgent++ ? await spend.allowance(s, row.requester, taskAgent.id).catch(() => null)++ : null;// Both execution paths get the same gate and assignment callbacks: the one loop passes them into its// tools, and an adapter that starts a Codes task gets them too.let requestApproval = null;@@@ -379,15 +385,24 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// a reviewer model of another family. Returns { asked: false } when the step may go ahead.// A standing permission the person gave for this exact kind and target (plan HO1) is read here: it lets the// step go on, and it never covers the floor.-- const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null }) => {++ const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null, spendStep = false }) => {const categories = levels.classify({ category, summary, payload, recipientKnown });++ // HO7: money is the person's own. Before a tip reaches them, its recipient is resolved (a person++ // usr_… or a creator's OpenVibe page) and the agent's weekly allowance is checked. An ask that++ // cannot pay is refused here, with one plain line, and the person never sees a card for it.++ let money = null;++ if (kind === 'money' && spendStep) {++ money = await spend.prepare({ config, s, owner: row.requester, agentId: taskAgent ? taskAgent.id : null, payload });++ if (!money.ok) return { asked: false, decision: { ask: false }, refusal: money.message };++ }const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });if (!decision.ask) return { asked: false, decision };-- const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload, target, categories });++ const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary: money ? money.summary : summary, payload: money ? money.payload : payload, target, categories });const url = `${config.baseUrl}/me/approvals/${ask.id}`;-- await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(summary).toLowerCase()} Nothing runs until you approve it: ${url}` });-- await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });++ const shown = money ? money.summary : summary;++ await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });++ await tellPerson(row, 'needs_you', { what: `approve: ${shown}`, url });return { asked: true, decision, ask, url };};// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.@@@ -473,8 +488,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// A task that would start coding work on the person's machine asks through the same gate (levels.js):// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a// payment, password, deletion or a new recipient never does.-- requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null }) => {-- const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target });++ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null, spend = false }) => {++ const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target, spendStep: spend });++ // A refusal (plan HO7): the step does not run and the model is told why.++ if (out.refusal) return { refused: out.refusal };if (!out.asked) return null;return { pending: true, id: out.ask.id, url: out.url };} : null;@@@ -517,6 +534,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =personText, agentId: ownAgent, notifyTask,askPerson, control, resume, fork, spent: () => spent,waits, waitEvent: wakePayload, attachments,++ // What this agent may spend this week (plan HO7): the spending tool is offered only++ // when the person set a weekly allowance on the agent's page.++ spend: spendAllowance,// 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),}));@@@ -41,6 +41,7 @@ const EXPECTED = {space_create: 'space',space_files_write: 'space',space_publish: 'publish',++ spend_vibes: 'money',};const SAMPLE = {@@@ -60,6 +61,7 @@ const SAMPLE = {space_create: { name: 'My space' },space_files_write: { space_id: 'sp_1', files: [{ path: 'a.txt', content: 'x' }] },space_publish: { space_id: 'sp_1' },++ spend_vibes: { to: 'bo', amount: 12, reason: 'thanks for the stream' },};(async () => {@@@ -124,6 +124,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot','server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL','server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',++ 'server/agents/spend.js': 'the configured OpenVibe.Tips base (ACTOR_TIPS_URL, loopback): a creator\'s page is looked up by a path-encoded handle or usr_ id, and a tip is paid through Tips\' checkout with the person\'s own token and the approval id as the Idempotency-Key; no URL from a task or from the model is ever fetched here (test/spend-vibes.test.js)','server/net/guarded-request.js': 'the MCP servers and A2A agents a person added on /connections (stage V2-11): https on 443/8443 to public addresses only, refused for localhost, private ranges and metadata addresses through openvibe-shared/egress safeLookup, with a timeout and a 256 KB cap, never a redirect (test/connections.test.js)','server/skills/run.js': 'the configured OpenVibe.Run base (ACTOR_RUN_URL) with Actor\'s own service token (run.job.submit/read/cancel), the person in X-OV-Subject and a project id checked as prj_<ULID> in X-OV-Project; a job id must be job_<ULID> and is URL-encoded, every code input is a Media id med_<ULID> with a sha256 from the listing, and no URL from the model or a skill enters a request (test/skill-sandbox.test.js)',};nnew file mode 100644@@@ -0,0 +1,450 @@++'use strict';++/**++ * HO7: an agent that can spend the person's Vibes.++ *++ * The tool is only offered to an agent whose person set a weekly allowance. A tip always asks (money is the floor:++ * no level and no "always allow" rule covers it), and the money moves only when the person approves the card with++ * their own session, once, with their own token, through OpenVibe.Tips' checkout. An ask over what is left of the++ * allowance (or under Tips' own minimum) is refused before the person ever sees it. A definite refusal from Tips++ * leaves the request pending with its reason; an unknown answer keeps the claim paying, so the week still counts it,++ * and a retry after the stale window sends the same Idempotency-Key, which Tips replays.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { serviceAuth } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const personal = require('../server/agents/personal');++const contacts = require('../server/agents/contacts');++const approvals = require('../server/agents/approvals');++const allows = require('../server/agents/allows');++const levels = require('../server/agents/levels');++const spend = require('../server/agents/spend');++const tool = require('../server/runtime/tools/spend_vibes');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const UNCONFIRMED = 'We could not confirm the tip yet; nothing will be paid twice.';++++/**++ * A stand-in for OpenVibe.Tips: the creator profile resolve() reads, and the one checkout a person's own token pays.++ * A replay with the same Idempotency-Key returns the first interaction (200, Idempotent-Replayed), as Tips does.++ * state.refuse is what Tips answers with; state.delayMs makes the first request outlive the client's timeout, so its++ * interaction is recorded while the caller sees an unknown outcome.++ */++async function startTips({ creators = {} } = {}) {++ const calls = []; // POST /api/v1/checkout, with the headers and body Actor sent++ const views = []; // GET /api/v1/profiles/:creator++ const payments = new Map(); // Idempotency-Key → the interaction Tips recorded first++ const state = { refuse: null, delayMs: 0 };++ const list = Object.entries(creators).map(([handle, c]) => ({ handle, min: 1, ...c }));++ const find = (ref) => list.find((c) => c.handle === ref || c.subject === ref) || null;++ let n = 0;++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj, headers = {}) => {++ if (res.destroyed || res.writableEnded) return;++ try { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); } catch { /* the client gave up */ }++ };++ const m = /^\/api\/v1\/profiles\/([^/?]+)$/.exec(req.url);++ if (m && req.method === 'GET') {++ const ref = decodeURIComponent(m[1]);++ views.push(ref);++ const c = find(ref);++ if (!c) return json(404, { code: 'tips.creator_not_found', detail: 'no tip profile for this creator' });++ return json(200, {++ profile: {++ creator: { type: 'user', id: c.subject }, handle: c.handle, display_name: c.name || c.handle,++ minimums: { tip: c.min || 1 },++ },++ });++ }++ if (req.url === '/api/v1/checkout' && req.method === 'POST') {++ const body = raw ? JSON.parse(raw) : {};++ const key = req.headers['idempotency-key'] || null;++ calls.push({ auth: req.headers.authorization || null, key, body, contentType: req.headers['content-type'] || null });++ const answer = () => {++ if (state.refuse) {++ const status = state.refuse.status || 409;++ return json(status, {++ type: 'about:blank', title: state.refuse.detail, status,++ code: state.refuse.code || 'tips.refused', detail: state.refuse.detail,++ });++ }++ const prev = key ? payments.get(key) : null;++ if (prev) return json(200, { interaction: prev }, { 'Idempotent-Replayed': 'true' });++ const c = find(body.creator);++ if (!c) return json(404, { code: 'tips.creator_not_found', detail: 'no tip profile for this creator' });++ const interaction = { id: `tint_${String(++n).padStart(26, '0')}`, creator: { type: 'user', id: c.subject }, amount: body.amount, payment: { state: 'settled' } };++ if (key) payments.set(key, interaction);++ return json(201, { interaction });++ };++ if (state.delayMs) { setTimeout(answer, state.delayMs); return; }++ return answer();++ }++ return json(404, { code: 'not_found', detail: 'no such route' });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, views, payments, state, close: () => new Promise((r) => server.close(r)) };++}++++/** The model turn that asks for the tip, and the answer that follows it. */++const tipTurn = (args) => ({ content: 'Tipping the creator.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'spend_vibes', arguments: JSON.stringify(args) } }] });++const doneTurn = { content: 'The tip was sent.' };++++(async () => {++ const tips = await startTips({++ creators: {++ bo: { subject: 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YB', name: 'Bo' },++ nina: { subject: 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YC', name: 'Nina', min: 10 },++ },++ });++ let now = Date.now();++ const t = await boot({++ env: { ACTOR_TIPS_URL: tips.url },++ now: () => now,++ adapters: { checkers: { openai: async () => ({ text: '{"ok":true,"reason":"fine"}', cost_usd: 0 }) } },++ });++ try {++ const ada = t.network.addUser('ada');++ const owner = `user:${ada.subject}`;++ const creator = 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YB';++ const agent = (await personal.create(t.ctx.s, t.config, owner, {++ name: 'Tipper', instructions: 'Tip creators when the person asks for it.', mode: 'balanced',++ budget_per_run_usd: 0.05, daily_runs_max: 5, schedule: 'off', status: 'active',++ })).row;++++ const moneyApprovals = async () => await t.ctx.s.db.many("SELECT * FROM actor_approvals WHERE owner = $1 AND kind = 'money' ORDER BY created_at DESC", [owner]);++ const payloadOf = (a) => (typeof a.payload === 'string' ? JSON.parse(a.payload || '{}') : (a.payload || {}));++ const asksFor = (approvalId) => tips.calls.filter((c) => c.key === approvalId);++ const askTip = async (args, { script = null } = {}) => {++ t.providers.state.script = script || [tipTurn(args), doneTurn];++ const out = await personal.run(t.ctx, agent, 'Send the tip the person asked for.');++ const row = await t.waitFor(out.task.id);++ return row;++ };++++ await check('the person sets the weekly allowance on the agent\'s page, and 0 turns it off', async () => {++ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });++ assert.ok(page.text.includes('What it may spend'), 'the agent page has the allowance panel');++ const set = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '25' }, headers: SAME });++ assert.strictEqual(set.status, 303, set.text.slice(0, 200));++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 25, 'the person\'s setting was saved');++ // Only a whole, non-negative number of Vibes, at most the cap.++ const bad = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '-1' }, headers: SAME });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 25, 'a bad setting changed nothing');++ const off = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '0' }, headers: SAME });++ assert.strictEqual(off.status, 303);++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 0, '0 is off');++ });++++ await check('an agent with no weekly allowance is never offered the spending tool', async () => {++ const defs = t.ctx.registry.definitions({ requester: owner, mode: 'balanced', config: t.config, spend: { weekly: 0, agent_id: agent.id } });++ assert.ok(!defs.some((d) => d.function.name === 'spend_vibes'), 'no allowance, no tool');++ const offered = t.ctx.registry.definitions({ requester: owner, mode: 'balanced', config: t.config, spend: { weekly: 20, agent_id: agent.id } });++ assert.ok(offered.some((d) => d.function.name === 'spend_vibes'), 'an allowance offers the tool');++ assert.strictEqual(tool.available({ config: t.config, spend: { weekly: 0 } }), false);++ assert.strictEqual(tool.available({ config: t.config, spend: { weekly: 1 } }), true);++ // With no Tips to pay it, the tool is not offered either: it could never be paid.++ assert.strictEqual(tool.available({ config: { services: { tipsUrl: '' } }, spend: { weekly: 1 } }), false);++ });++++ await check('a run with no allowance asks for nothing and pays nothing', async () => {++ const before = t.providers.requests.length;++ const row = await askTip({ to: creator, amount: 12, reason: 'thanks for the stream' });++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ assert.strictEqual((await moneyApprovals()).length, 0, 'no approval was asked for');++ assert.strictEqual(tips.calls.length, 0, 'nothing was paid');++ // The tool was not in the model's list at all: with no allowance there is nothing to offer.++ const tools = t.providers.requests.slice(before)++ .filter((r) => r.path.endsWith('/chat/completions') && Array.isArray(r.body.tools))++ .flatMap((r) => r.body.tools.map((x) => x.function && x.function.name));++ assert.ok(tools.length > 0, 'the loop saw a tool list');++ assert.ok(!tools.includes('spend_vibes'), 'no allowance, no spending tool');++ });++++ await check('a tip always asks, at every level, and no allow rule covers it', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ // Full access: the level never skips the floor.++ await contacts.setLevel(t.ctx.s, t.config, owner, agent.id, 'full');++ const before = t.providers.requests.length;++ const row = await askTip({ to: creator, amount: 12, reason: 'thanks for the stream' });++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ const tools = t.providers.requests.slice(before)++ .filter((r) => r.path.endsWith('/chat/completions') && Array.isArray(r.body.tools))++ .flatMap((r) => r.body.tools.map((x) => x.function && x.function.name));++ assert.ok(tools.includes('spend_vibes'), 'an allowance offers the spending tool');++ const asks = await moneyApprovals();++ assert.strictEqual(asks.length, 1, 'the tip asked for approval');++ const ask = asks[0];++ assert.strictEqual(ask.state, 'pending');++ assert.strictEqual(ask.agent_id, agent.id);++ assert.strictEqual(tips.calls.length, 0, 'nothing was paid without the person');++ // The card always names the handle, and the usr_ id too because this tip named an id.++ assert.strictEqual(ask.summary, 'Tip 12 Vibes to Bo (@bo) · usr_01JZ7QK9F3M8N2P4R6T8V0X2YB: thanks for the stream');++ // A standing permission can never cover money, even one written by hand into the rules it reads.++ const rule = await allows.grant(t.ctx.s, owner, agent.id, 'money', 'tip 12 Vibes to x');++ assert.ok(rule.error, 'money is never always allowed');++ assert.strictEqual(allows.matches([], 'money', 'tip 12 Vibes to x'), false);++ assert.strictEqual(allows.matches([{ kind: 'money', target: 'tip 12 Vibes to x' }], 'money', 'tip 12 Vibes to x'), false, 'a money rule never matches');++ const stillAsks = await levels.decide({ level: 'full', categories: ['money'], allow: { kind: 'money', target: 'tip 12 Vibes to x', rules: [{ kind: 'money', target: 'tip 12 Vibes to x' }] } });++ assert.strictEqual(stillAsks.ask, true, 'the floor asks at full access even with a rule');++ // And the card says "approve and pay", never "always allow".++ const card = await t.get(`/me/approvals/${ask.id}`, { as: ada });++ assert.ok(card.text.includes('Approve and pay'), 'the card offers to pay');++ assert.ok(!card.text.includes('Always allow this'), 'money is never always allowed');++ assert.ok(card.text.includes('12 Vibes') && card.text.includes('thanks for the stream'), 'the card says the amount and the reason');++ assert.ok(card.text.includes('Bo (@bo)') && card.text.includes(creator), 'the card says who receives it, by handle and id');++ assert.ok(card.text.includes('Left this week'), 'the card says what is left this week');++ // A tip named by its handle names the handle, never an id the person did not ask to see.++ const byHandle = await spend.prepare({ config: t.config, s: t.ctx.s, owner, agentId: agent.id, payload: { to: '@bo', amount: 4 } });++ assert.ok(byHandle.ok, byHandle.message);++ assert.strictEqual(byHandle.summary, 'Tip 4 Vibes to Bo (@bo)');++ assert.ok(!byHandle.summary.includes(creator), 'a handle-named tip does not show the usr_ id');++ const byId = await spend.prepare({ config: t.config, s: t.ctx.s, owner, agentId: agent.id, payload: { to: creator, amount: 4 } });++ assert.ok(byId.summary.includes('Bo (@bo)') && byId.summary.includes(creator), 'an id-named tip shows the handle and the id');++ assert.ok(tips.views.includes('bo') && tips.views.includes(creator), 'each was looked up by the name the tip gave');++ });++++ await check('a tip below the creator\'s minimum is refused before the person sees a card', async () => {++ const before = (await moneyApprovals()).length;++ const paidBefore = tips.calls.length;++ const row = await askTip({ to: 'nina', amount: 5, reason: 'too small' });++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ assert.strictEqual((await moneyApprovals()).length, before, 'no approval was created');++ assert.strictEqual(tips.calls.length, paidBefore, 'Tips was never asked to pay');++ const said = await t.ctx.s.db.many("SELECT body->>'text' AS text FROM task_events WHERE task_id = $1 AND body->>'step' = 'tool_result'", [row.id]);++ assert.ok(said.some((e) => e.text.includes('minimum for a tip is 10 Vibes')), 'the agent is told Tips\' own minimum');++ });++++ await check('an ask over what is left of the allowance is refused before the person sees it', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 5);++ const before = (await moneyApprovals()).length;++ const row = await askTip({ to: creator, amount: 40, reason: 'too much' });++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ assert.strictEqual((await moneyApprovals()).length, before, 'no approval was created');++ assert.strictEqual(tips.calls.length, 0, 'nothing was paid');++ // The model is told why in one plain line, and it never reaches the person's card.++ const said = await t.ctx.s.db.many("SELECT body->>'text' AS text FROM task_events WHERE task_id = $1 AND body->>'step' = 'tool_result'", [row.id]);++ assert.ok(said.some((e) => /left this week/.test(e.text || '')), 'the agent is told why in one line');++ assert.ok(said.some((e) => /this tip is 40/.test(e.text || '')), 'and how much was asked');++ });++++ await check('approving pays once through Tips checkout, with the person\'s own token', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ const ask = (await moneyApprovals()).find((a) => payloadOf(a).amount === 12);++ assert.ok(ask, 'the 12 Vibe tip is waiting');++ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 303, res.text.slice(0, 200));++ assert.strictEqual(tips.calls.length, 1, 'the tip was paid once');++ const call = tips.calls[0];++ assert.strictEqual(call.auth, `Bearer ${t.network.userToken(ada)}`, 'paid with the person\'s own token');++ assert.strictEqual(call.key, ask.id, 'the approval id is the idempotency key');++ assert.strictEqual(call.contentType, 'application/json');++ assert.deepStrictEqual(call.body, { creator, amount: 12, message: 'thanks for the stream', pay_with: 'credit' });++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');++ const paid = await spend.paidFor(t.ctx.s, owner, ask.id);++ assert.ok(paid, 'the payment is recorded');++ assert.strictEqual(Number(paid.amount), 12);++ assert.strictEqual(paid.txn_id, tips.payments.get(ask.id).id, 'Tips\' interaction id is the record');++ // A second click never pays twice.++ const again = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(tips.calls.length, 1, 'still one payment');++ });++++ await check('a tip over what is left at payment time is refused, not paid', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ const ask = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'money', summary: 'Tip 30 Vibes to Bo: over now',++ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 30, reason: 'over now' },++ });++ // The person lowers the allowance after the ask: the card may be older than the setting.++ await spend.setAllowance(t.ctx.s, owner, agent.id, 5);++ const before = tips.calls.length;++ try {++ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 200, res.text.slice(0, 200));++ assert.ok(res.text.includes('left this week'), 'the card says what is left');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending');++ assert.strictEqual(tips.calls.length, before, 'Tips was never called');++ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null);++ } finally {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ }++ // The person may decline it, and no money moves.++ const declined = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'decline' }, headers: SAME });++ assert.strictEqual(declined.status, 303);++ });++++ await check('the payment is in the agent\'s activity, in the Action Center and in the export', async () => {++ const ask = (await moneyApprovals()).find((a) => payloadOf(a).amount === 12);++ assert.ok(ask, 'the paid tip is there');++ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });++ assert.ok(page.text.includes('12 Vibes'), 'the agent\'s page shows what it spent');++ assert.ok(page.text.includes('Bo (@bo)'), 'and the handle of who received it');++ assert.ok(page.text.includes('thanks for the stream'), 'and why');++ // The Action Center: a first-party service with actor.actions.read reads the person's items.++ const now = Math.floor(Date.now() / 1000);++ const reader = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.actor'], cap: ['actor.actions.read'], iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);++ const feed = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });++ assert.strictEqual(feed.status, 200, feed.text.slice(0, 200));++ const items = feed.json().items || [];++ const item = items.find((i) => i.kind === 'approval' && String(i.summary || '').includes('12 Vibes'));++ assert.ok(item, 'the payment is an Action Center item');++ assert.strictEqual(item.bucket, 'done');++ // The export: the rows of the payments are the person's own.++ const { TABLES } = require('../server/identity/account-data');++ assert.ok(TABLES.some((x) => x.table === 'actor_spends'), 'the payments are in the export');++ const rows = await t.ctx.s.db.many('SELECT * FROM actor_spends WHERE owner = $1', [owner]);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].state, 'paid');++ assert.strictEqual(rows[0].txn_id, tips.payments.get(ask.id).id);++ });++++ await check('a tip is never paid by a service, nor by approve-always', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ const ask = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'money', summary: 'Tip 5 Vibes to Bo: no service',++ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 5, reason: 'no service' },++ });++ const before = tips.calls.length;++ // The Action Center's service token has no person behind it: it can decline, never pay.++ const service = await approvals.decide(t.ctx, owner, ask.id, 'approve');++ assert.ok(service.error && service.pending, 'a service approval does not pay');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'and the request stays pending');++ // Even the person's own token cannot make a money step always-allowed.++ const always = await approvals.decide(t.ctx, owner, ask.id, 'approve_always', { personToken: t.network.userToken(ada) });++ assert.ok(always.error, 'approve-always is refused for money');++ assert.match(String(always.error), /always asks/);++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending');++ assert.strictEqual(tips.calls.length, before, 'nothing was paid');++ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null);++ const declined = await approvals.decide(t.ctx, owner, ask.id, 'decline');++ assert.strictEqual(declined.row.state, 'declined');++ });++++ await check('a definite refusal from Tips marks the claim failed and keeps the request pending', async () => {++ const ask = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'money', summary: 'Tip 5 Vibes to Bo: another one',++ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 5, reason: 'another one' },++ });++ // Tips refuses this one: not enough Vibes (a 409, a definite answer: no money moved).++ const before = tips.calls.length;++ tips.state.refuse = { status: 409, code: 'billing.insufficient_funds', detail: 'Not enough Vibes on this account.' };++ try {++ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 200, res.text.slice(0, 200));++ assert.ok(res.text.includes('Not enough Vibes'), 'the reason is shown');++ assert.ok(res.text.includes('Approve and pay'), 'the card is still waiting');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'it stays pending');++ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null, 'nothing was paid');++ assert.strictEqual(tips.calls.length, before + 1, 'Tips was asked once');++ const failed = await t.ctx.s.db.maybe("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'failed'", [ask.id]);++ assert.ok(failed, 'the claim is kept as a failed attempt');++ // A refused attempt is not a payment: it does not eat into the week.++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 12);++ } finally {++ tips.state.refuse = null;++ }++ // The person may try again once the cause is fixed.++ const retry = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(retry.status, 303, retry.text.slice(0, 200));++ assert.strictEqual(tips.calls.length, before + 2, 'the retry asked Tips again');++ assert.ok(await spend.paidFor(t.ctx.s, owner, ask.id), 'the retry paid once');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 17, 'only the payment counts');++ });++++ await check('every 4xx from Tips is definite (no money moved), except an idempotency claim still in progress', async () => {++ const answer = (status, code = null) => async () => new Response(JSON.stringify({ code, detail: `answered ${status}` }), { status, headers: { 'content-type': 'application/json' } });++ const tipWith = (fetchImpl) => spend.createTips({ config: { services: { tipsUrl: 'https://tips.example' } }, fetchImpl })++ .tip({ token: 'tok', to: creator, amount: 5, key: 'apr_x' });++ for (const [status, code] of [[401, 'token.invalid'], [403, null], [404, 'tips.no_creator'], [409, 'billing.insufficient_funds'], [422, null], [429, 'rate.limited']]) {++ const out = await tipWith(answer(status, code));++ assert.strictEqual(out.definite, true, `${status} is definite`);++ assert.ok(!out.unknown, `${status} is not unknown`);++ }++ for (const [status, code] of [[409, 'idempotency.in_progress'], [500, null], [502, null], [503, null]]) {++ const out = await tipWith(answer(status, code));++ assert.strictEqual(out.unknown, true, `${status} ${code || ''} is unknown: it keeps counting`);++ }++ const thrown = await tipWith(async () => { throw new Error('socket hang up'); });++ assert.strictEqual(thrown.unknown, true, 'no answer at all is unknown');++ });++++ await check('an unknown answer keeps the claim paying, counts it, and a retry after the stale window replays it once', async () => {++ const goodTips = t.ctx.tips;++ t.ctx.tips = spend.createTips({ config: t.config, fetchImpl: globalThis.fetch, timeoutMs: 80 });++ const ask = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'money', summary: 'Tip 7 Vibes to Bo: maybe',++ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 7, reason: 'maybe' },++ });++ const before = tips.calls.length;++ tips.state.delayMs = 400; // the first request outlives the client's 80 ms: Tips records it, Actor cannot know++ try {++ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 200, res.text.slice(0, 200));++ assert.ok(res.text.includes(UNCONFIRMED), 'the person is told the tip is not confirmed');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'the card stays pending');++ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null, 'nothing is recorded as paid yet');++ const paying = await t.ctx.s.db.maybe("SELECT * FROM actor_spends WHERE approval_id = $1", [ask.id]);++ assert.ok(paying, 'the claim is kept');++ assert.strictEqual(paying.state, 'paying', 'an unknown answer is not a failure');++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 24, 'the paying claim counts against the week');++ // Turning the allowance off after the fact must not strand a payment that may already have moved.++ await spend.setAllowance(t.ctx.s, owner, agent.id, 0);++ } finally {++ tips.state.delayMs = 0;++ }++ // The first request finished at Tips (and recorded its interaction), though Actor never heard the answer.++ await new Promise((r) => setTimeout(r, 500));++ // After the stale window the same approval pays again: the same key, so Tips replays the one interaction.++ now += 6 * 60_000;++ t.ctx.tips = goodTips;++ const retry = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(retry.status, 303, retry.text.slice(0, 200));++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');++ const paid = await spend.paidFor(t.ctx.s, owner, ask.id);++ assert.ok(paid, 'the tip settled once');++ assert.strictEqual(paid.txn_id, tips.payments.get(ask.id).id, 'the replayed interaction is the record');++ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 24, 'the same payment did not count twice');++ const attempts = tips.calls.slice(before);++ assert.strictEqual(attempts.length, 2, 'the retry called Tips once more');++ assert.strictEqual(attempts[0].key, ask.id);++ assert.strictEqual(attempts[1].key, ask.id, 'the retry sends the same Idempotency-Key');++ assert.deepStrictEqual(attempts[1].body, attempts[0].body, 'with the same body');++ assert.strictEqual((await t.ctx.s.db.many("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'paid'", [ask.id])).length, 1);++ });++++ await check('two clicks at once pay once', async () => {++ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);++ const ask = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'money', summary: 'Tip 3 Vibes to Bo: together',++ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 3, reason: 'together' },++ });++ const before = tips.calls.length;++ const personToken = t.network.userToken(ada);++ await Promise.all([++ approvals.decide(t.ctx, owner, ask.id, 'approve', { personToken }),++ approvals.decide(t.ctx, owner, ask.id, 'approve', { personToken }),++ ]);++ assert.strictEqual(tips.calls.length, before + 1, 'Tips was called once for two clicks');++ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');++ assert.ok(await spend.paidFor(t.ctx.s, owner, ask.id), 'the tip was paid');++ const paidRows = await t.ctx.s.db.many("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'paid'", [ask.id]);++ assert.strictEqual(paidRows.length, 1, 'one payment row for two clicks');++ });++ } catch (err) {++ process.stdout.write(` ✗ the suite threw\n ${(err.stack || String(err)).split('\n').slice(0, 8).join('\n ')}\n`);++ process.exitCode = 1;++ } finally {++ await t.close();++ await tips.close();++ }++ done();++})();@@@ -57,6 +57,8 @@ const EXPECTED = {space_files_write: true,space_preview: false,space_publish: true,++ // Money (plan HO7): a tip asks the person, and the payment moves their Vibes when they approve it.++ spend_vibes: true,};(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+-- phase: expand
+-- HO7 (an agent that can spend): a weekly allowance per agent, in Vibes, and the payments made against it.
+--
+-- The allowance lives on the agent (spend_weekly_vibes): 0 means off, and an agent with no allowance is never offered
+-- the spending tool at all. The payments are their own rows (actor_spends): one claim per approval, reserved before
+-- Tips' checkout is called and settled when it answers, so the week can be counted and the person can see every tip
+-- their agent sent. A payment Tips definitely refused is kept as a failed attempt: it never counts against the
+-- allowance and never shows as a payment. A claim whose answer was unknown stays 'paying' and counts, because the
+-- money may have moved; the same idempotency key retries it.
+--
+-- Expand-only: a new column with a default and a new table. Nothing renamed, nothing dropped, so the previous
+-- release keeps writing rows and an old row reads as "no allowance".
+ALTER TABLE actor_agents
+ ADD COLUMN IF NOT EXISTS spend_weekly_vibes integer NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS spend_weekly_set_at text;
+
+CREATE TABLE IF NOT EXISTS actor_spends (
+ id text COLLATE "C" PRIMARY KEY, -- spn_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_… the person whose Vibes paid
+ agent_id text COLLATE "C" NOT NULL, -- agt_… the agent that asked
+ approval_id text COLLATE "C", -- the approval it was paid at (the idempotency key)
+ to_subject text COLLATE "C" NOT NULL, -- usr_… the creator who receives it
+ to_label text COLLATE "C", -- what the person read on the card (a name or a handle)
+ amount integer NOT NULL CHECK (amount > 0), -- Vibes (bits): whole Vibes, never a fraction
+ reason text COLLATE "C", -- the person's own words for it
+ target jsonb, -- what the money is for (a creator's page, a channel …)
+ state text COLLATE "C" NOT NULL CHECK (state IN ('paying', 'paid', 'failed')),
+ txn_id text COLLATE "C", -- Tips' interaction id for a paid tip
+ error text COLLATE "C", -- why a refusal did not pay
+ week_start text COLLATE "C" NOT NULL, -- the allowance week it counts in (a Monday, UTC)
+ created_at text COLLATE "C" NOT NULL
+);
+-- One live claim per approval: a second click can never pay twice, even if two clicks race. A failed attempt is not
+-- live, so the person may try again after topping up their Vibes.
+CREATE UNIQUE INDEX IF NOT EXISTS actor_spends_approval ON actor_spends (approval_id) WHERE state IN ('paying', 'paid');
+CREATE INDEX IF NOT EXISTS actor_spends_agent ON actor_spends (owner, agent_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS actor_spends_week ON actor_spends (owner, agent_id, week_start) WHERE state = 'paid';
@@ -121,11 +121,16 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
@@ -121,11 +121,16 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
function approvalItem(row, { agentName = null, thread = null } = {}) {
function approvalItem(row, { agentName = null, thread = null } = {}) {
const bucket = bucketFor(row.state);
const bucket = bucketFor(row.state);
const payload = parse(row.payload) || {};
const payload = parse(row.payload) || {};
+ // A payment (plan HO7): once approved, the item says what was paid and who received it, not what it would do.
+ // A pending tip keeps the ordinary words: nothing has been paid yet.
+ const money = row.kind === 'money' && row.state === 'approved' && payload && payload.spend === 'tip' && payload.amount
+ ? `Paid ${Number(payload.amount)} Vibes to ${payload.to_label || payload.to || 'a creator'}`
+ : null;
// The kind in plain words and the thread it came from (plan HO1): a raw agent id says nothing to a person,
// The kind in plain words and the thread it came from (plan HO1): a raw agent id says nothing to a person,
// so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own
// so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own
// (an old row) keeps its own words.
// (an old row) keeps its own words.
const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);
const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);
- const about = [kinds.label(row.kind), detail].filter(Boolean).join(' · ');
+ const about = money || [kinds.label(row.kind), detail].filter(Boolean).join(' · ');
return {
return {
service: 'actor',
service: 'actor',
id: row.id,
id: row.id,
@@ -249,6 +249,26 @@ function stepFor(name, input) {
@@ -249,6 +249,26 @@ function stepFor(name, input) {
};
};
}
}
+ // ── Money: a tip to a creator, with the person's own Vibes (plan HO7) ──
+ case 'spend_vibes': {
+ const to = text(a.to, 120);
+ const amount = Number(a.amount);
+ const reason = text(a.reason, 300);
+ const vibes = Number.isInteger(amount) && amount > 0 ? amount : 0;
+ return {
+ act: 'act',
+ kind: 'money',
+ // The floor: money always asks, at every level, and is never always allowed (agents/kinds.js).
+ category: 'money',
+ // The marker that says this money is a tip (plan HO7) and not some other money step: only a tip is
+ // prepared, capped and paid at the person's approval.
+ spend: true,
+ summary: `Tip ${vibes} Vibes to ${to || 'a creator'}${reason ? `: ${reason}` : ''}`,
+ target: to ? exactTarget(`tip ${vibes} Vibes to ${word(to)}`) : null,
+ payload: { to, amount: vibes, reason },
+ };
+ }
+
// ── Actor's own working state: never asks ──
// ── Actor's own working state: never asks ──
case 'make_file':
case 'make_file':
case 'make_chart':
case 'make_chart':
@@ -24,6 +24,7 @@ const { createTask } = require('../tasks/create');
@@ -24,6 +24,7 @@ const { createTask } = require('../tasks/create');
const levels = require('./levels');
const levels = require('./levels');
const kinds = require('./kinds');
const kinds = require('./kinds');
const allows = require('./allows');
const allows = require('./allows');
+const spend = require('./spend');
const KINDS = kinds.KINDS;
const KINDS = kinds.KINDS;
const EXPIRES_DAYS = 7;
const EXPIRES_DAYS = 7;
@@ -85,8 +86,12 @@ function floorOf(row) {
@@ -85,8 +86,12 @@ function floorOf(row) {
* refused for a floor step (money, a password, a deletion, a new recipient), which asks every time.
* refused for a floor step (money, a password, a deletion, a new recipient), which asks every time.
* With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON
* With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON
* Action Center route); the HTML page keeps its 409.
* Action Center route); the HTML page keeps its 409.
+ *
+ * A payment (plan HO7, the kind 'money') is decided differently: the money moves now, with the person's own token,
+ * and only from the person's own session. A service that decides for them (the Action Center) never pays: there is
+ * no person behind the service token, and the approval stays pending until the person approves it themselves.
*/
*/
-async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
+async function decide(ctx, owner, id, decision, { idempotent = false, personToken = null } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
const row = await get(s, owner, id);
const row = await get(s, owner, id);
if (!row) return { error: 'No such request of yours.', status: 404 };
if (!row) return { error: 'No such request of yours.', status: 404 };
@@ -99,6 +104,13 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
@@ -99,6 +104,13 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
return { row: done };
return { row: done };
}
}
if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };
if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };
+ // A tip (plan HO7): the person's own approval pays, now. Any other money step (a payment tool reached through
+ // OpenVibe.Tools) keeps the old behaviour: it asks, and approving it means the person runs it again.
+ const money = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : (row.payload || {});
+ if (row.kind === 'money' && money.spend === 'tip') {
+ if (decision === 'approve_always') return { error: 'This step always asks, so it can never be always allowed.', status: 422 };
+ return await pay(ctx, row, { personToken });
+ }
const always = decision === 'approve_always';
const always = decision === 'approve_always';
if (always) {
if (always) {
const floored = floorOf(row);
const floored = floorOf(row);
@@ -142,4 +154,70 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
@@ -142,4 +154,70 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };
return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };
}
}
-module.exports = { request, list, get, pendingCount, decide, floorOf, KINDS, EXPIRES_DAYS };
+/**
+ * A payment, at the person's own approval (plan HO7). The money moves through OpenVibe.Tips' checkout with the
+ * person's own token: never a service token, never later from a worker. The approval id is the idempotency key, so a
+ * second click (or two clicks at once) never pays twice. A definite refusal from Tips shows its reason and leaves the
+ * approval pending, so the person can top up their Vibes and try again. An unknown answer keeps the claim 'paying':
+ * the money may have moved, so it still counts against the week, and the retry after the stale window sends the same
+ * key, which Tips replays.
+ */
+async function pay(ctx, row, { personToken = null } = {}) {
+ const { s, tips } = ctx;
+ const owner = row.owner;
+ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : (row.payload || {});
+ const amount = Number(payload.amount);
+ const to = String(payload.to || '');
+ const agentId = row.agent_id || '';
+ // Only the person may pay, with their own session. A service deciding for them never moves money.
+ if (!personToken) {
+ return { error: 'A payment is paid by you, on this page, with your own Vibes. Nothing was paid.', status: 409, pending: true };
+ }
+ if (!tips || typeof tips.tip !== 'function') {
+ return { error: 'OpenVibe.Tips is not connected on this server, so nothing was paid.', status: 503, pending: true };
+ }
+ // Reserve the payment first (one claim per approval, one weekly cap per agent), then move the money with the
+ // person's own token. The approval id is Tips' idempotency key, so a retry replays instead of paying twice.
+ const reserved = await spend.claim(s, {
+ owner, agentId, approvalId: row.id, to, toLabel: payload.to_label || null, amount,
+ reason: payload.reason || null, target: payload.target || null,
+ });
+ if (reserved.paid) return { row: await approvePaid(s, owner, row), spend: reserved.paid, note: 'This tip was already paid.' };
+ if (reserved.busy) return { error: 'This tip is already being paid. Refresh this page in a moment.', status: 409, pending: true };
+ if (reserved.error) return { error: reserved.error, status: reserved.status || 422, pending: true };
+ const out = await tips.tip({
+ token: personToken, to, amount,
+ message: payload.reason || null,
+ key: row.id,
+ });
+ if (!out.ok) {
+ if (out.unknown) {
+ // The money may have moved: keep the claim 'paying'. It counts against the week, the card stays pending,
+ // and the retry after the stale window sends the same Idempotency-Key, which Tips replays.
+ return { error: out.error, status: 502, pending: true, unconfirmed: true };
+ }
+ await spend.fail(s, reserved.claim.id, out.error).catch(() => null);
+ const status = [409, 422].includes(out.status) ? out.status : 502;
+ return { error: out.error, status, pending: true };
+ }
+ const paid = await spend.settle(s, reserved.claim.id, out.id || null);
+ if (!paid) {
+ const recorded = await spend.paidFor(s, owner, row.id);
+ if (!recorded) {
+ return {
+ error: 'OpenVibe.Tips paid the tip, but Actor could not record it. Reload this page; nothing will be paid twice.',
+ status: 503, pending: true,
+ };
+ }
+ return { row: await approvePaid(s, owner, row), spend: recorded, note: `Paid ${amount} Vibes to ${payload.to_label || to}.` };
+ }
+ return { row: await approvePaid(s, owner, row), spend: paid, note: `Paid ${amount} Vibes to ${payload.to_label || to}.` };
+}
+
+/** Mark the approval approved, once; a concurrent click that already did answers the same row. */
+async function approvePaid(s, owner, row) {
+ return await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = 'once' WHERE id = $1 AND state = 'pending' RETURNING *", [row.id, s.iso(), owner])
+ || await get(s, owner, row.id) || row;
+}
+
+module.exports = { request, list, get, pendingCount, decide, pay, floorOf, KINDS, EXPIRES_DAYS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,326 @@
@@ -0,0 +1,326 @@
+'use strict';
+
+/**
+ * An agent's money (plan HO7, "an agent that can spend"): a weekly allowance in Vibes per agent, and the payments
+ * made against it.
+ *
+ * allowance(s, owner, agentId) → { agent_id, weekly, spent, left, week }: what it may spend this week
+ * setAllowance(s, owner, agentId, amount) → the person's own setting (0 = off: the tool is not offered at all)
+ * check(s, { owner, agentId, amount }) → { ok, message }: an ask over what is left is refused with one plain line
+ * claim(s, ...) / settle(s, id, txnId) / fail(s, id, error)
+ * → the payment row, reserved before Tips is called, then settled or refused
+ * resolve(config, to) → the creator a tip names: a person (usr_…) or a creator's OpenVibe page
+ * listFor(s, owner, ...) → the payments and refused attempts (newest first)
+ *
+ * The money itself moves in OpenVibe.Tips (its checkout, which makes the Billing transfer): the person approves, and
+ * the payment is made with THEIR token, at that moment, once (the approval id is the idempotency key). Nothing in
+ * this file ever moves money by itself: an agent can only ask, and only the person's own approval pays.
+ *
+ * Vibes are whole (Billing's bits, one bit to the cent): an allowance and a tip are integers, never fractions.
+ */
+
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+// A week's allowance in Vibes: 100,000 Vibes is 1,000 USD of creator value at Billing's rate (100 bits to the dollar).
+const MAX_ALLOWANCE = 100_000;
+// The smallest tip that means anything: one Vibe.
+const MIN_TIP = 1;
+
+const text = (v, n = 200) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
+const int = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : null; };
+
+/**
+ * The allowance week: Monday 00:00 UTC to Sunday 23:59 UTC. The key is the Monday's date, so every row of a week
+ * counts in the same week and the person reads one plain date range.
+ */
+function weekStart(at = new Date()) {
+ const d = new Date(at);
+ if (!Number.isFinite(d.getTime())) return new Date().toISOString().slice(0, 10);
+ const day = (d.getUTCDay() + 6) % 7; // Monday is the first day of the week
+ d.setUTCDate(d.getUTCDate() - day);
+ d.setUTCHours(0, 0, 0, 0);
+ return d.toISOString().slice(0, 10);
+}
+
+/** The week a date belongs to, and the Sunday it ends on (for the card). */
+function weekRange(at = new Date()) {
+ const start = weekStart(at);
+ const end = new Date(Date.parse(`${start}T00:00:00.000Z`) + 6 * 86_400_000).toISOString().slice(0, 10);
+ return { start, end };
+}
+
+/** What this agent may spend this week, and what is left of it. */
+async function allowance(s, owner, agentId) {
+ const week = weekStart(s.now());
+ const row = await s.db.maybe('SELECT spend_weekly_vibes FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);
+ const weekly = row ? Number(row.spend_weekly_vibes || 0) : 0;
+ // A payment still 'paying' may already have moved (an unknown answer from Tips): it counts against the week,
+ // exactly as the lock in claim() counts it. Only a definite refusal (a 'failed' row) never does.
+ const spent = Number(await s.db.value(
+ "SELECT COALESCE(SUM(amount), 0) FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND week_start = $3 AND state IN ('paid', 'paying')",
+ [owner, agentId, week]));
+ return { agent_id: agentId, weekly, spent: Number(spent) || 0, left: Math.max(0, weekly - (Number(spent) || 0)), week, ...weekRange(s.now()) };
+}
+
+/**
+ * The person's own setting: how many Vibes this agent may spend a week. 0 turns the spending tool off (it is then
+ * never offered to the agent at all). The setting is the person's, never the agent's.
+ */
+async function setAllowance(s, owner, agentId, amount) {
+ const value = int(amount);
+ if (value == null) return { error: 'The weekly allowance must be a whole number of Vibes, 0 or more.' };
+ if (value > MAX_ALLOWANCE) return { error: `The weekly allowance may be at most ${MAX_ALLOWANCE} Vibes.` };
+ const row = await s.db.maybe('SELECT id FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);
+ if (!row) return { error: 'No such agent of yours.' };
+ await s.db.query('UPDATE actor_agents SET spend_weekly_vibes = $3, spend_weekly_set_at = $4, updated_at = $4 WHERE id = $1 AND owner = $2',
+ [agentId, owner, value, s.iso()]);
+ return { row: { id: agentId, spend_weekly_vibes: value } };
+}
+
+/**
+ * May this agent spend this much, right now? An ask over what is left is refused here, before the person ever sees
+ * it, with one plain line the agent can say out loud.
+ */
+async function check(s, { owner, agentId, amount }) {
+ const a = await allowance(s, owner, agentId);
+ const want = int(amount);
+ if (!agentId) return { ok: false, ...a, message: 'This agent may not spend: it has no allowance of its own.' };
+ if (!a.weekly) {
+ return { ok: false, ...a, message: 'This agent may not spend anything: no weekly allowance is set on its page. Ask your person to set one before a tip.' };
+ }
+ if (want == null || want < MIN_TIP) {
+ return { ok: false, ...a, message: 'A tip is a whole number of Vibes, at least 1.' };
+ }
+ if (want > a.left) return { ok: false, ...a, message: overMessage(a.weekly, a.left, want) };
+ return { ok: true, ...a };
+}
+
+/**
+ * The creator a tip names, and the profile that says what a tip may be. A person on OpenVibe is their account id
+ * (usr_…); a creator's OpenVibe page is its handle (a channel). Tips resolves either, and its profile is also what
+ * says the page's minimum tip: paying less would be refused there, so it is refused here before the person sees a
+ * card. Nothing is guessed: a name that names nobody is refused, because money must never go to the wrong person.
+ *
+ * The label always carries the handle (a display name is not unique: anyone can call themselves "Josh"), and the
+ * usr_ id too when the tip named an id. The subject is what the checkout names: a handle can be renamed, a subject
+ * cannot.
+ */
+async function resolve(config, to, { fetchImpl = globalThis.fetch, signal = null } = {}) {
+ const value = text(to, 120);
+ const namedId = SUBJECT.test(value);
+ const handle = value.replace(/^@/, '').trim().toLowerCase();
+ if (!namedId && !handle) return { error: 'Who receives the tip? Give the creator\'s OpenVibe account id (usr_…) or their OpenVibe page name.' };
+ const base = config && config.services ? config.services.tipsUrl : '';
+ if (!base) return { error: 'This server cannot reach OpenVibe.Tips, so no tip can be paid.' };
+ const ref = namedId ? value : handle;
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/profiles/${encodeURIComponent(ref)}`, {
+ headers: { Accept: 'application/json' },
+ signal: signal || AbortSignal.timeout(8_000),
+ });
+ } catch (err) {
+ return { error: `OpenVibe could not be reached to look up "${ref}", so no tip was asked for.` };
+ }
+ if (!res.ok) {
+ if (res.status === 404) return { error: `No OpenVibe page or person is called "${ref}". A tip needs the creator's account id (usr_…) or their OpenVibe page name.` };
+ return { error: `OpenVibe could not look up "${ref}" (it answered ${res.status}), so no tip was asked for.` };
+ }
+ const data = await res.json().catch(() => null);
+ const profile = data && data.profile ? data.profile : null;
+ const creator = profile && profile.creator ? profile.creator : null;
+ const subject = creator && typeof creator.id === 'string' ? creator.id : null;
+ const foundHandle = text(profile && profile.handle, 40);
+ if (!subject || !SUBJECT.test(subject) || !foundHandle) {
+ return { error: `No OpenVibe page or person is called "${ref}". A tip needs the creator's account id (usr_…) or their OpenVibe page name.` };
+ }
+ const name = text(profile.display_name, 80) || foundHandle;
+ const label = `${name} (@${foundHandle})${namedId ? ` · ${subject}` : ''}`;
+ const target = { service: 'tips', type: 'creator', id: foundHandle, label: text(label, 200) };
+ const tipMin = Number(profile.minimums && profile.minimums.tip);
+ return {
+ subject, label: text(label, 200), target, named_id: namedId,
+ min: Number.isInteger(tipMin) && tipMin > 0 ? tipMin : MIN_TIP,
+ };
+}
+
+/**
+ * A tip, ready to be asked for: the recipient is resolved to the person who receives the money (and what Tips says
+ * is the page's minimum), and the agent's weekly allowance is checked. Either way the answer is one plain line, and
+ * nothing is asked of the person when it says no.
+ */
+async function prepare({ config, s, owner, agentId, payload, fetchImpl = globalThis.fetch } = {}) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const amount = int(p.amount);
+ if (amount == null || amount < MIN_TIP) return { ok: false, message: 'A tip is a whole number of Vibes, at least 1.' };
+ if (!agentId) return { ok: false, message: 'This agent may not spend: it has no allowance of its own.' };
+ const left = await check(s, { owner, agentId, amount });
+ if (!left.ok) return { ok: false, message: left.message };
+ const who = await resolve(config, p.to, { fetchImpl });
+ if (who.error) return { ok: false, message: who.error };
+ // Tips' own words for its profile's minimum: a smaller tip would be refused there, so it never reaches a card.
+ if (amount < who.min) return { ok: false, message: `The minimum for a tip is ${who.min} Vibes.` };
+ // Tips refuses a transfer to yourself; say so before the person sees a card that could never be paid.
+ if (who.subject === String(owner || '').replace(/^user:/, '')) {
+ return { ok: false, message: 'A tip to yourself would not go anywhere: name a creator on OpenVibe.' };
+ }
+ const reason = text(p.reason, 300);
+ return {
+ ok: true,
+ // The marker says this is a tip (plan HO7): only a tip is paid at the person's approval.
+ payload: { spend: 'tip', to: who.subject, to_label: who.label, amount, reason, target: who.target, named_id: who.named_id },
+ summary: `Tip ${amount} Vibes to ${who.label}${reason ? `: ${reason}` : ''}`,
+ left,
+ };
+}
+
+/**
+ * The payment itself, through OpenVibe.Tips' checkout. It is made with the person's own token (the session they
+ * approved with), never with a service token and never later from a worker. Tips records the interaction (so the
+ * creator sees the tip like any other), mints the person's assertion and makes the Billing transfer. The approval id
+ * is the idempotency key, so a second click (or a retry of an unknown one) never pays twice.
+ *
+ * A definite refusal (Tips' 409 refusals, or a 422 validation one) means no money moved. Anything else — a network
+ * error, a timeout, a 5xx, or Tips still handling the first request — is unknown: the money may have moved, so the
+ * caller must keep the claim counting and retry with this same key, which Tips replays.
+ */
+const UNCONFIRMED = 'We could not confirm the tip yet; nothing will be paid twice.';
+
+function createTips({ config, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
+ const base = config && config.services ? config.services.tipsUrl : '';
+ const enabled = !!base;
+ async function tip({ token, to, amount, message = null, key, signal = null }) {
+ if (!enabled) return { ok: false, definite: true, status: 503, error: 'OpenVibe.Tips is not connected on this server, so no tip can be paid.' };
+ if (!token) return { ok: false, definite: true, status: 401, error: 'This payment needs the person\'s own session.' };
+ if (!key) return { ok: false, definite: true, status: 500, error: 'This payment has no idempotency key, so nothing was paid.' };
+ // The resolved creator (a usr_ id) and what was read on the card; no URL or name from the model is sent.
+ const body = { creator: String(to), amount, pay_with: 'credit' };
+ if (message) body.message = message;
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/checkout`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'Idempotency-Key': String(key),
+ },
+ body: JSON.stringify(body),
+ signal: signal || AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ return { ok: false, unknown: true, error: UNCONFIRMED };
+ }
+ const data = await res.json().catch(() => null);
+ if (res.ok) {
+ const interaction = data && data.interaction ? data.interaction : null;
+ return {
+ ok: true, status: res.status, interaction,
+ id: interaction && interaction.id ? interaction.id : null,
+ replay: res.headers.get('idempotent-replayed') === 'true',
+ };
+ }
+ const code = (data && data.code) || null;
+ const detail = (data && (data.detail || data.title)) || `OpenVibe.Tips answered ${res.status}`;
+ // Any 4xx is Tips refusing before it moved money (409 not enough Vibes or not accepting, 422 validation, 401/403
+ // the session, 404 no such creator, 429 too many): definite. Except the idempotency claim still in progress,
+ // where the first request may pay; that, a 5xx and no answer at all are unknown and keep counting.
+ if (res.status >= 400 && res.status < 500 && !(res.status === 409 && code === 'idempotency.in_progress')) {
+ return { ok: false, definite: true, status: res.status, code, error: String(detail).slice(0, 300) };
+ }
+ return { ok: false, unknown: true, status: res.status, code, error: UNCONFIRMED };
+ }
+ return { enabled, tip };
+}
+
+/** One plain line for an ask over what is left this week. */
+function overMessage(weekly, left, want) {
+ return `This agent may spend ${weekly} Vibes a week; ${left} ${left === 1 ? 'is' : 'are'} left this week, and this tip is ${want}. Ask your person to raise the allowance on the agent's page.`;
+}
+
+/**
+ * Reserve one payment before Tips is called. The row is 'paying' until Tips answers, so:
+ * - two clicks on the same approval cannot both call Tips (the partial unique index on approval_id);
+ * - two tips of the same agent cannot both pass the weekly cap (the agent's row is locked while the week is summed).
+ * Tips still holds the approval id as its idempotency key, so a claim abandoned by a crash (or left 'paying' by an
+ * unknown answer) is safe to retry: the retry reserves the same row and Tips replays instead of moving money twice.
+ */
+async function claim(s, { owner, agentId = null, approvalId = null, to, toLabel = null, amount, reason = null, target = null } = {}) {
+ const want = int(amount);
+ if (!agentId) return { error: 'This agent may not spend: it has no allowance of its own.' };
+ if (want == null || want < MIN_TIP) return { error: 'A tip is a whole number of Vibes, at least 1.' };
+ if (!approvalId) return { error: 'This payment has no approval to key it to, so nothing was paid.' };
+ try {
+ return await s.tx(async () => {
+ const paid = await paidFor(s, owner, approvalId);
+ if (paid) return { paid };
+ // Lock the live claim first: two retries of one unknown payment serialize, so only one sends the key.
+ const inFlight = await s.db.maybe("SELECT * FROM actor_spends WHERE owner = $1 AND approval_id = $2 AND state = 'paying' FOR UPDATE", [owner, approvalId]);
+ // Then this agent's allowance row: two different tips of one agent cannot both pass the cap.
+ const agent = await s.db.maybe('SELECT spend_weekly_vibes FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agentId, owner]);
+ const weekly = agent ? Number(agent.spend_weekly_vibes || 0) : 0;
+ const now = s.iso();
+ if (inFlight) {
+ if (Date.parse(now) - Date.parse(inFlight.created_at) <= 5 * 60_000) return { busy: true, claim: inFlight };
+ // The same payment, already reserved and cap-checked before Tips was called: retry it with the same
+ // key, even if the allowance changed meanwhile. The money may already have moved; Tips replays it.
+ const again = await s.db.one("UPDATE actor_spends SET created_at = $2 WHERE id = $1 AND state = 'paying' RETURNING *", [inFlight.id, now]);
+ return again ? { claim: again } : { busy: true };
+ }
+ if (!weekly) {
+ return { error: 'This agent may not spend anything: no weekly allowance is set on its page. Ask your person to set one before a tip.' };
+ }
+ const week = weekStart(s.now());
+ // Paying claims count against the week too: the money may already have moved while the row is settled.
+ const used = Number(await s.db.value(
+ "SELECT COALESCE(SUM(amount), 0) FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND week_start = $3 AND state IN ('paid', 'paying') AND approval_id IS DISTINCT FROM $4",
+ [owner, agentId, week, approvalId])) || 0;
+ if (used + want > weekly) return { error: overMessage(weekly, Math.max(0, weekly - used), want) };
+ const row = await s.db.one(`INSERT INTO actor_spends (id, owner, agent_id, approval_id, to_subject, to_label, amount, reason, target, state, week_start, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 'paying', $10, $11)
+ ON CONFLICT DO NOTHING RETURNING *`,
+ [s.newId('spn'), owner, agentId, approvalId, to, toLabel, want, reason, target ? JSON.stringify(target) : null, week, now]);
+ if (row) return { claim: row };
+ // Another click won the race: the first payment is the one that counts.
+ const won = await paidFor(s, owner, approvalId);
+ return won ? { paid: won } : { busy: true };
+ });
+ } catch (err) {
+ return { error: 'The payment could not be reserved, so nothing was paid. Try again in a moment.', status: 503 };
+ }
+}
+
+/** Tips paid: the reserved row becomes the payment of the week (its interaction id is the record). */
+async function settle(s, id, txnId = null) {
+ return await s.db.one("UPDATE actor_spends SET state = 'paid', txn_id = $2, error = NULL WHERE id = $1 AND state = 'paying' RETURNING *", [id, txnId]);
+}
+
+/** Tips definitely refused: the reserved row is kept as a failed attempt, and never counts against the week. */
+async function fail(s, id, error) {
+ return await s.db.one("UPDATE actor_spends SET state = 'failed', error = $2 WHERE id = $1 AND state = 'paying' RETURNING *", [id, String(error || '').slice(0, 300)]);
+}
+
+/** The payment of one approval, when it was paid. */
+async function paidFor(s, owner, approvalId) {
+ if (!approvalId) return null;
+ return await s.db.maybe("SELECT * FROM actor_spends WHERE owner = $1 AND approval_id = $2 AND state = 'paid'", [owner, approvalId]);
+}
+
+/**
+ * The payments and refused attempts of one agent (or of the whole person), newest first. A reservation still in
+ * flight is not a payment yet, so it is not listed here.
+ */
+async function listFor(s, owner, { agentId = null, limit = 50 } = {}) {
+ const rows = agentId
+ ? await s.db.many("SELECT * FROM actor_spends WHERE owner = $1 AND agent_id = $2 AND state IN ('paid', 'failed') ORDER BY created_at DESC LIMIT $3", [owner, agentId, limit])
+ : await s.db.many("SELECT * FROM actor_spends WHERE owner = $1 AND state IN ('paid', 'failed') ORDER BY created_at DESC LIMIT $2", [owner, limit]);
+ return rows;
+}
+
+module.exports = {
+ SUBJECT, MAX_ALLOWANCE, MIN_TIP,
+ weekStart, weekRange,
+ allowance, setAllowance, check, prepare,
+ resolve, createTips,
+ claim, settle, fail, paidFor, listFor,
+};
@@ -160,9 +160,11 @@ async function createApp(opts = {}) {
@@ -160,9 +160,11 @@ async function createApp(opts = {}) {
// The person's computers (agents/computers.js): one adapter for the task's still proxy, made here so a test can
// The person's computers (agents/computers.js): one adapter for the task's still proxy, made here so a test can
// stand Bot/Run in (opts.bot.computers) exactly as the Console pages do.
// stand Bot/Run in (opts.bot.computers) exactly as the Console pages do.
const computers = (opts.bot && opts.bot.computers) || require('./agents/computers').createComputers({ config, fetchImpl: providerFetch, log });
const computers = (opts.bot && opts.bot.computers) || require('./agents/computers').createComputers({ config, fetchImpl: providerFetch, log });
+ // An agent's money (plan HO7): the client that pays a tip through OpenVibe.Tips with the person's own token.
+ const tips = require('./agents/spend').createTips({ config, fetchImpl });
// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine
// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine
// reports each run's end back here, and the approvals decide() asks it to send an approved draft.
// reports each run's end back here, and the approvals decide() asks it to send an approved draft.
- const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media };
+ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media, tips };
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
engine.setMailReplies((task) => ctx.agentMail.answered(task));
engine.setMailReplies((task) => ctx.agentMail.answered(task));
// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and
// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and
@@ -178,6 +178,10 @@ function load(env = process.env) {
@@ -178,6 +178,10 @@ function load(env = process.env) {
// OpenVibe.Watch: where a trigger's subscription lives (plan §12 A-4). The person's own token is
// OpenVibe.Watch: where a trigger's subscription lives (plan §12 A-4). The person's own token is
// always the authority; empty turns triggers off.
// always the authority; empty turns triggers off.
watchUrl: trim(env.ACTOR_WATCH_URL != null ? env.ACTOR_WATCH_URL : (selfHosted ? '' : 'http://127.0.0.1:4730')),
watchUrl: trim(env.ACTOR_WATCH_URL != null ? env.ACTOR_WATCH_URL : (selfHosted ? '' : 'http://127.0.0.1:4730')),
+ // OpenVibe.Tips: where a tip is paid (its checkout makes the Billing transfer) and where a creator's
+ // page is looked up to resolve the person who receives it (plan HO7). The person's own token pays, at
+ // their own approval; empty turns the spending tool off.
+ tipsUrl: trim(env.ACTOR_TIPS_URL != null ? env.ACTOR_TIPS_URL : (selfHosted ? '' : 'http://127.0.0.1:4610')),
// OpenVibe.Mail (track HO6): an agent answers the mail sent to its own address. Actor reads one letter in
// OpenVibe.Mail (track HO6): an agent answers the mail sent to its own address. Actor reads one letter in
// full text and sends the reply from the agent's inbox, over loopback under its own service token. Empty
// full text and sends the reply from the agent's inbox, over loopback under its own service token. Empty
// turns the mail consumer off.
// turns the mail consumer off.
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases'];
+const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -15,6 +15,7 @@ const shares = require('../agents/voice-shares');
@@ -15,6 +15,7 @@ const shares = require('../agents/voice-shares');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
const allows = require('../agents/allows');
const allows = require('../agents/allows');
const kinds = require('../agents/kinds');
const kinds = require('../agents/kinds');
+const spend = require('../agents/spend');
const skills = require('../skills');
const skills = require('../skills');
/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */
/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */
@@ -162,15 +163,17 @@ ${importPanel()}<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${
@@ -162,15 +163,17 @@ ${importPanel()}<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const rows = await approvals.list(s, owner(req));
const rows = await approvals.list(s, owner(req));
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
-<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
+<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A tip your agent asks to send is different: approving it pays it at once, from your own Vibes, with your own session. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">${kinds.label(a.kind)} · Asked ${time(a.created_at)}</span></li>`)}</ul>`
${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">${kinds.label(a.kind)} · Asked ${time(a.created_at)}</span></li>`)}</ul>`
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
<p><a href="/me/agents">Your agents</a></p>`);
<p><a href="/me/agents">Your agents</a></p>`);
});
});
- r.get('/me/approvals/:id', async (req, res) => {
- if (!signed(req)) return login(req, res);
- const a = await approvals.get(s, owner(req), req.params.id);
- if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);
+ /**
+ * The approval card. A payment (plan HO7) says more than the other kinds: how much, who receives it, why, and
+ * what is left of the agent's weekly allowance — the four things a person needs before they pay with their own
+ * Vibes.
+ */
+ const approvalCard = async (req, res, a, { notice = null, status = 200 } = {}) => {
const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
// The agent by its name (a person's own agent: another person's is never looked up here).
// The agent by its name (a person's own agent: another person's is never looked up here).
const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;
const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;
@@ -179,6 +182,8 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
@@ -179,6 +182,8 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
// say, and a raw command that deletes by its argv (rm, shred …), the same checks approvals.decide uses.
// say, and a raw command that deletes by its argv (rm, shred …), the same checks approvals.decide uses.
const floor = approvals.floorOf(a).filter((c) => CAUTION[c]);
const floor = approvals.floorOf(a).filter((c) => CAUTION[c]);
const coding = a.kind === 'codes.task';
const coding = a.kind === 'codes.task';
+ // A tip (plan HO7) is the only money step with a card of its own: what it pays, who receives it and why.
+ const money = a.kind === 'money' && p && p.spend === 'tip' ? p : null;
const cmd = coding ? null : commandLine(p);
const cmd = coding ? null : commandLine(p);
const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
@@ -191,27 +196,51 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
@@ -191,27 +196,51 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
: floor.length ? html`<p class="pa-hint">This always asks, so it can never be always allowed.</p>`
: floor.length ? html`<p class="pa-hint">This always asks, so it can never be always allowed.</p>`
: canAllow ? html`<p class="pa-hint">Always allowing covers exactly this, and nothing else: ${kinds.phrase(a.kind, a.target)}. Anything different still asks. You can take it back on <a href="/me/agents/${a.agent_id}#always">${agentName}'s page</a>.</p>`
: canAllow ? html`<p class="pa-hint">Always allowing covers exactly this, and nothing else: ${kinds.phrase(a.kind, a.target)}. Anything different still asks. You can take it back on <a href="/me/agents/${a.agent_id}#always">${agentName}'s page</a>.</p>`
: '';
: '';
+ // What is left of this agent's allowance this week, and what the tip would take of it.
+ const left = money && a.agent_id ? await spend.allowance(s, owner(req), a.agent_id) : null;
+ const paid = money ? await spend.paidFor(s, owner(req), a.id) : null;
const decided = a.state === 'approved'
const decided = a.state === 'approved'
- ? (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.')
+ ? (paid ? `You approved this once and paid ${paid.amount} Vibes to ${paid.to_label || paid.to_subject}.`
+ : (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.'))
: (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
: (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
const buttons = a.state === 'pending'
const buttons = a.state === 'pending'
- ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : 'Approve for a retry'}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`
+ ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : (money ? 'Approve and pay' : 'Approve for a retry')}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`
: html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;
: html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;
+ const moneyCard = money ? html`<section class="pa-panel"><h2>What it would do</h2>
+<p>It would send <strong>${Number(money.amount) || 0} Vibes</strong> to ${money.to_label || money.to}.</p>
+${money.reason ? html`<p>Why: ${money.reason}</p>` : ''}
+${left ? html`<p>Left this week for ${agentName}: <strong>${left.left} of ${left.weekly} Vibes</strong> (${left.start} to ${left.end} UTC).</p>` : ''}
+<p class="pa-hint">The money moves when you approve, from your own Vibes and with your own session. It is never paid later and never by the agent itself.</p></section>` : '';
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
<p>${approvalPill(a)} <span class="pa-when">${kinds.label(a.kind)} · Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>
<p>${approvalPill(a)} <span class="pa-when">${kinds.label(a.kind)} · Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>
+${notice ? html`<p class="pa-caution" role="alert">${notice}</p>` : ''}
${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}
${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}
-${!coding
- ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
+${money ? moneyCard : (coding
+ ? html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
+<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`
+ : html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
-<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
- : html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
-<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}
-${buttons}`);
+<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`)}
+${buttons}`, status);
+ };
+ r.get('/me/approvals/:id', async (req, res) => {
+ if (!signed(req)) return login(req, res);
+ const a = await approvals.get(s, owner(req), req.params.id);
+ if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);
+ return approvalCard(req, res, a);
});
});
r.post('/me/approvals/:id', async (req, res) => {
r.post('/me/approvals/:id', async (req, res) => {
if (!write(req, res)) return;
if (!write(req, res)) return;
- const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''));
- if (out.error) return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);
+ // A payment is paid with the person's own token: the session they are sitting in (plan HO7). A service
+ // deciding for them never pays, and the request stays pending until they do it themselves.
+ const personToken = req.viewer && req.viewer.token ? req.viewer.token : null;
+ const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''), { personToken });
+ if (out.error) {
+ // A refusal that leaves the request pending: the reason is shown on the card and the person may try again.
+ const row = await approvals.get(s, owner(req), req.params.id);
+ if (out.pending && row) return approvalCard(req, res, row, { notice: out.error });
+ return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);
+ }
res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);
res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);
});
});
@@ -401,6 +430,9 @@ ${signed(req) ? html`<form method="post" action="/voices/${row.id}/borrow"><butt
@@ -401,6 +430,9 @@ ${signed(req) ? html`<form method="post" action="/voices/${row.id}/borrow"><butt
const tidyChanges = await tidy.changes(s, a.owner, { agentId: a.id, limit: 10 });
const tidyChanges = await tidy.changes(s, a.owner, { agentId: a.id, limit: 10 });
// What this agent may always do (plan HO1), and every decision the person made about its asks.
// What this agent may always do (plan HO1), and every decision the person made about its asks.
const allowRules = await allows.list(s, a.owner, a.id);
const allowRules = await allows.list(s, a.owner, a.id);
+ // What it may spend this week (plan HO7), and what it has spent.
+ const allowance = await spend.allowance(s, a.owner, a.id);
+ const payments = await spend.listFor(s, a.owner, { agentId: a.id, limit: 20 });
const decisions = await s.db.many(`SELECT id, kind, summary, state, decision_scope, decided_by, decided_at, created_at
const decisions = await s.db.many(`SELECT id, kind, summary, state, decision_scope, decided_by, decided_at, created_at
FROM actor_approvals WHERE owner = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 20`, [a.owner, a.id]);
FROM actor_approvals WHERE owner = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 20`, [a.owner, a.id]);
// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.
// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.
@@ -443,6 +475,14 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r
@@ -443,6 +475,14 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r
<p class="pa-hint">When you said "always allow this for this agent", the permission is here: one kind of step, on one target. Take any of them back and the next one asks again. Spending money, a password, a deletion and a new recipient always ask, whatever is allowed here.</p>
<p class="pa-hint">When you said "always allow this for this agent", the permission is here: one kind of step, on one target. Take any of them back and the next one asks again. Spending money, a password, a deletion and a new recipient always ask, whatever is allowed here.</p>
${allowRules.length ? html`<ul class="pa-notes">${allowRules.map((r) => html`<li><p class="pa-note-text">${kinds.phrase(r.kind, r.target)}</p><p class="pa-note-meta">Allowed ${time(r.created_at)}</p>
${allowRules.length ? html`<ul class="pa-notes">${allowRules.map((r) => html`<li><p class="pa-note-text">${kinds.phrase(r.kind, r.target)}</p><p class="pa-note-meta">Allowed ${time(r.created_at)}</p>
<form method="post" action="/me/agents/${a.id}/allows/${r.id}/revoke" class="pa-actions"><button type="submit" class="pa-danger-link">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">Nothing is always allowed for this agent.</p>`}</section>
<form method="post" action="/me/agents/${a.id}/allows/${r.id}/revoke" class="pa-actions"><button type="submit" class="pa-danger-link">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">Nothing is always allowed for this agent.</p>`}</section>
+<section class="pa-panel" id="spend" aria-labelledby="ac-spend-h"><h2 id="ac-spend-h">What it may spend</h2>
+<p class="pa-hint">An agent may tip a creator on OpenVibe with your Vibes, but only after you approve each tip, and only up to what is left of its weekly allowance. Zero means it may not spend at all: the spending tool is then not offered to it at all.</p>
+<form method="post" action="/me/agents/${a.id}/spend"><label>Weekly allowance (Vibes) <input name="weekly_vibes" type="number" min="0" max="${spend.MAX_ALLOWANCE}" step="1" value="${allowance.weekly}"></label> <button type="submit" class="pa-btn">Save allowance</button></form>
+<p>Left this week: <strong>${allowance.left} of ${allowance.weekly} Vibes</strong> (${allowance.start} to ${allowance.end} UTC). The week starts on Monday.</p>
+<h3 class="pa-sub">What it spent</h3>
+${payments.length ? html`<ul class="pa-notes">${payments.map((p2) => html`<li><p class="pa-note-text">${p2.state === 'paid' ? html`${p2.amount} Vibes to ${p2.to_label || p2.to_subject}` : html`${p2.amount} Vibes to ${p2.to_label || p2.to_subject} — not paid`}</p>
+<p class="pa-note-meta">${p2.reason ? html`${p2.reason} · ` : ''}${p2.state === 'paid' ? 'Paid' : html`Refused: ${p2.error || 'Tips did not pay it'}`} · ${time(p2.created_at)}</p></li>`)}</ul>` : html`<p class="muted">It has not spent anything yet.</p>`}
+</section>
<section class="pa-panel" id="decisions" aria-labelledby="pa-dec-h"><h2 id="pa-dec-h">What you decided</h2>
<section class="pa-panel" id="decisions" aria-labelledby="pa-dec-h"><h2 id="pa-dec-h">What you decided</h2>
<p class="pa-hint">Every approval this agent asked for, and what you answered: once, always, or no.</p>
<p class="pa-hint">Every approval this agent asked for, and what you answered: once, always, or no.</p>
${decisions.length ? html`<ul class="pa-notes">${decisions.map((d) => html`<li><p class="pa-note-text"><a href="/me/approvals/${d.id}">${d.summary}</a></p><p class="pa-note-meta">${kinds.label(d.kind)} · ${d.state === 'approved' ? (d.decision_scope === 'always' ? 'You approved it and allowed it always' : 'You approved it once') : d.state === 'declined' ? 'You declined it' : d.state === 'expired' ? 'It expired, nobody answered' : 'Waiting for you'} · ${time(d.decided_at || d.created_at)}</p></li>`)}</ul>` : html`<p class="muted">This agent has not asked for anything yet.</p>`}</section>
${decisions.length ? html`<ul class="pa-notes">${decisions.map((d) => html`<li><p class="pa-note-text"><a href="/me/approvals/${d.id}">${d.summary}</a></p><p class="pa-note-meta">${kinds.label(d.kind)} · ${d.state === 'approved' ? (d.decision_scope === 'always' ? 'You approved it and allowed it always' : 'You approved it once') : d.state === 'declined' ? 'You declined it' : d.state === 'expired' ? 'It expired, nobody answered' : 'Waiting for you'} · ${time(d.decided_at || d.created_at)}</p></li>`)}</ul>` : html`<p class="muted">This agent has not asked for anything yet.</p>`}</section>
@@ -476,6 +516,15 @@ ${c.undoable ? html`<form method="post" action="/me/agents/${a.id}/changes/${c.i
@@ -476,6 +516,15 @@ ${c.undoable ? html`<form method="post" action="/me/agents/${a.id}/changes/${c.i
<form method="post" action="/me/agents/${a.id}/delete" class="pa-delete"><button type="submit" class="pa-danger-link">Delete this agent</button> <span class="pa-hint">Its memory goes with it; its past runs stay on your task list.</span></form></section>`);
<form method="post" action="/me/agents/${a.id}/delete" class="pa-delete"><button type="submit" class="pa-danger-link">Delete this agent</button> <span class="pa-hint">Its memory goes with it; its past runs stay on your task list.</span></form></section>`);
});
});
r.post('/me/agents/:id/edit', async (req, res) => { if (!write(req, res)) return; const out = await personal.update(s, config, owner(req), req.params.id, fields(req.body)); if (!out) return page(req, res, 'Agent not found', html`<h1>Agent not found</h1>`, 404); if (!out.row) return page(req, res, 'Invalid agent', html`<h1>Invalid agent</h1><p>${out.error}</p>`, 422); res.redirect(303, `/me/agents/${req.params.id}`); });
r.post('/me/agents/:id/edit', async (req, res) => { if (!write(req, res)) return; const out = await personal.update(s, config, owner(req), req.params.id, fields(req.body)); if (!out) return page(req, res, 'Agent not found', html`<h1>Agent not found</h1>`, 404); if (!out.row) return page(req, res, 'Invalid agent', html`<h1>Invalid agent</h1><p>${out.error}</p>`, 422); res.redirect(303, `/me/agents/${req.params.id}`); });
+ // The weekly allowance (plan HO7): the person's own setting, on their agent's page. 0 turns the spending
+ // tool off for that agent.
+ r.post('/me/agents/:id/spend', async (req, res) => {
+ if (!write(req, res)) return;
+ const a = await find(req, res); if (!a) return;
+ const out = await spend.setAllowance(s, a.owner, a.id, (req.body || {}).weekly_vibes);
+ if (out.error) return page(req, res, 'Not saved', html`<h1>Not saved</h1><p>${out.error}</p><p><a href="/me/agents/${a.id}">Back to the agent</a></p>`, 422);
+ res.redirect(303, `/me/agents/${a.id}#spend`);
+ });
// Take a skill back from one of the person's agents (plan HO2).
// Take a skill back from one of the person's agents (plan HO2).
r.post('/me/agents/:id/skills/:listingId/remove', async (req, res) => {
r.post('/me/agents/:id/skills/:listingId/remove', async (req, res) => {
if (!write(req, res)) return;
if (!write(req, res)) return;
@@ -112,10 +112,20 @@ function createApi(ctx) {
@@ -112,10 +112,20 @@ function createApi(ctx) {
agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,
agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,
thread: a.task_id ? threadTitle.get(a.task_id) || null : null,
thread: a.task_id ? threadTitle.get(a.task_id) || null : null,
}));
}));
+ // A payment that was made (plan HO7): the person sees it in their Action Center, with what it paid and who
+ // received it. Only a recorded payment becomes a done item; a refused one stays on its pending approval card.
+ const paidRows = await s.db.many("SELECT approval_id FROM actor_spends WHERE owner = $1 AND state = 'paid' AND approval_id IS NOT NULL", [requester]);
+ const paidIds = new Set(paidRows.map((r) => r.approval_id));
+ const paidAsks = (await approvals.list(s, requester))
+ .filter((a) => a.state === 'approved' && a.kind === 'money' && paidIds.has(a.id))
+ .map((a) => items.approvalItem(a, {
+ agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,
+ thread: a.task_id ? threadTitle.get(a.task_id) || null : null,
+ }));
const scheduled = (await personal.list(s, requester))
const scheduled = (await personal.list(s, requester))
.filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
.filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
.map(items.scheduledItem);
.map(items.scheduledItem);
- const all = [...tasks, ...asks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));
+ const all = [...tasks, ...asks, ...paidAsks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));
res.json({ items: all.slice(0, 50) });
res.json({ items: all.slice(0, 50) });
});
});
@@ -109,6 +109,10 @@ const TABLES = [
@@ -109,6 +109,10 @@ const TABLES = [
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
+ // The tips their agents sent (plan HO7): who received them, how much, why, and what Billing said. The row of a
+ // payment that Billing refused is here too: it was their attempt, and it is their money either way.
+ { table: 'actor_spends', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-spends.json',
+ columns: ['id', 'owner', 'agent_id', 'approval_id', 'to_subject', 'to_label', 'amount', 'reason', 'target', 'state', 'txn_id', 'error', 'week_start', 'created_at'] },
// The standing permissions the person gave their agents (agents/allows.js): what they always allow, and when.
// The standing permissions the person gave their agents (agents/allows.js): what they always allow, and when.
{ table: 'actor_approval_allows', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approval-allows.json' },
{ table: 'actor_approval_allows', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approval-allows.json' },
// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused
// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused
@@ -870,7 +870,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -870,7 +870,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, latency = 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, spend = 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' });
@@ -891,7 +891,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -891,7 +891,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, latency,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,
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,
@@ -913,7 +913,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -913,7 +913,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
// The live tool list, at most the checkpoint's allow-list. A tool the registry no longer offers is dropped; the
// The live tool list, at most the checkpoint's allow-list. A tool the registry no longer offers is dropped; the
// delegate is kept only for a "best" run that already had it (never added by a fork).
// delegate is kept only for a "best" run that already had it (never added by a fork).
// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.
// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.
- const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits });
+ const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits, spend });
// The connector tools of this person (stage V2-11), at most the checkpoint's allow-list.
// The connector tools of this person (stage V2-11), at most the checkpoint's allow-list.
const dynamic = await connectorTools({ requester, mode });
const dynamic = await connectorTools({ requester, mode });
const installed = await installedSkillTools({ requester, agentId, mode });
const installed = await installedSkillTools({ requester, agentId, mode });
@@ -1030,9 +1030,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1030,9 +1030,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, latency = 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, spend = 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, latency });
+ 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, spend });
}
}
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' });
@@ -1056,7 +1056,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1056,7 +1056,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, latency,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,
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,
@@ -1108,7 +1108,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1108,7 +1108,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
// Which tools are offered first: the demoted ones last, never removed (stage V2-8). Below about 40 tools
// Which tools are offered first: the demoted ones last, never removed (stage V2-8). Below about 40 tools
// the model sees them all — no LLM picker and no embedding shortlist here.
// the model sees them all — no LLM picker and no embedding shortlist here.
// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.
// The waits (plan A2) travel with the tool list: wait_for offers itself only when the run may wait.
- const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits });
+ const offered = registry.definitions({ requester, mode, chatPolicy, config, models, signal, artifacts, store, agentId, taskId, now, waits, spend });
// The connections of this person (stage V2-11): the MCP tools they turned on and ask_agent for their agents.
// The connections of this person (stage V2-11): the MCP tools they turned on and ask_agent for their agents.
const dynamic = await connectorTools({ requester, mode });
const dynamic = await connectorTools({ requester, mode });
const installed = await installedSkillTools({ requester, agentId, mode });
const installed = await installedSkillTools({ requester, agentId, mode });
@@ -1202,7 +1202,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1202,7 +1202,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;
state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });
state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });
// The run's own tools and allow-list (sub-agents included in "best" mode), so a revision can do no more than the run.
// The run's own tools and allow-list (sub-agents included in "best" mode), so a revision can do no more than the run.
- const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts, store, agentId: state.agentId, taskId: state.taskId, now });
+ const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts, store, agentId: state.agentId, taskId: state.taskId, now, spend: ctx.spend });
if (!state.allow) state.allow = new Set(tools.map((t) => t.function.name));
if (!state.allow) state.allow = new Set(tools.map((t) => t.function.name));
const r = await callModel(state, ctx, { tools });
const r = await callModel(state, ctx, { tools });
if (r) {
if (r) {
@@ -175,6 +175,15 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
@@ -175,6 +175,15 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
data: ask, isError: true,
data: ask, isError: true,
});
});
}
}
+ // The gate refused the step outright (plan HO7: a tip over what is left of the agent's weekly
+ // allowance, or a creator that names nobody). The step does not run, and the model is told why in
+ // one plain line.
+ if (ask && ask.refused) {
+ return normalize({
+ text: JSON.stringify({ error: String(ask.refused).slice(0, 300) }),
+ data: { refused: String(ask.refused).slice(0, 300) }, isError: true,
+ });
+ }
}
}
}
}
const signal = ctx.signal;
const signal = ctx.signal;
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * spend_vibes: tip a creator on OpenVibe with the person's own Vibes (plan HO7).
+ *
+ * The tool never pays. It asks: the step is money, so it always reaches the person's approval floor (agents/kinds.js
+ * keeps 'money' out of what may be always allowed), and the payment is made at approval time with the person's own
+ * token (agents/spend.js). A run that asks over what is left of the agent's weekly allowance is refused before the
+ * person ever sees it.
+ *
+ * The tool is only offered to an agent whose person set a weekly allowance on its page (ctx.spend, read by the engine
+ * from the agent that owns the run). With no allowance there is nothing to offer.
+ */
+
+const spend = require('../../agents/spend');
+
+module.exports = {
+ name: 'spend_vibes',
+ description: 'Tip a creator on OpenVibe with your person\'s Vibes. The tip is not paid here: it goes to your person '
+ + 'as an approval, and they pay it themselves with their own Vibes. Name the creator by their OpenVibe account '
+ + 'id (usr_…, a person on OpenVibe) or by their OpenVibe page name (a channel). Say why, in one short line: '
+ + 'your person reads it on the card. The tip may not be more than what is left of your weekly allowance.',
+ input: {
+ type: 'object',
+ properties: {
+ to: { type: 'string', description: 'The creator: their OpenVibe account id (usr_…) or their OpenVibe page name (a channel).' },
+ amount: { type: 'integer', minimum: 1, description: 'How many whole Vibes to send.' },
+ reason: { type: 'string', maxLength: 300, description: 'Why the tip is sent, in one short line: your person reads it before they pay.' },
+ },
+ required: ['to', 'amount'],
+ },
+ scope: 'person',
+ acts: true,
+ costUsd: 0,
+ timeoutMs: 20_000,
+
+ /** Only an agent with a weekly allowance of its own, on a server that can pay it, is offered this tool. */
+ available(ctx) {
+ const s = ctx && ctx.spend ? ctx.spend : null;
+ const tips = ctx && ctx.config && ctx.config.services ? ctx.config.services.tipsUrl : '';
+ // A chat has its own consent card, not the approval floor; money is only offered behind the floor.
+ return !!(s && Number(s.weekly) > 0 && tips && !ctx.chatPolicy);
+ },
+
+ /**
+ * Nothing is paid here, ever. The approval gate above stops the call with the person's card; if it somehow did
+ * not, this repeats the allowance check and says plainly that no tip was sent, so a run can never spend by
+ * another path.
+ */
+ async run(input, ctx) {
+ const owner = String((ctx && ctx.requester) || '');
+ const agentId = ctx && ctx.spend ? ctx.spend.agent_id : null;
+ const amount = Number(input && input.amount);
+ if (!agentId) return { text: 'This agent may not spend: it has no allowance of its own. Ask your person to set one on the agent\'s page.', isError: true };
+ const left = await spend.check(ctx.store, { owner, agentId, amount }).catch(() => null);
+ if (!left || !left.ok) {
+ return { text: (left && left.message) || 'This agent may not spend that.', isError: true };
+ }
+ return {
+ text: 'No tip was sent: a tip is only paid when your person approves it on their approvals page.',
+ data: { asked: false, amount, to: String(input.to || '') },
+ isError: true,
+ };
+ },
+};
@@ -34,6 +34,7 @@ const contacts = require('../agents/contacts');
@@ -34,6 +34,7 @@ const contacts = require('../agents/contacts');
const assignments = require('../agents/assignments');
const assignments = require('../agents/assignments');
const runTokens = require('../triggers/tokens');
const runTokens = require('../triggers/tokens');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
+const spend = require('../agents/spend');
const { repoIn: codesRepoIn } = require('../agents/codes');
const { repoIn: codesRepoIn } = require('../agents/codes');
const memory = require('../memory');
const memory = require('../memory');
const taskNotices = require('../notify/task-notices');
const taskNotices = require('../notify/task-notices');
@@ -370,6 +371,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -370,6 +371,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
} catch (err) {
} catch (err) {
log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);
log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);
}
}
+ // HO7: what this agent may spend this week. The spending tool is offered only when the person set a
+ // weekly allowance on the agent's page (0 = off: the tool is not there at all).
+ const spendAllowance = taskAgent
+ ? await spend.allowance(s, row.requester, taskAgent.id).catch(() => null)
+ : null;
// Both execution paths get the same gate and assignment callbacks: the one loop passes them into its
// Both execution paths get the same gate and assignment callbacks: the one loop passes them into its
// tools, and an adapter that starts a Codes task gets them too.
// tools, and an adapter that starts a Codes task gets them too.
let requestApproval = null;
let requestApproval = null;
@@ -379,15 +385,24 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -379,15 +385,24 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
// A standing permission the person gave for this exact kind and target (plan HO1) is read here: it lets the
// A standing permission the person gave for this exact kind and target (plan HO1) is read here: it lets the
// step go on, and it never covers the floor.
// step go on, and it never covers the floor.
- const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null }) => {
+ const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null, spendStep = false }) => {
const categories = levels.classify({ category, summary, payload, recipientKnown });
const categories = levels.classify({ category, summary, payload, recipientKnown });
+ // HO7: money is the person's own. Before a tip reaches them, its recipient is resolved (a person
+ // usr_… or a creator's OpenVibe page) and the agent's weekly allowance is checked. An ask that
+ // cannot pay is refused here, with one plain line, and the person never sees a card for it.
+ let money = null;
+ if (kind === 'money' && spendStep) {
+ money = await spend.prepare({ config, s, owner: row.requester, agentId: taskAgent ? taskAgent.id : null, payload });
+ if (!money.ok) return { asked: false, decision: { ask: false }, refusal: money.message };
+ }
const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];
const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];
const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });
const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });
if (!decision.ask) return { asked: false, decision };
if (!decision.ask) return { asked: false, decision };
- const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload, target, categories });
+ const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary: money ? money.summary : summary, payload: money ? money.payload : payload, target, categories });
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
- await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(summary).toLowerCase()} Nothing runs until you approve it: ${url}` });
- await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });
+ const shown = money ? money.summary : summary;
+ await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });
+ await tellPerson(row, 'needs_you', { what: `approve: ${shown}`, url });
return { asked: true, decision, ask, url };
return { asked: true, decision, ask, url };
};
};
// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.
// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.
@@ -473,8 +488,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -473,8 +488,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// payment, password, deletion or a new recipient never does.
// payment, password, deletion or a new recipient never does.
- requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null }) => {
- const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target });
+ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null, spend = false }) => {
+ const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target, spendStep: spend });
+ // A refusal (plan HO7): the step does not run and the model is told why.
+ if (out.refusal) return { refused: out.refusal };
if (!out.asked) return null;
if (!out.asked) return null;
return { pending: true, id: out.ask.id, url: out.url };
return { pending: true, id: out.ask.id, url: out.url };
} : null;
} : null;
@@ -517,6 +534,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -517,6 +534,9 @@ 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,
+ // What this agent may spend this week (plan HO7): the spending tool is offered only
+ // when the person set a weekly allowance on the agent's page.
+ spend: spendAllowance,
// A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.
// 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),
latency: await latencyClass(id),
}));
}));
@@ -41,6 +41,7 @@ const EXPECTED = {
@@ -41,6 +41,7 @@ const EXPECTED = {
space_create: 'space',
space_create: 'space',
space_files_write: 'space',
space_files_write: 'space',
space_publish: 'publish',
space_publish: 'publish',
+ spend_vibes: 'money',
};
};
const SAMPLE = {
const SAMPLE = {
@@ -60,6 +61,7 @@ const SAMPLE = {
@@ -60,6 +61,7 @@ const SAMPLE = {
space_create: { name: 'My space' },
space_create: { name: 'My space' },
space_files_write: { space_id: 'sp_1', files: [{ path: 'a.txt', content: 'x' }] },
space_files_write: { space_id: 'sp_1', files: [{ path: 'a.txt', content: 'x' }] },
space_publish: { space_id: 'sp_1' },
space_publish: { space_id: 'sp_1' },
+ spend_vibes: { to: 'bo', amount: 12, reason: 'thanks for the stream' },
};
};
(async () => {
(async () => {
@@ -124,6 +124,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -124,6 +124,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL',
'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
+ 'server/agents/spend.js': 'the configured OpenVibe.Tips base (ACTOR_TIPS_URL, loopback): a creator\'s page is looked up by a path-encoded handle or usr_ id, and a tip is paid through Tips\' checkout with the person\'s own token and the approval id as the Idempotency-Key; no URL from a task or from the model is ever fetched here (test/spend-vibes.test.js)',
'server/net/guarded-request.js': 'the MCP servers and A2A agents a person added on /connections (stage V2-11): https on 443/8443 to public addresses only, refused for localhost, private ranges and metadata addresses through openvibe-shared/egress safeLookup, with a timeout and a 256 KB cap, never a redirect (test/connections.test.js)',
'server/net/guarded-request.js': 'the MCP servers and A2A agents a person added on /connections (stage V2-11): https on 443/8443 to public addresses only, refused for localhost, private ranges and metadata addresses through openvibe-shared/egress safeLookup, with a timeout and a 256 KB cap, never a redirect (test/connections.test.js)',
'server/skills/run.js': 'the configured OpenVibe.Run base (ACTOR_RUN_URL) with Actor\'s own service token (run.job.submit/read/cancel), the person in X-OV-Subject and a project id checked as prj_<ULID> in X-OV-Project; a job id must be job_<ULID> and is URL-encoded, every code input is a Media id med_<ULID> with a sha256 from the listing, and no URL from the model or a skill enters a request (test/skill-sandbox.test.js)',
'server/skills/run.js': 'the configured OpenVibe.Run base (ACTOR_RUN_URL) with Actor\'s own service token (run.job.submit/read/cancel), the person in X-OV-Subject and a project id checked as prj_<ULID> in X-OV-Project; a job id must be job_<ULID> and is URL-encoded, every code input is a Media id med_<ULID> with a sha256 from the listing, and no URL from the model or a skill enters a request (test/skill-sandbox.test.js)',
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,450 @@
@@ -0,0 +1,450 @@
+'use strict';
+/**
+ * HO7: an agent that can spend the person's Vibes.
+ *
+ * The tool is only offered to an agent whose person set a weekly allowance. A tip always asks (money is the floor:
+ * no level and no "always allow" rule covers it), and the money moves only when the person approves the card with
+ * their own session, once, with their own token, through OpenVibe.Tips' checkout. An ask over what is left of the
+ * allowance (or under Tips' own minimum) is refused before the person ever sees it. A definite refusal from Tips
+ * leaves the request pending with its reason; an unknown answer keeps the claim paying, so the week still counts it,
+ * and a retry after the stale window sends the same Idempotency-Key, which Tips replays.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { serviceAuth } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const personal = require('../server/agents/personal');
+const contacts = require('../server/agents/contacts');
+const approvals = require('../server/agents/approvals');
+const allows = require('../server/agents/allows');
+const levels = require('../server/agents/levels');
+const spend = require('../server/agents/spend');
+const tool = require('../server/runtime/tools/spend_vibes');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const UNCONFIRMED = 'We could not confirm the tip yet; nothing will be paid twice.';
+
+/**
+ * A stand-in for OpenVibe.Tips: the creator profile resolve() reads, and the one checkout a person's own token pays.
+ * A replay with the same Idempotency-Key returns the first interaction (200, Idempotent-Replayed), as Tips does.
+ * state.refuse is what Tips answers with; state.delayMs makes the first request outlive the client's timeout, so its
+ * interaction is recorded while the caller sees an unknown outcome.
+ */
+async function startTips({ creators = {} } = {}) {
+ const calls = []; // POST /api/v1/checkout, with the headers and body Actor sent
+ const views = []; // GET /api/v1/profiles/:creator
+ const payments = new Map(); // Idempotency-Key → the interaction Tips recorded first
+ const state = { refuse: null, delayMs: 0 };
+ const list = Object.entries(creators).map(([handle, c]) => ({ handle, min: 1, ...c }));
+ const find = (ref) => list.find((c) => c.handle === ref || c.subject === ref) || null;
+ let n = 0;
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj, headers = {}) => {
+ if (res.destroyed || res.writableEnded) return;
+ try { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); } catch { /* the client gave up */ }
+ };
+ const m = /^\/api\/v1\/profiles\/([^/?]+)$/.exec(req.url);
+ if (m && req.method === 'GET') {
+ const ref = decodeURIComponent(m[1]);
+ views.push(ref);
+ const c = find(ref);
+ if (!c) return json(404, { code: 'tips.creator_not_found', detail: 'no tip profile for this creator' });
+ return json(200, {
+ profile: {
+ creator: { type: 'user', id: c.subject }, handle: c.handle, display_name: c.name || c.handle,
+ minimums: { tip: c.min || 1 },
+ },
+ });
+ }
+ if (req.url === '/api/v1/checkout' && req.method === 'POST') {
+ const body = raw ? JSON.parse(raw) : {};
+ const key = req.headers['idempotency-key'] || null;
+ calls.push({ auth: req.headers.authorization || null, key, body, contentType: req.headers['content-type'] || null });
+ const answer = () => {
+ if (state.refuse) {
+ const status = state.refuse.status || 409;
+ return json(status, {
+ type: 'about:blank', title: state.refuse.detail, status,
+ code: state.refuse.code || 'tips.refused', detail: state.refuse.detail,
+ });
+ }
+ const prev = key ? payments.get(key) : null;
+ if (prev) return json(200, { interaction: prev }, { 'Idempotent-Replayed': 'true' });
+ const c = find(body.creator);
+ if (!c) return json(404, { code: 'tips.creator_not_found', detail: 'no tip profile for this creator' });
+ const interaction = { id: `tint_${String(++n).padStart(26, '0')}`, creator: { type: 'user', id: c.subject }, amount: body.amount, payment: { state: 'settled' } };
+ if (key) payments.set(key, interaction);
+ return json(201, { interaction });
+ };
+ if (state.delayMs) { setTimeout(answer, state.delayMs); return; }
+ return answer();
+ }
+ return json(404, { code: 'not_found', detail: 'no such route' });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, views, payments, state, close: () => new Promise((r) => server.close(r)) };
+}
+
+/** The model turn that asks for the tip, and the answer that follows it. */
+const tipTurn = (args) => ({ content: 'Tipping the creator.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'spend_vibes', arguments: JSON.stringify(args) } }] });
+const doneTurn = { content: 'The tip was sent.' };
+
+(async () => {
+ const tips = await startTips({
+ creators: {
+ bo: { subject: 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YB', name: 'Bo' },
+ nina: { subject: 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YC', name: 'Nina', min: 10 },
+ },
+ });
+ let now = Date.now();
+ const t = await boot({
+ env: { ACTOR_TIPS_URL: tips.url },
+ now: () => now,
+ adapters: { checkers: { openai: async () => ({ text: '{"ok":true,"reason":"fine"}', cost_usd: 0 }) } },
+ });
+ try {
+ const ada = t.network.addUser('ada');
+ const owner = `user:${ada.subject}`;
+ const creator = 'usr_01JZ7QK9F3M8N2P4R6T8V0X2YB';
+ const agent = (await personal.create(t.ctx.s, t.config, owner, {
+ name: 'Tipper', instructions: 'Tip creators when the person asks for it.', mode: 'balanced',
+ budget_per_run_usd: 0.05, daily_runs_max: 5, schedule: 'off', status: 'active',
+ })).row;
+
+ const moneyApprovals = async () => await t.ctx.s.db.many("SELECT * FROM actor_approvals WHERE owner = $1 AND kind = 'money' ORDER BY created_at DESC", [owner]);
+ const payloadOf = (a) => (typeof a.payload === 'string' ? JSON.parse(a.payload || '{}') : (a.payload || {}));
+ const asksFor = (approvalId) => tips.calls.filter((c) => c.key === approvalId);
+ const askTip = async (args, { script = null } = {}) => {
+ t.providers.state.script = script || [tipTurn(args), doneTurn];
+ const out = await personal.run(t.ctx, agent, 'Send the tip the person asked for.');
+ const row = await t.waitFor(out.task.id);
+ return row;
+ };
+
+ await check('the person sets the weekly allowance on the agent\'s page, and 0 turns it off', async () => {
+ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });
+ assert.ok(page.text.includes('What it may spend'), 'the agent page has the allowance panel');
+ const set = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '25' }, headers: SAME });
+ assert.strictEqual(set.status, 303, set.text.slice(0, 200));
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 25, 'the person\'s setting was saved');
+ // Only a whole, non-negative number of Vibes, at most the cap.
+ const bad = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '-1' }, headers: SAME });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 25, 'a bad setting changed nothing');
+ const off = await t.get(`/me/agents/${agent.id}/spend`, { as: ada, form: { weekly_vibes: '0' }, headers: SAME });
+ assert.strictEqual(off.status, 303);
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).weekly, 0, '0 is off');
+ });
+
+ await check('an agent with no weekly allowance is never offered the spending tool', async () => {
+ const defs = t.ctx.registry.definitions({ requester: owner, mode: 'balanced', config: t.config, spend: { weekly: 0, agent_id: agent.id } });
+ assert.ok(!defs.some((d) => d.function.name === 'spend_vibes'), 'no allowance, no tool');
+ const offered = t.ctx.registry.definitions({ requester: owner, mode: 'balanced', config: t.config, spend: { weekly: 20, agent_id: agent.id } });
+ assert.ok(offered.some((d) => d.function.name === 'spend_vibes'), 'an allowance offers the tool');
+ assert.strictEqual(tool.available({ config: t.config, spend: { weekly: 0 } }), false);
+ assert.strictEqual(tool.available({ config: t.config, spend: { weekly: 1 } }), true);
+ // With no Tips to pay it, the tool is not offered either: it could never be paid.
+ assert.strictEqual(tool.available({ config: { services: { tipsUrl: '' } }, spend: { weekly: 1 } }), false);
+ });
+
+ await check('a run with no allowance asks for nothing and pays nothing', async () => {
+ const before = t.providers.requests.length;
+ const row = await askTip({ to: creator, amount: 12, reason: 'thanks for the stream' });
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ assert.strictEqual((await moneyApprovals()).length, 0, 'no approval was asked for');
+ assert.strictEqual(tips.calls.length, 0, 'nothing was paid');
+ // The tool was not in the model's list at all: with no allowance there is nothing to offer.
+ const tools = t.providers.requests.slice(before)
+ .filter((r) => r.path.endsWith('/chat/completions') && Array.isArray(r.body.tools))
+ .flatMap((r) => r.body.tools.map((x) => x.function && x.function.name));
+ assert.ok(tools.length > 0, 'the loop saw a tool list');
+ assert.ok(!tools.includes('spend_vibes'), 'no allowance, no spending tool');
+ });
+
+ await check('a tip always asks, at every level, and no allow rule covers it', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ // Full access: the level never skips the floor.
+ await contacts.setLevel(t.ctx.s, t.config, owner, agent.id, 'full');
+ const before = t.providers.requests.length;
+ const row = await askTip({ to: creator, amount: 12, reason: 'thanks for the stream' });
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ const tools = t.providers.requests.slice(before)
+ .filter((r) => r.path.endsWith('/chat/completions') && Array.isArray(r.body.tools))
+ .flatMap((r) => r.body.tools.map((x) => x.function && x.function.name));
+ assert.ok(tools.includes('spend_vibes'), 'an allowance offers the spending tool');
+ const asks = await moneyApprovals();
+ assert.strictEqual(asks.length, 1, 'the tip asked for approval');
+ const ask = asks[0];
+ assert.strictEqual(ask.state, 'pending');
+ assert.strictEqual(ask.agent_id, agent.id);
+ assert.strictEqual(tips.calls.length, 0, 'nothing was paid without the person');
+ // The card always names the handle, and the usr_ id too because this tip named an id.
+ assert.strictEqual(ask.summary, 'Tip 12 Vibes to Bo (@bo) · usr_01JZ7QK9F3M8N2P4R6T8V0X2YB: thanks for the stream');
+ // A standing permission can never cover money, even one written by hand into the rules it reads.
+ const rule = await allows.grant(t.ctx.s, owner, agent.id, 'money', 'tip 12 Vibes to x');
+ assert.ok(rule.error, 'money is never always allowed');
+ assert.strictEqual(allows.matches([], 'money', 'tip 12 Vibes to x'), false);
+ assert.strictEqual(allows.matches([{ kind: 'money', target: 'tip 12 Vibes to x' }], 'money', 'tip 12 Vibes to x'), false, 'a money rule never matches');
+ const stillAsks = await levels.decide({ level: 'full', categories: ['money'], allow: { kind: 'money', target: 'tip 12 Vibes to x', rules: [{ kind: 'money', target: 'tip 12 Vibes to x' }] } });
+ assert.strictEqual(stillAsks.ask, true, 'the floor asks at full access even with a rule');
+ // And the card says "approve and pay", never "always allow".
+ const card = await t.get(`/me/approvals/${ask.id}`, { as: ada });
+ assert.ok(card.text.includes('Approve and pay'), 'the card offers to pay');
+ assert.ok(!card.text.includes('Always allow this'), 'money is never always allowed');
+ assert.ok(card.text.includes('12 Vibes') && card.text.includes('thanks for the stream'), 'the card says the amount and the reason');
+ assert.ok(card.text.includes('Bo (@bo)') && card.text.includes(creator), 'the card says who receives it, by handle and id');
+ assert.ok(card.text.includes('Left this week'), 'the card says what is left this week');
+ // A tip named by its handle names the handle, never an id the person did not ask to see.
+ const byHandle = await spend.prepare({ config: t.config, s: t.ctx.s, owner, agentId: agent.id, payload: { to: '@bo', amount: 4 } });
+ assert.ok(byHandle.ok, byHandle.message);
+ assert.strictEqual(byHandle.summary, 'Tip 4 Vibes to Bo (@bo)');
+ assert.ok(!byHandle.summary.includes(creator), 'a handle-named tip does not show the usr_ id');
+ const byId = await spend.prepare({ config: t.config, s: t.ctx.s, owner, agentId: agent.id, payload: { to: creator, amount: 4 } });
+ assert.ok(byId.summary.includes('Bo (@bo)') && byId.summary.includes(creator), 'an id-named tip shows the handle and the id');
+ assert.ok(tips.views.includes('bo') && tips.views.includes(creator), 'each was looked up by the name the tip gave');
+ });
+
+ await check('a tip below the creator\'s minimum is refused before the person sees a card', async () => {
+ const before = (await moneyApprovals()).length;
+ const paidBefore = tips.calls.length;
+ const row = await askTip({ to: 'nina', amount: 5, reason: 'too small' });
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ assert.strictEqual((await moneyApprovals()).length, before, 'no approval was created');
+ assert.strictEqual(tips.calls.length, paidBefore, 'Tips was never asked to pay');
+ const said = await t.ctx.s.db.many("SELECT body->>'text' AS text FROM task_events WHERE task_id = $1 AND body->>'step' = 'tool_result'", [row.id]);
+ assert.ok(said.some((e) => e.text.includes('minimum for a tip is 10 Vibes')), 'the agent is told Tips\' own minimum');
+ });
+
+ await check('an ask over what is left of the allowance is refused before the person sees it', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 5);
+ const before = (await moneyApprovals()).length;
+ const row = await askTip({ to: creator, amount: 40, reason: 'too much' });
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ assert.strictEqual((await moneyApprovals()).length, before, 'no approval was created');
+ assert.strictEqual(tips.calls.length, 0, 'nothing was paid');
+ // The model is told why in one plain line, and it never reaches the person's card.
+ const said = await t.ctx.s.db.many("SELECT body->>'text' AS text FROM task_events WHERE task_id = $1 AND body->>'step' = 'tool_result'", [row.id]);
+ assert.ok(said.some((e) => /left this week/.test(e.text || '')), 'the agent is told why in one line');
+ assert.ok(said.some((e) => /this tip is 40/.test(e.text || '')), 'and how much was asked');
+ });
+
+ await check('approving pays once through Tips checkout, with the person\'s own token', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ const ask = (await moneyApprovals()).find((a) => payloadOf(a).amount === 12);
+ assert.ok(ask, 'the 12 Vibe tip is waiting');
+ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 303, res.text.slice(0, 200));
+ assert.strictEqual(tips.calls.length, 1, 'the tip was paid once');
+ const call = tips.calls[0];
+ assert.strictEqual(call.auth, `Bearer ${t.network.userToken(ada)}`, 'paid with the person\'s own token');
+ assert.strictEqual(call.key, ask.id, 'the approval id is the idempotency key');
+ assert.strictEqual(call.contentType, 'application/json');
+ assert.deepStrictEqual(call.body, { creator, amount: 12, message: 'thanks for the stream', pay_with: 'credit' });
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');
+ const paid = await spend.paidFor(t.ctx.s, owner, ask.id);
+ assert.ok(paid, 'the payment is recorded');
+ assert.strictEqual(Number(paid.amount), 12);
+ assert.strictEqual(paid.txn_id, tips.payments.get(ask.id).id, 'Tips\' interaction id is the record');
+ // A second click never pays twice.
+ const again = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(tips.calls.length, 1, 'still one payment');
+ });
+
+ await check('a tip over what is left at payment time is refused, not paid', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ const ask = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'money', summary: 'Tip 30 Vibes to Bo: over now',
+ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 30, reason: 'over now' },
+ });
+ // The person lowers the allowance after the ask: the card may be older than the setting.
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 5);
+ const before = tips.calls.length;
+ try {
+ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 200, res.text.slice(0, 200));
+ assert.ok(res.text.includes('left this week'), 'the card says what is left');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending');
+ assert.strictEqual(tips.calls.length, before, 'Tips was never called');
+ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null);
+ } finally {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ }
+ // The person may decline it, and no money moves.
+ const declined = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'decline' }, headers: SAME });
+ assert.strictEqual(declined.status, 303);
+ });
+
+ await check('the payment is in the agent\'s activity, in the Action Center and in the export', async () => {
+ const ask = (await moneyApprovals()).find((a) => payloadOf(a).amount === 12);
+ assert.ok(ask, 'the paid tip is there');
+ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });
+ assert.ok(page.text.includes('12 Vibes'), 'the agent\'s page shows what it spent');
+ assert.ok(page.text.includes('Bo (@bo)'), 'and the handle of who received it');
+ assert.ok(page.text.includes('thanks for the stream'), 'and why');
+ // The Action Center: a first-party service with actor.actions.read reads the person's items.
+ const now = Math.floor(Date.now() / 1000);
+ const reader = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.actor'], cap: ['actor.actions.read'], iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);
+ const feed = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });
+ assert.strictEqual(feed.status, 200, feed.text.slice(0, 200));
+ const items = feed.json().items || [];
+ const item = items.find((i) => i.kind === 'approval' && String(i.summary || '').includes('12 Vibes'));
+ assert.ok(item, 'the payment is an Action Center item');
+ assert.strictEqual(item.bucket, 'done');
+ // The export: the rows of the payments are the person's own.
+ const { TABLES } = require('../server/identity/account-data');
+ assert.ok(TABLES.some((x) => x.table === 'actor_spends'), 'the payments are in the export');
+ const rows = await t.ctx.s.db.many('SELECT * FROM actor_spends WHERE owner = $1', [owner]);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].state, 'paid');
+ assert.strictEqual(rows[0].txn_id, tips.payments.get(ask.id).id);
+ });
+
+ await check('a tip is never paid by a service, nor by approve-always', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ const ask = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'money', summary: 'Tip 5 Vibes to Bo: no service',
+ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 5, reason: 'no service' },
+ });
+ const before = tips.calls.length;
+ // The Action Center's service token has no person behind it: it can decline, never pay.
+ const service = await approvals.decide(t.ctx, owner, ask.id, 'approve');
+ assert.ok(service.error && service.pending, 'a service approval does not pay');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'and the request stays pending');
+ // Even the person's own token cannot make a money step always-allowed.
+ const always = await approvals.decide(t.ctx, owner, ask.id, 'approve_always', { personToken: t.network.userToken(ada) });
+ assert.ok(always.error, 'approve-always is refused for money');
+ assert.match(String(always.error), /always asks/);
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending');
+ assert.strictEqual(tips.calls.length, before, 'nothing was paid');
+ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null);
+ const declined = await approvals.decide(t.ctx, owner, ask.id, 'decline');
+ assert.strictEqual(declined.row.state, 'declined');
+ });
+
+ await check('a definite refusal from Tips marks the claim failed and keeps the request pending', async () => {
+ const ask = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'money', summary: 'Tip 5 Vibes to Bo: another one',
+ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 5, reason: 'another one' },
+ });
+ // Tips refuses this one: not enough Vibes (a 409, a definite answer: no money moved).
+ const before = tips.calls.length;
+ tips.state.refuse = { status: 409, code: 'billing.insufficient_funds', detail: 'Not enough Vibes on this account.' };
+ try {
+ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 200, res.text.slice(0, 200));
+ assert.ok(res.text.includes('Not enough Vibes'), 'the reason is shown');
+ assert.ok(res.text.includes('Approve and pay'), 'the card is still waiting');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'it stays pending');
+ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null, 'nothing was paid');
+ assert.strictEqual(tips.calls.length, before + 1, 'Tips was asked once');
+ const failed = await t.ctx.s.db.maybe("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'failed'", [ask.id]);
+ assert.ok(failed, 'the claim is kept as a failed attempt');
+ // A refused attempt is not a payment: it does not eat into the week.
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 12);
+ } finally {
+ tips.state.refuse = null;
+ }
+ // The person may try again once the cause is fixed.
+ const retry = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(retry.status, 303, retry.text.slice(0, 200));
+ assert.strictEqual(tips.calls.length, before + 2, 'the retry asked Tips again');
+ assert.ok(await spend.paidFor(t.ctx.s, owner, ask.id), 'the retry paid once');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 17, 'only the payment counts');
+ });
+
+ await check('every 4xx from Tips is definite (no money moved), except an idempotency claim still in progress', async () => {
+ const answer = (status, code = null) => async () => new Response(JSON.stringify({ code, detail: `answered ${status}` }), { status, headers: { 'content-type': 'application/json' } });
+ const tipWith = (fetchImpl) => spend.createTips({ config: { services: { tipsUrl: 'https://tips.example' } }, fetchImpl })
+ .tip({ token: 'tok', to: creator, amount: 5, key: 'apr_x' });
+ for (const [status, code] of [[401, 'token.invalid'], [403, null], [404, 'tips.no_creator'], [409, 'billing.insufficient_funds'], [422, null], [429, 'rate.limited']]) {
+ const out = await tipWith(answer(status, code));
+ assert.strictEqual(out.definite, true, `${status} is definite`);
+ assert.ok(!out.unknown, `${status} is not unknown`);
+ }
+ for (const [status, code] of [[409, 'idempotency.in_progress'], [500, null], [502, null], [503, null]]) {
+ const out = await tipWith(answer(status, code));
+ assert.strictEqual(out.unknown, true, `${status} ${code || ''} is unknown: it keeps counting`);
+ }
+ const thrown = await tipWith(async () => { throw new Error('socket hang up'); });
+ assert.strictEqual(thrown.unknown, true, 'no answer at all is unknown');
+ });
+
+ await check('an unknown answer keeps the claim paying, counts it, and a retry after the stale window replays it once', async () => {
+ const goodTips = t.ctx.tips;
+ t.ctx.tips = spend.createTips({ config: t.config, fetchImpl: globalThis.fetch, timeoutMs: 80 });
+ const ask = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'money', summary: 'Tip 7 Vibes to Bo: maybe',
+ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 7, reason: 'maybe' },
+ });
+ const before = tips.calls.length;
+ tips.state.delayMs = 400; // the first request outlives the client's 80 ms: Tips records it, Actor cannot know
+ try {
+ const res = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 200, res.text.slice(0, 200));
+ assert.ok(res.text.includes(UNCONFIRMED), 'the person is told the tip is not confirmed');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'pending', 'the card stays pending');
+ assert.strictEqual(await spend.paidFor(t.ctx.s, owner, ask.id), null, 'nothing is recorded as paid yet');
+ const paying = await t.ctx.s.db.maybe("SELECT * FROM actor_spends WHERE approval_id = $1", [ask.id]);
+ assert.ok(paying, 'the claim is kept');
+ assert.strictEqual(paying.state, 'paying', 'an unknown answer is not a failure');
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 24, 'the paying claim counts against the week');
+ // Turning the allowance off after the fact must not strand a payment that may already have moved.
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 0);
+ } finally {
+ tips.state.delayMs = 0;
+ }
+ // The first request finished at Tips (and recorded its interaction), though Actor never heard the answer.
+ await new Promise((r) => setTimeout(r, 500));
+ // After the stale window the same approval pays again: the same key, so Tips replays the one interaction.
+ now += 6 * 60_000;
+ t.ctx.tips = goodTips;
+ const retry = await t.get(`/me/approvals/${ask.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(retry.status, 303, retry.text.slice(0, 200));
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');
+ const paid = await spend.paidFor(t.ctx.s, owner, ask.id);
+ assert.ok(paid, 'the tip settled once');
+ assert.strictEqual(paid.txn_id, tips.payments.get(ask.id).id, 'the replayed interaction is the record');
+ assert.strictEqual((await spend.allowance(t.ctx.s, owner, agent.id)).spent, 24, 'the same payment did not count twice');
+ const attempts = tips.calls.slice(before);
+ assert.strictEqual(attempts.length, 2, 'the retry called Tips once more');
+ assert.strictEqual(attempts[0].key, ask.id);
+ assert.strictEqual(attempts[1].key, ask.id, 'the retry sends the same Idempotency-Key');
+ assert.deepStrictEqual(attempts[1].body, attempts[0].body, 'with the same body');
+ assert.strictEqual((await t.ctx.s.db.many("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'paid'", [ask.id])).length, 1);
+ });
+
+ await check('two clicks at once pay once', async () => {
+ await spend.setAllowance(t.ctx.s, owner, agent.id, 50);
+ const ask = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'money', summary: 'Tip 3 Vibes to Bo: together',
+ payload: { spend: 'tip', to: creator, to_label: 'Bo (@bo)', amount: 3, reason: 'together' },
+ });
+ const before = tips.calls.length;
+ const personToken = t.network.userToken(ada);
+ await Promise.all([
+ approvals.decide(t.ctx, owner, ask.id, 'approve', { personToken }),
+ approvals.decide(t.ctx, owner, ask.id, 'approve', { personToken }),
+ ]);
+ assert.strictEqual(tips.calls.length, before + 1, 'Tips was called once for two clicks');
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, ask.id)).state, 'approved');
+ assert.ok(await spend.paidFor(t.ctx.s, owner, ask.id), 'the tip was paid');
+ const paidRows = await t.ctx.s.db.many("SELECT * FROM actor_spends WHERE approval_id = $1 AND state = 'paid'", [ask.id]);
+ assert.strictEqual(paidRows.length, 1, 'one payment row for two clicks');
+ });
+ } catch (err) {
+ process.stdout.write(` ✗ the suite threw\n ${(err.stack || String(err)).split('\n').slice(0, 8).join('\n ')}\n`);
+ process.exitCode = 1;
+ } finally {
+ await t.close();
+ await tips.close();
+ }
+ done();
+})();
@@ -57,6 +57,8 @@ const EXPECTED = {
@@ -57,6 +57,8 @@ const EXPECTED = {
space_files_write: true,
space_files_write: true,
space_preview: false,
space_preview: false,
space_publish: true,
space_publish: true,
+ // Money (plan HO7): a tip asks the person, and the payment moves their Vibes when they approve it.
+ spend_vibes: true,
};
};
(async () => {
(async () => {