Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ tts-env/
venv/
.venv/
.vercel

# 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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -358,6 +380,24 @@ contrast.
speech and incoming messages are announced automatically, `role="alert"` on errors,
properly associated `<label>`s on every control, and a skip-to-content link.

### Controls with no target to find

A mute button is only a control if you can find it. These three do the same job without
one, and each announces itself aloud, because a state change a user cannot see or hear is
a state change they have to guess at.

| Gesture | What happens |
|---|---|
| **Tap anywhere, or press any key** | Cuts Aide off mid-sentence. Nothing to aim at — a blind user should not have to hunt for a stop button while being talked over. |
| **Tap three times** | Closes the microphone and keeps it closed. Aide says so, and says how to come back, since that sentence is the last thing heard before it goes quiet. Three more taps reopen it. Two would be too easy to do by accident; three is not. |
| **Say nothing for 90 seconds** | Aide closes the mic itself and says it is doing so, rather than streaming an empty room indefinitely. Any tap or key wakes it. |

The held state is enforced at the single function that can open the microphone, not at each
of its callers, so a tab regaining focus, a reply finishing, or a recognizer restarting
cannot quietly undo it. The input-level meter releases its own capture stream at the same
time — otherwise the browser's recording indicator stays lit and "I've stopped listening"
is a lie.

The throughline: **redundancy**. Text, shape, contrast, and voice each carry the meaning on
their own, so no single sensory channel is load-bearing — which is what lets one interface
serve blind, low-vision, and colorblind users at the same time.
Expand Down
31 changes: 20 additions & 11 deletions app/aide/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type AideContextValue = {
listening: boolean;
speaking: boolean;
dormant: boolean;
muted: boolean;
thinking: boolean;
capturing: boolean;
supported: boolean;
Expand Down Expand Up @@ -77,6 +78,7 @@ export function AideProvider({ children }: { children: React.ReactNode }) {
listening: false,
speaking: false,
dormant: false,
muted: false,
interim: "",
micStatus: "starting…",
error: null,
Expand Down Expand Up @@ -237,7 +239,9 @@ export function AideProvider({ children }: { children: React.ReactNode }) {
.catch(() => null)
.then((data) => {
const base = data?.greeting || "Hello, I'm Aide. I'm listening — just talk to me.";
const greeting = `${base} By the way, you can stop me any time — just tap the screen or press any key.`;
// The gesture has no visual affordance at all, so the only place a
// user can learn it is here. Kept to one clause, said once a session.
const greeting = `${base} By the way, you can stop me any time — just tap the screen or press any key. Tap three times if you'd like me to stop listening altogether.`;
setMessages((m) => [...m, { role: "assistant", content: greeting }]);
engine.speak(greeting);
});
Expand Down Expand Up @@ -287,6 +291,7 @@ export function AideProvider({ children }: { children: React.ReactNode }) {
listening: voice.listening,
speaking: voice.speaking,
dormant: voice.dormant,
muted: voice.muted,
thinking,
capturing,
supported,
Expand Down Expand Up @@ -338,17 +343,21 @@ function PaymentAlerts({
// The small Aide that follows the user onto every other screen. It glows
// while talking and pulses while listening; tapping it interrupts Aide.
function MiniAide() {
const { listening, speaking, thinking, capturing, interim, messages, interrupt } = useAide();
const { listening, speaking, thinking, capturing, muted, interim, messages, interrupt } = useAide();
const lastAide = [...messages].reverse().find((m) => m.role === "assistant")?.content;
const status = speaking
? "Aide is speaking"
: thinking
? "Aide is thinking"
: capturing
? "Aide is writing down what you say"
: listening
? "Aide is listening"
: "Aide is paused";
// Held beats everything below it. Announcing "Aide is listening" while the
// user has deliberately closed the mic is the one lie that matters here.
const status = muted
? "Aide is not listening — tap three times to start again"
: speaking
? "Aide is speaking"
: thinking
? "Aide is thinking"
: capturing
? "Aide is writing down what you say"
: listening
? "Aide is listening"
: "Aide is paused";

return (
<div className="fixed bottom-5 right-5 z-40 flex flex-col items-end gap-2">
Expand Down
Loading
Loading