From 7bf36fdf06badd3189e9e08586fa978b7254d532 Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sat, 22 Aug 2026 20:14:56 +0100 Subject: [PATCH 1/5] chore(convex): regenerate server bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the typesafe `env` export added by a newer Convex version. No behaviour change — the file is generated, and this keeps it matching the installed client. --- convex/_generated/server.d.ts | 19 +++++++++++++++++++ convex/_generated/server.js | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts index bec05e6..f235db4 100644 --- a/convex/_generated/server.d.ts +++ b/convex/_generated/server.d.ts @@ -21,6 +21,17 @@ import { } from "convex/server"; import type { DataModel } from "./dataModel.js"; +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + /** * Define a query in this Convex app's public API. * @@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder; */ export declare const httpAction: HttpActionBuilder; +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; + /** * A set of services for use within Convex query functions. * diff --git a/convex/_generated/server.js b/convex/_generated/server.js index bf3d25a..2dbe0db 100644 --- a/convex/_generated/server.js +++ b/convex/_generated/server.js @@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric; * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export const env = process.env; From 775388988b5e98f274f82b28e4c662fb898425f7 Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sat, 22 Aug 2026 20:15:11 +0100 Subject: [PATCH 2/5] fix(payments): fail fast on an unreachable provider, and back off the poller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DNS outage turned one dead host into a wall of stack traces: the inbound-payment poller ran every fifteen seconds regardless, each pass opening connections that took the Node default of many seconds to give up, each failure printing in full. The real problem — the provider is unreachable — was buried under its own repetition. Two changes. Calls now carry an eight-second deadline, so a hung connection is abandoned rather than left to occupy the poll it belongs to. And repeat failures against the same host collapse: the first prints, then every twentieth, until a success resets the count. The poller itself moves from a fixed interval to self-scheduling with exponential backoff, fifteen seconds out to five minutes. It only backs off when no wallet reached the provider at all — a single wallet failing is a wallet problem, not an outage, and must not slow down everyone else's payments. --- lib/monnify.ts | 43 ++++++++++++++++++++-- lib/store/events.ts | 88 +++++++++++++++++++++++++++++---------------- 2 files changed, 98 insertions(+), 33 deletions(-) diff --git a/lib/monnify.ts b/lib/monnify.ts index 68297e2..64c5c75 100644 --- a/lib/monnify.ts +++ b/lib/monnify.ts @@ -4,16 +4,55 @@ import { env } from "./env"; type TokenCache = { token: string; expiresAt: number }; let cached: TokenCache | null = null; +// Fail fast rather than sitting on a connection that is not going to open. The +// default is around ten seconds, which is a long time to hold a request that is +// already doomed. +const CALL_TIMEOUT_MS = 8000; + +// A provider outage is not news after the first time. Every failure used to +// print the error object and its stack, so an unreachable host produced a wall +// of identical traces every fifteen seconds and buried every other log on the +// machine. Repeats now collapse into a counter, and recovery says so. +const failing = new Map(); + +function reasonOf(e: unknown): string { + const cause = (e as { cause?: { code?: string } })?.cause; + return cause?.code ?? (e as Error)?.message ?? "unknown error"; +} + +function noteFailure(path: string, e: unknown): void { + const reason = reasonOf(e); + const prev = failing.get(path); + if (prev?.reason === reason) { + prev.count += 1; + // Occasional reminders that it is still down, not one per attempt. + if (prev.count % 20 === 0) { + console.error(`[Monnify] ${path} still unreachable (${reason}) — ${prev.count} consecutive failures`); + } + return; + } + failing.set(path, { reason, count: 1 }); + console.error(`[Monnify] ${path} failed: ${reason}`); +} + +function noteSuccess(path: string): void { + const prev = failing.get(path); + if (!prev) return; + failing.delete(path); + console.info(`[Monnify] ${path} recovered after ${prev.count} failed attempt(s).`); +} + async function call(path: string, init: RequestInit): Promise { try { - const res = await fetch(`${env.baseUrl}${path}`, init); + const res = await fetch(`${env.baseUrl}${path}`, { ...init, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) }); const body = (await res.json()) as { requestSuccessful: boolean; responseMessage: string; responseBody: T }; if (!res.ok || !body.requestSuccessful) { throw new Error(`Monnify ${path} failed (${res.status}): ${body.responseMessage ?? "unknown error"}`); } + noteSuccess(path); return body.responseBody; } catch (e) { - console.error(`[Monnify API Error] Fetch to ${path} failed:`, e); + noteFailure(path, e); throw e; } } diff --git a/lib/store/events.ts b/lib/store/events.ts index 14122a6..6399dee 100644 --- a/lib/store/events.ts +++ b/lib/store/events.ts @@ -17,50 +17,76 @@ export function publishEvent(accountId: string, e: AideEvent, at?: number): void void publishConvexEvent(accountId, e, at); } -let pollBusy = false; - // The poller is the fallback that makes LOCAL demos work without a public // tunnel Monnify can reach (in production the webhook is the real path, and a // serverless setInterval wouldn't survive anyway). It polls every active wallet // and publishes confirmed payments into Convex, tagged with their real time so // only genuinely new money is announced. +// How often the poller asks the payment provider for new transactions, and how +// far it backs off when the provider cannot be reached at all. A fixed interval +// meant an outage produced a doomed request every fifteen seconds forever, each +// one burning its full connect timeout — so the machine spent more time waiting +// on a host that was not answering than doing anything else. +const POLL_BASE_MS = 15_000; +const POLL_MAX_MS = 5 * 60_000; +let pollFailures = 0; + +const nextPollDelay = () => + pollFailures === 0 ? POLL_BASE_MS : Math.min(POLL_BASE_MS * 2 ** pollFailures, POLL_MAX_MS); + export function ensurePolling(): void { + // state.pollTimer stays set for the life of the loop — including while a tick + // is in flight — so repeat calls from other requests cannot start a second one. if (state.pollTimer) return; - state.pollTimer = setInterval(async () => { - if (pollBusy) return; + + const schedule = () => { + state.pollTimer = setTimeout(tick, nextPollDelay()); + }; + + const tick = async () => { let watched; try { watched = await listActiveWallets(); } catch { - return; // Convex unreachable this tick — try again next time + schedule(); // Convex unreachable this tick — try again later + return; } - if (watched.length === 0) return; - pollBusy = true; - try { - for (const wallet of watched) { - try { - const { content } = await getReservedAccountTransactions(wallet.accountReference); - const paid = content.filter((t) => t.paymentStatus === "PAID"); - await cacheWalletBalance(wallet.accountId, paid.reduce((s, t) => s + t.amount, 0)); - for (const t of paid) { - const parsed = typeof t.createdOn === "number" ? t.createdOn : t.createdOn ? Date.parse(t.createdOn) : Date.now(); - publishEvent( - wallet.accountId, - { - type: "payment", - amount: t.amountPaid ?? t.amount, - from: t.customerDTO?.name ?? "a bank transfer", - reference: t.transactionReference, - }, - Number.isNaN(parsed) ? Date.now() : parsed, - ); - } - } catch { - /* transient — next tick retries this wallet */ + if (watched.length === 0) { + pollFailures = 0; + schedule(); + return; + } + + let reachedProvider = false; + for (const wallet of watched) { + try { + const { content } = await getReservedAccountTransactions(wallet.accountReference); + reachedProvider = true; + const paid = content.filter((t) => t.paymentStatus === "PAID"); + await cacheWalletBalance(wallet.accountId, paid.reduce((s, t) => s + t.amount, 0)); + for (const t of paid) { + const parsed = typeof t.createdOn === "number" ? t.createdOn : t.createdOn ? Date.parse(t.createdOn) : Date.now(); + publishEvent( + wallet.accountId, + { + type: "payment", + amount: t.amountPaid ?? t.amount, + from: t.customerDTO?.name ?? "a bank transfer", + reference: t.transactionReference, + }, + Number.isNaN(parsed) ? Date.now() : parsed, + ); } + } catch { + /* transient for this wallet — the backoff below decides how soon to retry */ } - } finally { - pollBusy = false; } - }, 15000); + + // Backing off is about the provider being unreachable, not about one wallet + // erroring. If anything got through, the connection is fine. + pollFailures = reachedProvider ? 0 : Math.min(pollFailures + 1, 5); + schedule(); + }; + + schedule(); } From 3460d49f4e4dce049e6d336a3f7830a4f147d823 Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sat, 22 Aug 2026 20:15:32 +0100 Subject: [PATCH 3/5] fix(voice): stop the verbal tics, and keep the neural voice alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things a user had to sit through, each fixed at its cause. Aide opened almost every sentence with "Let me", including twice in a row, and the engine's own stall filler said much the same thing — so a pause sounded like Aide starting over. The fillers now split in two: openers only before the first tool call, continuations for a mid-reply lull, worded so they can never be mistaken for the start of a turn. The prompt allows the covering opener once per turn, not once per tool. Aide also claimed to have opened pages it never opened, and asked a blind user whether they could see something. Both are now explicit prompt rules, and tests assert the rules still exist — a deletion inside a long prompt is easy to miss in review. The speech worker's request deadline started at enqueue rather than at synthesis, so queued work burned its budget waiting its turn. Enough short sentences at once and the last few timed out untouched, killing the worker and taking the rest of the reply with it. The clock now belongs to whatever the worker is actually synthesising, and a worker that dies has its pending requests replayed on a fresh process instead of rejected. A request is only text; failing it drops the user to the robotic browser voice mid-sentence for no reason. Finally, AIDE_DIST_DIR: two Next processes in one tree share one build directory and silently overwrite each other, which surfaces much later as a missing-chunk error in whichever server was there first. --- .gitignore | 3 + app/aide/voice-engine.ts | 117 +++++++++++++++++++++--------- app/api/tts/route.ts | 73 +++++++++++++++---- lib/agent/system.ts | 4 +- next.config.mjs | 7 ++ tests/unit/spoken-conduct.test.ts | 65 +++++++++++++++++ 6 files changed, 218 insertions(+), 51 deletions(-) create mode 100644 tests/unit/spoken-conduct.test.ts diff --git a/.gitignore b/.gitignore index 6704187..2f8dfcc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ tts-env/ venv/ .venv/ .vercel + +# Throwaway build dirs used by side-by-side test servers (see AIDE_DIST_DIR) +.next-*/ diff --git a/app/aide/voice-engine.ts b/app/aide/voice-engine.ts index cc86aa6..f8c01d0 100644 --- a/app/aide/voice-engine.ts +++ b/app/aide/voice-engine.ts @@ -45,18 +45,27 @@ const MIC_SILENT_MIN_MS = 3000; // function (/api/speak) instead. Either way the browser voice is the fallback. const TTS_PATH = process.env.NEXT_PUBLIC_TTS_PATH || "/api/tts"; -// Last-resort filler for when the model produces nothing at all for a while. -// Aide is instructed to open every reply with its own short sentence, so this -// should rarely be heard — hearing it every turn would be a verbal tic, not -// conversation. The phrases are a FIXED set so the long Cache-Control on -// /api/speak applies and they play instantly; they rotate so a slow patch -// doesn't repeat the same words back to back. -const THINKING_FILLERS = ["One moment.", "Let me check.", "Just a second.", "Bear with me."]; +// Spoken cover for a wait. Two sets, because the right words depend entirely on +// whether Aide has said anything yet this turn: "Still working on that" is +// nonsense before a first word, and "One moment" is nonsense after one. +// +// Neither set may echo the openers the system prompt suggests to the model. +// They used to: this list held "Let me check." while the prompt suggested +// "Let me check that.", so a slow turn played both back to back. +// +// Both sets are FIXED so the long Cache-Control on /api/speak applies and they +// start instantly, and they rotate so a long wait doesn't repeat itself. +export const OPENING_FILLERS = ["One moment.", "Just a second.", "Bear with me."]; +export const CONTINUING_FILLERS = ["Still working on that.", "Almost there.", "Won't be long now."]; +export const THINKING_FILLERS = [...OPENING_FILLERS, ...CONTINUING_FILLERS]; +// A stalled turn gets a few reassurances, not a running commentary. +const MAX_FILLERS_PER_TURN = 3; // Measured against production: DeepSeek's first sentence reaches the speaker at -// roughly 3.5s. Anything below that fires on EVERY turn, which is how a helpful -// bridge turns into a verbal tic. This sits past it, so it only speaks when a -// reply is genuinely stuck. -const FILLER_AFTER_MS = 4200; +// roughly 3.5s. The old 4200 sat only just past that, so an ordinary slow turn +// tripped it and the user heard the filler collide with Aide's own opening +// line. Aide now covers the pause itself, which makes this a genuine +// last resort for a reply that has actually stalled — so it waits much longer. +const FILLER_AFTER_MS = 7000; // The budget above is silence measured from the USER's point of view, which // starts when they stop talking — not when the request goes out. The quiet // period that decides they finished is part of that silence, so it is @@ -137,6 +146,10 @@ export class VoiceEngine { // beginReply(). private replyAbandoned = false; private ackTimer: ReturnType | null = null; + // Whether a real sentence has been spoken this turn — decides whether a + // cover should sound like a beginning or a continuation. + private spokeThisTurn = false; + private fillersUsed = 0; private fillerIndex = -1; // Sleep/wake: the mic closes after a stretch of silence and a gesture // reopens it, so Aide isn't streaming an empty room indefinitely. @@ -201,12 +214,10 @@ export class VoiceEngine { this.armIdleTimer(); } - // Warm the fillers into the browser cache (and the serverless function) - // while the greeting plays, so the first time one is genuinely needed it - // starts instantly instead of paying full synthesis latency. - for (const phrase of THINKING_FILLERS) { - void fetch(`${TTS_PATH}?text=${encodeURIComponent(phrase)}`).catch(() => {}); - } + // No warm-up request. /api/tts is one Python process synthesizing serially, + // so anything fired at startup queues in front of the greeting — the one + // utterance the user is actually waiting on. The handshake a warm-up would + // pay for is per-process, and the greeting pays it anyway. } // Speak-only mode for browsers with no SpeechRecognition (Firefox, most iOS): @@ -243,6 +254,17 @@ export class VoiceEngine { } // Public speak: interrupts whatever is queued and says this instead. + // + // Sent as ONE request, deliberately. Splitting a known block of text into + // sentences looks like it should start sooner, and it was tried: it made + // things far worse. Synthesis cost is dominated by a fixed per-request round + // trip to the speech service — roughly five seconds here — not by length, so + // a four-sentence greeting split four ways paid that cost four times. The + // whole greeting as one request took six seconds; split, it took forty. + // + // Streamed model replies are different and DO go sentence by sentence, but + // only because their sentences genuinely arrive over time. There is nothing + // to overlap when the full text is already in hand. speak(text: string): void { this.discardQueue(); // primed sentences from the old reply must not play this.speakNow(text); @@ -253,28 +275,47 @@ export class VoiceEngine { beginReply(): void { this.replyPending = true; this.replyAbandoned = false; + this.spokeThisTurn = false; + this.fillersUsed = 0; + this.armFiller(); + } + + // Arm the spoken cover for a wait. + // + // A blind user gets no spinner, so silence is indistinguishable from a dead + // app. This used to be armed once, at the start of a turn, and cleared by the + // first sentence that arrived — which meant it only ever covered the wait + // BEFORE Aide's first word, never the far longer waits after it. A recorded + // session showed the cost: 47% of it was silence, including a 27-second gap + // straight after Aide had spoken, with nothing filling it because the timer + // had already been cleared. So it is re-armed on every mid-reply lull too. + private armFiller(): void { + if (!this.replyPending || this.replyAbandoned) return; + if (this.fillersUsed >= MAX_FILLERS_PER_TURN) return; // reassurance, not commentary + if (this.ackTimer) clearTimeout(this.ackTimer); - // A blind user gets no spinner. Several seconds of silence after speaking - // is indistinguishable from the app being broken, so if the model hasn't - // produced anything audible shortly, say something. Only fires when the - // wait is real — a fast reply cancels it before it is ever heard. - // - // The countdown starts from when the USER stopped talking, not from this - // call: deciding they had finished already cost them a beat of silence, - // and that beat is part of the same wait they are sitting through. Typed - // messages (nothing heard recently) just get the full budget. + // For the FIRST cover the countdown starts from when the user stopped + // talking, not from this call: deciding they had finished already cost + // them a beat, and that beat is part of the same wait. Mid-reply there is + // no such debt — the clock starts now. Typed messages (nothing heard + // recently) just get the full budget. const sinceHeard = Date.now() - this.lastHeardAt; - const alreadyWaited = this.lastHeardAt && sinceHeard < 3000 ? sinceHeard : 0; - const fillerDelay = Math.max(FILLER_MIN_WAIT_MS, FILLER_AFTER_MS - alreadyWaited); + const owed = this.lastHeardAt && sinceHeard < 3000 ? sinceHeard : 0; + const delay = Math.max(FILLER_MIN_WAIT_MS, FILLER_AFTER_MS - (this.spokeThisTurn ? 0 : owed)); - if (this.ackTimer) clearTimeout(this.ackTimer); this.ackTimer = setTimeout(() => { this.ackTimer = null; if (this.replyAbandoned || !this.replyPending) return; - if (this.currentAudio || this.currentUtter || this.queue.length > 0) return; - this.fillerIndex = (this.fillerIndex + 1) % THINKING_FILLERS.length; - this.speakNow(THINKING_FILLERS[this.fillerIndex]); - }, fillerDelay); + // Something is audible or about to be — no cover needed. + if (this.currentAudio || this.currentUtter || this.activeSpeech !== 0 || this.queue.length > 0) return; + // Before a first word it has to sound like a beginning; after one it has + // to sound like a continuation. + const set = this.spokeThisTurn ? CONTINUING_FILLERS : OPENING_FILLERS; + this.fillerIndex = (this.fillerIndex + 1) % set.length; + this.fillersUsed += 1; + this.spokeThisTurn = true; // anything further is now a continuation + this.speakNow(set[this.fillerIndex]); + }, delay); } endReply(): void { @@ -304,6 +345,7 @@ export class VoiceEngine { clearTimeout(this.ackTimer); this.ackTimer = null; } + this.spokeThisTurn = true; // any later cover must sound like a continuation // The speaker is taken — either audible, or downloading the sentence // ahead of this one. Queue behind it and get synthesis started early. if (this.activeSpeech !== 0) { @@ -691,8 +733,13 @@ export class VoiceEngine { return; } // Mid-reply lull: hold the turn open rather than ending it. queueSpeak() - // resumes playback the moment the next sentence arrives. - if (this.replyPending) return; + // resumes playback the moment the next sentence arrives — but a tool can + // take twenty seconds, and nothing else covers that, so arm a spoken cover + // for the wait rather than leaving the user in silence. + if (this.replyPending) { + this.armFiller(); + return; + } this.speaking = false; this.handlers.onState({ speaking: false }); this.speechEndedAt = Date.now(); diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts index c1b8152..d6db27a 100644 --- a/app/api/tts/route.ts +++ b/app/api/tts/route.ts @@ -35,7 +35,11 @@ const WORKER_SCRIPT = path.join(process.cwd(), "scripts", "tts_worker.py"); // Per-request deadline once the worker has a request in flight. Generous, // because a cold worker's first request can take 6s+; a wedged worker is // killed and respawned rather than left to hang future requests forever. -const REQUEST_TIMEOUT_MS = 12000; +const REQUEST_TIMEOUT_MS = 30000; +// A worker that dies is recoverable — a request is only text — so pending work +// is replayed on a fresh process rather than failed. This caps the replaying, +// so a process that dies instantly every time cannot spin forever. +const MAX_ATTEMPTS = 2; const audioCache = new Map(); const CACHE_MAX = 100; @@ -47,20 +51,47 @@ const CACHE_MAX = 100; const BREAKER_COOLDOWN_MS = 30_000; let breakerOpenUntil = 0; -type PendingRequest = { resolve: (buf: Buffer) => void; reject: (err: Error) => void; timer: ReturnType }; +type PendingRequest = { + text: string; + voice: string; + resolve: (buf: Buffer) => void; + reject: (err: Error) => void; + attempts: number; +}; let worker: ChildProcessWithoutNullStreams | null = null; let queue: PendingRequest[] = []; +let frontTimer: ReturnType | null = null; // Incoming stdout bytes for the request currently at the front of the queue. let recvBuf: Buffer = Buffer.alloc(0); let expectedLen: number | null = null; +// The deadline belongs to the request the worker is actually SYNTHESIZING, not +// to everything queued behind it. Starting the clock at enqueue meant a queued +// request burned its whole budget waiting its turn: six warm-up phrases against +// one serial worker was enough for the last few to time out having never been +// touched, which killed the worker and took the greeting down with it, leaving +// the browser to fall back to its robotic voice. +function armFrontTimeout() { + if (frontTimer) clearTimeout(frontTimer); + frontTimer = null; + if (queue.length === 0) return; + frontTimer = setTimeout(() => { + frontTimer = null; + const proc = worker; + if (!proc) return; + // Genuinely wedged: kill it so the next request gets a fresh process. + proc.kill(); + if (worker === proc) worker = null; + }, REQUEST_TIMEOUT_MS); +} + function settleFront(err: Error | null, audio?: Buffer) { const req = queue.shift(); if (!req) return; - clearTimeout(req.timer); if (err) req.reject(err); else req.resolve(audio!); + armFrontTimeout(); // the next request starts its clock when its turn starts } function onWorkerData(chunk: Buffer) { @@ -91,11 +122,22 @@ function spawnWorker(): ChildProcessWithoutNullStreams { proc.on("exit", (code) => { console.warn(`edge_tts worker exited (code ${code}) — will respawn on next request`); if (worker === proc) worker = null; + if (frontTimer) clearTimeout(frontTimer); + frontTimer = null; const pending = queue; queue = []; + // The framing protocol has no way to cancel a single request, so escaping a + // slow synthesis means killing the whole process — which used to reject + // every OTHER sentence queued behind it as well. A user hears that as Aide + // dropping to the robotic fallback voice partway through a reply, seemingly + // at random. A request is only text, so replay it instead of failing it. for (const req of pending) { - clearTimeout(req.timer); - req.reject(new Error("edge_tts worker exited unexpectedly")); + if (req.attempts + 1 < MAX_ATTEMPTS) { + req.attempts += 1; + dispatch(req); + } else { + req.reject(new Error("edge_tts worker exited repeatedly")); + } } }); proc.on("error", (err) => { @@ -126,18 +168,19 @@ if (process.env.NEXT_RUNTIME !== "edge" && !process.env.VERCEL) { }); } +// Hand one request to the worker. Separate from requestSynthesis so the exit +// handler above can re-send work a dead process never finished. +function dispatch(req: PendingRequest): void { + const proc = getWorker(); + queue.push(req); + // Only whatever is at the front of the queue is on the clock. + if (queue.length === 1) armFrontTimeout(); + proc.stdin.write(JSON.stringify({ text: req.text, voice: req.voice }) + "\n", "utf-8"); +} + function requestSynthesis(text: string, voice: string): Promise { return new Promise((resolve, reject) => { - const proc = getWorker(); - const timer = setTimeout(() => { - // The worker is wedged — kill it so the NEXT request gets a fresh - // process instead of queuing behind a synthesis that will never finish. - proc.kill(); - if (worker === proc) worker = null; - }, REQUEST_TIMEOUT_MS); - - queue.push({ resolve, reject, timer }); - proc.stdin.write(JSON.stringify({ text, voice }) + "\n", "utf-8"); + dispatch({ text, voice, resolve, reject, attempts: 0 }); }); } diff --git a/lib/agent/system.ts b/lib/agent/system.ts index ef1cbbc..bd256ea 100644 --- a/lib/agent/system.ts +++ b/lib/agent/system.ts @@ -1,9 +1,11 @@ export const SYSTEM_PROMPT = `You are Aide, a warm, calm voice assistant for a blind or visually impaired worker in Nigeria. You are their eyes and hands on a work-and-pay platform. The user speaks to you and hears your replies aloud — so: - Keep replies short and spoken-natural. No markdown, no lists, no symbols, no emoji. Speak amounts in words where natural ("twelve thousand naira"). -- ALWAYS open with one short, natural sentence before you call any tool — "Let me check that." / "Sure, one second." / "Checking your balance now." It is spoken aloud the moment you say it, and it is the only thing covering the pause while the tool runs. Without it the user hears silence and assumes you are broken. Vary the wording; never use the same opener twice in a row. Then call the tool and carry on in the same breath. +- Open with one short sentence before your FIRST tool call in a turn, and only when the answer actually needs a tool that will take a moment — "One second." / "Checking your balance now." / "Looking that up." It is spoken the instant you say it, and it is the only thing covering the pause. Say it ONCE per turn: if you go on to call more tools, do not announce each one, because the user has already heard you start. Vary it, and never open two turns in a row with the same words. Never begin with "Let me" twice in a row. If you can answer without a tool, just answer — an opener in front of an instant reply is a verbal tic, not politeness. - The platform's screens are: home (talking to you), jobs, payments, profile, and signup. When the user asks to open or see one, call open_page — a small version of you follows them to every screen, so carry on the conversation naturally after navigating. - Follow your own words on screen: whenever you are telling the user about something that lives on a page — job listings, external jobs, their balance, transaction history, their skills or applications — call open_page with the matching section so their screen moves to exactly what you are describing. +- Moving them is an ACTION, never a claim (strict). Do not say you have opened, shown, pulled up, or taken them to a screen unless open_page — or filter_jobs, or start_assessment — actually ran in this same turn and returned ok. Saying it without doing it leaves someone standing where they were told they had left, and they cannot glance up to discover that. If they ask about anything that lives on a page, call open_page first and then tell them where they now are. +- Never lean on sight (strict). Do not ask "can you see it?", and never say "as you can see", "look at", "over on the right", or anything else that assumes they are reading the screen. They are not. Say what is true and useful instead — "the jobs page is open now", "I've read out all three" — and offer to read something again rather than offering to show it. - When the user asks for jobs with conditions (a skill, a pay range, with or without assessment), call filter_jobs — the jobs page opens with those filters applied and you read out the matches. They can adjust the same filters on screen. - New users can join entirely by voice: ask their name and whether they want to be a worker (find and do gigs) or an employer (post work and pay workers), confirm both back, then call create_account. - Onboarding (right after create_account succeeds, or whenever a worker with an empty profile agrees to set it up): offer to set up their profile so you can match them with jobs. If they say yes, have a natural conversation — first ask what kind of work they can do and gather their skills (short phrases like "transcription" or "phone support"), then ask about their experience and turn it into a two-or-three-sentence bio in their own words. One question at a time. Read the skills and the bio back, get a spoken yes, then call update_profile with both. If they decline, say that's fine and they can just say "set up my profile" any time. diff --git a/next.config.mjs b/next.config.mjs index 4d7b8c9..ae3dbdf 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,13 @@ const nextConfig = { reactStrictMode: true, outputFileTracingRoot: import.meta.dirname, + // Two Next processes in one working tree share one build directory, and the + // second one silently overwrites artifacts the first has already loaded — + // which shows up much later as "Cannot find module '../chunks/ssr/...'" in + // whichever server was there first. Anything running alongside your dev + // server (a throwaway instance on another port, a production build) should + // set AIDE_DIST_DIR to keep its output somewhere else entirely. + distDir: process.env.AIDE_DIST_DIR || ".next", }; export default nextConfig; diff --git a/tests/unit/spoken-conduct.test.ts b/tests/unit/spoken-conduct.test.ts new file mode 100644 index 0000000..e6248a7 --- /dev/null +++ b/tests/unit/spoken-conduct.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { CONTINUING_FILLERS, OPENING_FILLERS, THINKING_FILLERS } from "../../app/aide/voice-engine"; +import { SYSTEM_PROMPT } from "../../lib/agent/system"; + +// Aide is only ever heard, never read. These guard the handful of rules that +// make it bearable to listen to — each one is here because the opposite +// shipped and a user had to sit through it. + +describe("the engine filler must not collide with Aide's own opener", () => { + // The filler covers a stalled reply. Aide also opens its turns with a short + // covering sentence. When both fired the user heard them stacked — + // "Let me check. Let me check that for you." — which is what this prevents. + it("never phrases a mid-reply cover as the start of a turn", () => { + // These fire AFTER Aide has spoken. "One moment" there sounds like it is + // starting over, which is how a wait turns into apparent repetition. + for (const filler of CONTINUING_FILLERS) { + expect(filler, `"${filler}" reads as an opener`).not.toMatch(/^(let me|one moment|okay|sure|checking|looking)\b/i); + } + }); + + it("never begins any cover with the model's own habitual opener", () => { + // "Let me ..." is what the model reaches for. A cover that also starts + // that way is indistinguishable from Aide repeating itself. + for (const filler of THINKING_FILLERS) { + expect(filler, `"${filler}" starts like the model's opener`).not.toMatch(/^let me\b/i); + } + }); + + it("keeps the two sets disjoint, so the wrong one cannot be picked", () => { + for (const opening of OPENING_FILLERS) expect(CONTINUING_FILLERS).not.toContain(opening); + }); + + it("uses no wording the system prompt also suggests to the model", () => { + const prompt = SYSTEM_PROMPT.toLowerCase(); + for (const filler of THINKING_FILLERS) { + expect(prompt, `filler "${filler}" is also a prompt example`).not.toContain(filler.toLowerCase()); + } + }); + + it("keeps the fillers distinct from each other", () => { + expect(new Set(THINKING_FILLERS).size).toBe(THINKING_FILLERS.length); + }); + + it("keeps every filler short enough to be a bridge, not a statement", () => { + for (const filler of THINKING_FILLERS) expect(filler.split(/\s+/).length).toBeLessThanOrEqual(5); + }); +}); + +describe("the prompt forbids what a blind user cannot do", () => { + // Not a test of the model — a test that the rules survive future edits to a + // long prompt, where a deletion is easy to miss in review. + it("bans asking whether the user can see something", () => { + expect(SYSTEM_PROMPT).toMatch(/can you see it/i); + expect(SYSTEM_PROMPT).toMatch(/never lean on sight/i); + }); + + it("forbids claiming a screen was opened without actually opening it", () => { + expect(SYSTEM_PROMPT).toMatch(/ACTION, never a claim/); + expect(SYSTEM_PROMPT).toMatch(/open_page/); + }); + + it("limits the covering opener to once per turn", () => { + expect(SYSTEM_PROMPT).toMatch(/ONCE per turn/); + }); +}); From a18ea2a1d84b87e144e713fa4e04eb2791ad444c Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sat, 22 Aug 2026 20:15:45 +0100 Subject: [PATCH 4/5] ci: gate every push on types, tests, build, speech and secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aide moves real money on behalf of users who cannot see the screen to notice when something has gone wrong, so the checks that used to live in one developer's terminal now run on every push and pull request. Five checks, in parallel, each one guarding a failure this codebase has actually had: - Types and Tests are the two gates run by hand before every commit so far. The test job spans both vitest projects, including the Convex functions, which only run inside an edge-runtime VM. - Production build catches routes that compile only under `next build` — the dev server is more forgiving than a deploy is. - Speech worker installs edge-tts and imports both speech entry points. When that path breaks, a blind user hears the robotic browser fallback and gets no explanation anywhere on screen. - No secrets committed refuses a tracked .env, local Convex state (it holds an admin key), or anything shaped like a live credential. All five run against placeholders, never a real provider: what they prove is the wiring, not the keys. Dependabot is monthly and grouped on purpose — a wall of open PRs gets ignored, which is worse than a slightly stale lockfile. --- .github/dependabot.yml | 21 ++++++++ .github/workflows/ci.yml | 108 +++++++++++++++++++++++++++++++++++++++ .gitignore | 4 ++ README.md | 22 ++++++++ package.json | 1 + 5 files changed, 156 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b303c07 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + # Grouped and monthly on purpose: this is a small team, and a wall of open PRs + # gets ignored, which is worse than a slightly older lockfile. Security + # advisories still arrive immediately, separately from this schedule. + - package-ecosystem: npm + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly + - package-ecosystem: pip + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f5a528 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +# Aide moves real money and is driven by a model, for users who cannot see the +# screen to notice something went wrong. Every push runs the same gates a +# reviewer would run by hand, in parallel, so a red check is visible before a +# merge rather than after a withdrawal. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# A second push to the same branch makes the first run irrelevant. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # lib/env.ts refuses to load without these, so `next build` cannot even import + # the payment routes unless they are present. CI never reaches a real + # provider — placeholders are the point: they prove the wiring, not the keys. + MONNIFY_API_KEY: ci-placeholder + MONNIFY_SECRET_KEY: ci-placeholder + MONNIFY_CONTRACT_CODE: "0000000000" + DEEPSEEK_API_KEY: ci-placeholder + NEXT_PUBLIC_CONVEX_URL: https://ci-placeholder.convex.cloud + +jobs: + typecheck: + name: Types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + # Two vitest projects run here: plain Node for the money, agent and speech + # suites, and an edge-runtime VM for the Convex functions, which is the + # only environment convex-test can drive. + - run: npm test + + build: + name: Production build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + + speech: + name: Speech worker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + # The neural voice is not a nice-to-have here — it is the entire + # interface. Both speech entry points guard their entry behind + # __main__, so importing them is safe and proves more than a syntax + # check: that edge-tts still installs and that the names these files + # reach for still exist. A break here would otherwise reach the user as + # the robotic browser fallback voice, with nothing on screen to explain it. + - run: pip install -r requirements.txt + - name: Import both speech entry points + run: | + python -c "import sys; sys.path[:0] = ['scripts', 'api']; import tts_worker, speak" + + secrets: + name: No secrets committed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Refuse tracked env files and local Convex state + run: | + bad=$(git ls-files | grep -E '^(\.env($|\..*)|\.convex/)' | grep -v '^\.env\.example$' || true) + if [ -n "$bad" ]; then + echo "::error::These must never be committed:"; echo "$bad"; exit 1 + fi + - name: Refuse live-looking credentials + run: | + # .env.example carries placeholders only (MK_TEST_xxx, sk-xxxxxxxx), + # so these patterns match real keys and nothing else. + if git grep -nIE 'MK_PROD_[A-Za-z0-9]{6,}|sk-[A-Za-z0-9]{24,}|-----BEGIN [A-Z ]*PRIVATE KEY-----' -- . ':!.github/workflows/ci.yml'; then + echo "::error::A live-looking credential is committed."; exit 1 + fi diff --git a/.gitignore b/.gitignore index 2f8dfcc..dd203dc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ venv/ # Throwaway build dirs used by side-by-side test servers (see AIDE_DIST_DIR) .next-*/ + +# Python bytecode from the TTS worker / speak function +__pycache__/ +*.pyc diff --git a/README.md b/README.md index acdbfc2..4709a96 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A worker talks; Aide does the rest — finds jobs, runs a spoken skill assessmen real bank account, confirms incoming pay, and reads the balance back aloud. No screen required. +[![CI](https://github.com/David-Pjs/Aide/actions/workflows/ci.yml/badge.svg)](https://github.com/David-Pjs/Aide/actions/workflows/ci.yml) + [**Live demo →** aide-ng.vercel.app](https://aide-ng.vercel.app) · Open in Chrome and just talk. --- @@ -268,6 +270,26 @@ npm run balance # wallet balance check `npm run proof` prints `SUCCESS` or the documented `PENDING_AUTHORIZATION` sandbox state +### Continuous integration + +Every push and pull request runs five checks in parallel +([`.github/workflows/ci.yml`](.github/workflows/ci.yml)). They run against placeholder +credentials, never a real provider — what they prove is the wiring, not the keys. + +| Check | What it protects | +|---|---| +| **Types** | `tsc --noEmit` across app, Convex functions, and tests. | +| **Tests** | 267 tests over two vitest projects — plain Node for money, agent, and speech; an edge-runtime VM for the Convex functions, which is the only environment `convex-test` can drive. | +| **Production build** | A full `next build`, so a route that only breaks when compiled cannot reach a deploy. | +| **Speech worker** | Installs `edge-tts` and imports both speech entry points. A break here would otherwise reach a blind user as the robotic fallback voice, with nothing on screen to explain it. | +| **No secrets committed** | Fails on a tracked `.env`, on local Convex state (it holds an admin key), or on anything shaped like a live credential. | + +Run the same gates locally: + +```bash +npm run typecheck && npm test && npm run build +``` + ### Troubleshooting | Symptom | Fix | diff --git a/package.json b/package.json index 2381395..2485abc 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "proof": "tsx src/proof.ts", "webhook": "tsx src/webhook.ts", "balance": "tsx src/balance.ts", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" }, From a3a685f7987595483d36fb749349a1694d91a53e Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sat, 22 Aug 2026 20:15:45 +0100 Subject: [PATCH 5/5] feat(voice): three taps close the microphone, three more open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to tell Aide to stop listening. The mic closes itself after ninety seconds of quiet, but that is Aide's decision, not the user's, and any stray touch reopens it — so a user who wanted a private moment had no way to take one. A mute button would not solve it. A button is only a control if you can find it, and finding it is exactly what these users cannot do. A count of taps needs no target: anywhere on the screen or the trackpad, on the surface their hand is already resting on. Three rather than two, because a hand double-taps by accident and does not triple-tap by accident. Both directions are announced, because the whole state change is otherwise silent and invisible. The closing notice carries the way back inside it — it is the last thing heard before Aide goes quiet, and there is nothing on screen to remind them afterwards. The hold is enforced at startRecognition(), the single function that can open the mic. Its half-dozen callers — a tab regaining focus, a reply finishing, the restart backoff, interrupt() — would each have undone it otherwise, quietly, at a moment the user had no reason to be watching for. The level meter releases its own capture stream too, or the browser's recording dot stays lit and the announcement is a lie. Two smaller things fall out of it: the third tap's trailing click lands on the Aide orb's interrupt handler and would cut off its own announcement, so interrupt() ignores a click that closely follows a toggle; and the gesture is inert in speak-only browsers, where there is no microphone to hold. Aide is told about the gesture in the prompt so it can answer when asked, and told it cannot perform it — the same rule as page navigation. A claim with no action behind it leaves someone believing a microphone is closed when it is open. --- README.md | 18 ++++ app/aide/index.tsx | 31 ++++--- app/aide/voice-engine.ts | 158 ++++++++++++++++++++++++++++++++++-- app/page.tsx | 38 ++++++--- lib/agent/system.ts | 1 + tests/unit/tap-hold.test.ts | 121 +++++++++++++++++++++++++++ 6 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 tests/unit/tap-hold.test.ts diff --git a/README.md b/README.md index 4709a96..e698a9e 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,24 @@ contrast. speech and incoming messages are announced automatically, `role="alert"` on errors, properly associated `