ava-engine 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +63 -0
- package/CLA.md +43 -0
- package/COMMERCIAL-LICENSE.md +43 -0
- package/LICENSE +73 -0
- package/README.md +240 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/TRADEMARKS.md +31 -0
- package/bin/ava-engine.mjs +12 -0
- package/lib/asr.mjs +147 -0
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/config.mjs +95 -0
- package/lib/cli/main.mjs +187 -0
- package/lib/cli/setup-server.mjs +195 -0
- package/lib/cli/setup.mjs +233 -0
- package/lib/fetch-models.mjs +104 -0
- package/lib/guard.mjs +92 -0
- package/lib/kokoro-model.mjs +51 -0
- package/lib/models.mjs +47 -0
- package/lib/openrouter.mjs +153 -0
- package/lib/ours.mjs +766 -0
- package/lib/platforms.mjs +201 -0
- package/lib/state.mjs +150 -0
- package/lib/stt.mjs +22 -0
- package/lib/tts.mjs +178 -0
- package/lib/workflow.mjs +18 -0
- package/package.json +80 -4
- package/public/app.js +1708 -0
- package/public/demo.js +420 -0
- package/public/favicon.svg +1 -0
- package/public/fonts/OFL.txt +96 -0
- package/public/fonts/inter-tight-var.woff2 +0 -0
- package/public/fonts/inter-var.woff2 +0 -0
- package/public/fonts/plex-mono-400.woff2 +0 -0
- package/public/fonts/plex-mono-500.woff2 +0 -0
- package/public/img/cta-1600.avif +0 -0
- package/public/img/cta-1600.webp +0 -0
- package/public/img/cta-2400.avif +0 -0
- package/public/img/cta-2400.webp +0 -0
- package/public/img/cta-960.avif +0 -0
- package/public/img/cta-960.webp +0 -0
- package/public/img/flows-1600.avif +0 -0
- package/public/img/flows-1600.webp +0 -0
- package/public/img/flows-2400.avif +0 -0
- package/public/img/flows-2400.webp +0 -0
- package/public/img/flows-960.avif +0 -0
- package/public/img/flows-960.webp +0 -0
- package/public/img/hero-1600.avif +0 -0
- package/public/img/hero-1600.webp +0 -0
- package/public/img/hero-2400.avif +0 -0
- package/public/img/hero-2400.webp +0 -0
- package/public/img/hero-960.avif +0 -0
- package/public/img/hero-960.webp +0 -0
- package/public/img/p-csm-480.avif +0 -0
- package/public/img/p-csm-480.webp +0 -0
- package/public/img/p-csm-800.avif +0 -0
- package/public/img/p-csm-800.webp +0 -0
- package/public/img/p-docs-480.avif +0 -0
- package/public/img/p-docs-480.webp +0 -0
- package/public/img/p-docs-800.avif +0 -0
- package/public/img/p-docs-800.webp +0 -0
- package/public/img/p-quiet-480.avif +0 -0
- package/public/img/p-quiet-480.webp +0 -0
- package/public/img/p-quiet-800.avif +0 -0
- package/public/img/p-quiet-800.webp +0 -0
- package/public/img/p-tickets-480.avif +0 -0
- package/public/img/p-tickets-480.webp +0 -0
- package/public/img/p-tickets-800.avif +0 -0
- package/public/img/p-tickets-800.webp +0 -0
- package/public/img/p-trial-480.avif +0 -0
- package/public/img/p-trial-480.webp +0 -0
- package/public/img/p-trial-800.avif +0 -0
- package/public/img/p-trial-800.webp +0 -0
- package/public/img/sees-1600.avif +0 -0
- package/public/img/sees-1600.webp +0 -0
- package/public/img/sees-2400.avif +0 -0
- package/public/img/sees-2400.webp +0 -0
- package/public/img/sees-960.avif +0 -0
- package/public/img/sees-960.webp +0 -0
- package/public/img/speaks-1600.avif +0 -0
- package/public/img/speaks-1600.webp +0 -0
- package/public/img/speaks-2400.avif +0 -0
- package/public/img/speaks-2400.webp +0 -0
- package/public/img/speaks-960.avif +0 -0
- package/public/img/speaks-960.webp +0 -0
- package/public/img/voice-1600.avif +0 -0
- package/public/img/voice-1600.webp +0 -0
- package/public/img/voice-2400.avif +0 -0
- package/public/img/voice-2400.webp +0 -0
- package/public/img/voice-960.avif +0 -0
- package/public/img/voice-960.webp +0 -0
- package/public/index.html +440 -0
- package/public/landing.html +975 -0
- package/public/pcm-worklet.js +11 -0
- package/public/perceptionWorker.js +103 -0
- package/public/rtc.js +60 -0
- package/public/setup.css +94 -0
- package/public/setup.html +129 -0
- package/public/setup.js +264 -0
- package/public/specialist.html +92 -0
- package/public/specialist.js +132 -0
- package/public/style.css +836 -0
- package/public/vendor/lenis.min.js +3 -0
- package/scripts/fetch-models.sh +15 -0
- package/server.mjs +502 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Complex platforms the onboarding agent can specialise in. Each entry gives the
|
|
2
|
+
// talker/planner enough to act like a real product specialist for that product:
|
|
3
|
+
// the sign-up → dashboard path, a clean short "journey" for the live progress
|
|
4
|
+
// tracker, and a small knowledge base the agent can retrieve from (RAG).
|
|
5
|
+
|
|
6
|
+
export const PLATFORMS = [
|
|
7
|
+
{
|
|
8
|
+
id: "todoist", name: "Todoist", url: "https://app.todoist.com",
|
|
9
|
+
category: "Productivity", accent: "#E44332",
|
|
10
|
+
tagline: "Dead-simple task manager — you're up and running in about a minute.",
|
|
11
|
+
firstWin: "your first task added and showing in your Today list",
|
|
12
|
+
path: [
|
|
13
|
+
"sign in or sign up (Google or email — it's quick)",
|
|
14
|
+
"you land on the Today view; click 'Add task' at the top (or just press the A key)",
|
|
15
|
+
"type a task, leave it due Today, and press Enter — it appears in your list, and that's the win",
|
|
16
|
+
],
|
|
17
|
+
optional: "Projects, labels, and reminders are all there when you want them, but you don't need any of that to get started.",
|
|
18
|
+
journey: ["Sign in", "Add your first task", "See it in Today"],
|
|
19
|
+
etaMin: 2,
|
|
20
|
+
knowledge: [
|
|
21
|
+
{ k: "add task new create shortcut a key", text: "To add a task, click 'Add task' at the top of the list or just press the A key. Type it and hit Enter to save." },
|
|
22
|
+
{ k: "today view due date schedule tomorrow", text: "The Today view shows what's due today. When adding a task you can type a due date in plain words like 'tomorrow 5pm' and Todoist schedules it automatically." },
|
|
23
|
+
{ k: "complete task done check circle", text: "Click the circle to the left of a task to complete it — it checks off and leaves your list." },
|
|
24
|
+
{ k: "project inbox organize sidebar", text: "Projects in the left sidebar group related tasks. You don't need one to start — new tasks land in Inbox or Today by default." },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "supabase", name: "Supabase", url: "https://supabase.com/dashboard",
|
|
29
|
+
category: "Developer backend", accent: "#3ECF8E",
|
|
30
|
+
tagline: "Postgres, auth, storage, edge functions — a lot of surface for a newcomer.",
|
|
31
|
+
firstWin: "your first project's dashboard, open and showing Healthy",
|
|
32
|
+
path: [
|
|
33
|
+
"sign in (GitHub or email)",
|
|
34
|
+
"create an organization (name it, choose the free plan)",
|
|
35
|
+
"create a new project — name it, set a strong database password (the Generate button works), pick the closest region",
|
|
36
|
+
"click Create new project and wait for it to provision",
|
|
37
|
+
"land on the project dashboard — once it shows Healthy, they're in and onboarding is DONE",
|
|
38
|
+
],
|
|
39
|
+
optional: "If they want their API keys afterward, they live under Settings → API Keys (Project URL is on that same page). This is a bonus, not required to finish.",
|
|
40
|
+
journey: ["Sign in", "Create an organization", "Create a project", "Wait for provisioning", "Open your dashboard"],
|
|
41
|
+
etaMin: 6,
|
|
42
|
+
knowledge: [
|
|
43
|
+
{ k: "password database generate strong project", text: "On the Create Project screen, the database password must be strong. The 'Generate a password' link fills a strong one automatically — click it if theirs is rejected as weak." },
|
|
44
|
+
{ k: "region project location latency", text: "Pick the region closest to the user or their users — it can't be changed after the project is created, only by making a new project." },
|
|
45
|
+
{ k: "api keys anon publishable url settings", text: "The Project URL and API keys live under Settings → API Keys. The 'publishable'/anon key is safe for browser code; the secret/service key must stay server-side." },
|
|
46
|
+
{ k: "free plan pricing organization tier", text: "The Free plan is enough to start: 2 free projects, 500MB database, 1GB storage. You choose the plan when creating the organization." },
|
|
47
|
+
{ k: "provision wait healthy time", text: "A new project takes about 2 minutes to provision. The dashboard shows 'Setting up' then 'Healthy' when it's ready to use." },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
// Fictional product used by demo mode (/app?demo=simple). Not in the lobby.
|
|
52
|
+
id: "basewell", name: "Basewell", url: "https://app.basewell.example", demoOnly: true,
|
|
53
|
+
category: "Developer backend", accent: "#F2541B",
|
|
54
|
+
tagline: "Postgres, auth and file storage in one place: quick to start, lots to learn.",
|
|
55
|
+
firstWin: "your first project's dashboard, open and showing Healthy",
|
|
56
|
+
path: [
|
|
57
|
+
"sign in (GitHub or email)",
|
|
58
|
+
"create a workspace (name it, keep the Free plan)",
|
|
59
|
+
"create a new project: name it, set a strong database password (the 'Generate a password' link works), pick the closest region",
|
|
60
|
+
"click Create new project and wait for it to provision",
|
|
61
|
+
"land on the project dashboard; once it shows Healthy, they're in and onboarding is DONE",
|
|
62
|
+
],
|
|
63
|
+
optional: "Their Project URL and public API key are on the dashboard under 'Get your API keys'. A bonus, not required to finish.",
|
|
64
|
+
journey: ["Sign in", "Create a workspace", "Create a project", "Wait for provisioning", "Open your dashboard"],
|
|
65
|
+
etaMin: 5,
|
|
66
|
+
knowledge: [
|
|
67
|
+
{ k: "password database generate strong project weak", text: "On Create a new project, the database password must be strong. The 'Generate a password' link (right above the field) fills a strong one automatically." },
|
|
68
|
+
{ k: "region project location latency", text: "Pick the region closest to the user's customers. It can't be changed later without creating a new project." },
|
|
69
|
+
{ k: "api keys public url connect", text: "The Project URL and the public API key are on the project dashboard under 'Get your API keys'. The public key is safe in browser code." },
|
|
70
|
+
{ k: "free plan pricing workspace organization tier", text: "The Free plan is enough to start. The plan is chosen when the workspace is created and can be upgraded later." },
|
|
71
|
+
{ k: "provision wait healthy time setting up", text: "A new project takes about a minute to provision. The dashboard shows 'Setting up your project', then 'Project is healthy' when it's ready." },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "stripe", name: "Stripe", url: "https://dashboard.stripe.com",
|
|
76
|
+
category: "Payments", accent: "#635BFF",
|
|
77
|
+
tagline: "Powerful but sprawling — activation, test mode, API keys, first payment.",
|
|
78
|
+
firstWin: "your dashboard in test mode with your API keys located",
|
|
79
|
+
path: [
|
|
80
|
+
"sign in or create an account",
|
|
81
|
+
"confirm you're in Test mode (toggle, top right)",
|
|
82
|
+
"open the Developers → API keys page",
|
|
83
|
+
"note the publishable + secret test keys",
|
|
84
|
+
"explore the Payments tab where charges will appear",
|
|
85
|
+
],
|
|
86
|
+
optional: "Activating live payments needs business details; that can wait until after they've tested.",
|
|
87
|
+
journey: ["Sign in", "Switch to Test mode", "Open API keys", "Copy your test keys", "See the Payments tab"],
|
|
88
|
+
etaMin: 5,
|
|
89
|
+
knowledge: [
|
|
90
|
+
{ k: "test mode live toggle sandbox", text: "The Test mode toggle is top-right. In test mode no real money moves and you use test card 4242 4242 4242 4242. Live mode needs account activation with business details." },
|
|
91
|
+
{ k: "api keys publishable secret developers", text: "API keys are under Developers → API keys. The publishable key (pk_) goes in client code; the secret key (sk_) must stay on your server." },
|
|
92
|
+
{ k: "payment first charge test card", text: "Use card number 4242 4242 4242 4242 with any future expiry and any CVC to create a successful test payment." },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "aws", name: "AWS Console", url: "https://console.aws.amazon.com",
|
|
97
|
+
category: "Cloud infrastructure", accent: "#FF9900",
|
|
98
|
+
tagline: "The classic maze — regions, services, IAM. Easy to get lost.",
|
|
99
|
+
firstWin: "the right region selected and the EC2 dashboard open",
|
|
100
|
+
path: [
|
|
101
|
+
"sign in to the console (root or IAM user)",
|
|
102
|
+
"check the region selector, top right — pick the closest region",
|
|
103
|
+
"search for the service you need in the top search bar (e.g. EC2)",
|
|
104
|
+
"open that service's dashboard",
|
|
105
|
+
"find the primary create/launch action",
|
|
106
|
+
],
|
|
107
|
+
optional: "Setting up billing alerts and an IAM user instead of root is good hygiene, but not needed to look around.",
|
|
108
|
+
journey: ["Sign in", "Choose your region", "Find your service", "Open its dashboard", "Start your first resource"],
|
|
109
|
+
etaMin: 7,
|
|
110
|
+
knowledge: [
|
|
111
|
+
{ k: "region selector location resources", text: "The region selector is top-right. Resources are region-specific — if something you created 'disappears', check you're in the same region you made it in." },
|
|
112
|
+
{ k: "iam root user security", text: "Avoid using the root account for daily work. Create an IAM user with appropriate permissions and sign in as that instead." },
|
|
113
|
+
{ k: "service search ec2 s3 find", text: "The top search bar jumps to any service by name — type 'EC2', 'S3', 'Lambda' etc. rather than hunting the menu." },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "salesforce", name: "Salesforce", url: "https://login.salesforce.com",
|
|
118
|
+
category: "Enterprise CRM", accent: "#00A1E0",
|
|
119
|
+
tagline: "Dense enterprise UI — objects, tabs, setup. Notoriously steep.",
|
|
120
|
+
firstWin: "your Sales home with a first lead or contact created",
|
|
121
|
+
path: [
|
|
122
|
+
"log in to your org",
|
|
123
|
+
"make sure the Sales app is selected (App Launcher, top left)",
|
|
124
|
+
"open the Leads or Contacts tab",
|
|
125
|
+
"use the New button to create your first record",
|
|
126
|
+
"save and view it on the record page",
|
|
127
|
+
],
|
|
128
|
+
optional: "Customising fields and page layouts lives in Setup (gear icon) — a later step.",
|
|
129
|
+
journey: ["Log in", "Open the Sales app", "Go to Leads", "Create a record", "Save and view it"],
|
|
130
|
+
etaMin: 6,
|
|
131
|
+
knowledge: [
|
|
132
|
+
{ k: "app launcher switch sales waffle", text: "The App Launcher is the grid/waffle icon, top-left. Use it to switch between apps like Sales, Service, and Marketing." },
|
|
133
|
+
{ k: "lead contact new record create", text: "Leads and Contacts each have a New button on their list view (top-right) to create a record. A Lead is an unqualified prospect; a Contact belongs to an Account." },
|
|
134
|
+
{ k: "setup customize fields gear", text: "Setup (the gear icon, top-right) is where you customise objects, fields, and layouts — separate from day-to-day record work." },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "datadog", name: "Datadog", url: "https://app.datadoghq.com",
|
|
139
|
+
category: "Observability", accent: "#632CA6",
|
|
140
|
+
tagline: "Deep product — agents, metrics, dashboards, monitors everywhere.",
|
|
141
|
+
firstWin: "a dashboard open with at least one metric graphed",
|
|
142
|
+
path: [
|
|
143
|
+
"log in",
|
|
144
|
+
"open the Dashboards section from the left nav",
|
|
145
|
+
"create a new dashboard",
|
|
146
|
+
"add a timeseries widget",
|
|
147
|
+
"pick a metric to graph and save",
|
|
148
|
+
],
|
|
149
|
+
optional: "Installing the Agent to send real metrics is the bigger step; you can explore built-in integrations first.",
|
|
150
|
+
journey: ["Log in", "Open Dashboards", "Create a dashboard", "Add a widget", "Graph a metric"],
|
|
151
|
+
etaMin: 6,
|
|
152
|
+
knowledge: [
|
|
153
|
+
{ k: "dashboard new create widget", text: "Dashboards are in the left nav. New Dashboard → then drag in a Timeseries widget to start graphing." },
|
|
154
|
+
{ k: "metric graph timeseries query", text: "In a Timeseries widget, pick a metric in the query editor (e.g. system.cpu.user). Built-in integration metrics appear even before you install the Agent." },
|
|
155
|
+
{ k: "agent install host metrics", text: "The Datadog Agent runs on your hosts to send metrics/logs. Install instructions are under Integrations → Agent." },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "vercel", name: "Vercel", url: "https://vercel.com/dashboard",
|
|
160
|
+
category: "Deployment", accent: "#111827",
|
|
161
|
+
tagline: "Git-connected deploys, projects, domains, env vars.",
|
|
162
|
+
firstWin: "your first project deployed and its production URL live",
|
|
163
|
+
path: [
|
|
164
|
+
"sign in (usually with GitHub)",
|
|
165
|
+
"click Add New → Project",
|
|
166
|
+
"import a Git repository",
|
|
167
|
+
"confirm the framework preset and deploy",
|
|
168
|
+
"open the production URL when the build finishes",
|
|
169
|
+
],
|
|
170
|
+
optional: "Custom domains and environment variables are set per-project after the first deploy.",
|
|
171
|
+
journey: ["Sign in", "Add new project", "Import a repo", "Deploy", "Open your live URL"],
|
|
172
|
+
etaMin: 5,
|
|
173
|
+
knowledge: [
|
|
174
|
+
{ k: "import repository git github project", text: "Add New → Project lets you import a Git repo. Vercel auto-detects the framework (Next.js, Vite, etc.) — usually no config needed." },
|
|
175
|
+
{ k: "environment variables env project settings", text: "Environment variables are set per project under Settings → Environment Variables, and apply to Production/Preview/Development." },
|
|
176
|
+
{ k: "domain custom production url", text: "Every deploy gets a vercel.app URL. Add a custom domain under the project's Domains tab." },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "google-workspace", name: "Google Workspace", url: "https://mail.google.com", demoOnly: true,
|
|
181
|
+
category: "Productivity", accent: "#4285F4",
|
|
182
|
+
tagline: "Gmail, Calendar, Docs — everyday multi-app work.",
|
|
183
|
+
firstWin: "the thing they came to do, done",
|
|
184
|
+
path: [],
|
|
185
|
+
journey: ["Read the email", "Reply", "Add it to Calendar", "Confirm the event"],
|
|
186
|
+
etaMin: 4,
|
|
187
|
+
knowledge: [
|
|
188
|
+
{ k: "latest recent newest email top inbox order", text: "In Gmail the newest email is at the very top of the inbox list; each row shows sender, subject and time. 'Latest' means the top row." },
|
|
189
|
+
{ k: "sender address email from who", text: "Open an email and the sender's name is at the top; their full address (name <address@domain>) shows next to it or when you click the sender name." },
|
|
190
|
+
{ k: "reply respond email", text: "To reply, open the email and click Reply (bottom of the message, or the arrow top-right); the compose box opens with the recipient pre-filled." },
|
|
191
|
+
{ k: "calendar event create meeting add schedule", text: "In Google Calendar, click Create (top-left) or click a time slot to add an event; set title, time and guests, then Save." },
|
|
192
|
+
{ k: "guest invite attendee calendar", text: "Add people to an event in the 'Add guests' field; they get an email invite and it appears on their calendar." },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
export function platformById(id) { return PLATFORMS.find((p) => p.id === id) || null; }
|
|
198
|
+
// Trimmed shape for the onboarding picker in the client (demo-only entries hidden).
|
|
199
|
+
// Demo-only (fictional) products are included, flagged, so demo mode can select
|
|
200
|
+
// them; the lobby's platform picker leaves them out.
|
|
201
|
+
export function platformsView() { return PLATFORMS.map(({ id, name, url, category, tagline, accent, demoOnly }) => ({ id, name, url, category, tagline, accent, ...(demoOnly ? { demoOnly: true } : {}) })); }
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// In-memory session store for the flexible agent.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const sessions = new Map();
|
|
6
|
+
const byKey = new Map(); // specialist key → session id (see mintSpecialistKey)
|
|
7
|
+
|
|
8
|
+
function emptyScore() {
|
|
9
|
+
return { turns: 0, promptTokens: 0, completionTokens: 0, cost: 0, latencies: [], perception: { captured: 0, analysed: 0 } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 50;
|
|
13
|
+
const MAX_AGE_MS = 6 * 3600 * 1000;
|
|
14
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function drop(id) {
|
|
17
|
+
const s = sessions.get(id);
|
|
18
|
+
if (s?.specialistKey) revokeSpecialistKey(s);
|
|
19
|
+
sessions.delete(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Room for one more session? Expired and idle sessions are pruned first. */
|
|
23
|
+
export function hasCapacity() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id); }
|
|
26
|
+
return sessions.size < MAX_SESSIONS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function newSession(opts = {}) {
|
|
30
|
+
if (!hasCapacity()) return null; // never evict someone's live call to make room
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
const mode = opts.mode === "onboarding" ? "onboarding" : "flexible";
|
|
33
|
+
// platform can attach in ANY mode — it supplies the RAG knowledge base; it only
|
|
34
|
+
// drives the persona + fixed goal when in onboarding mode.
|
|
35
|
+
const platform = opts.platform || null;
|
|
36
|
+
sessions.set(id, {
|
|
37
|
+
id,
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
lastActive: Date.now(),
|
|
40
|
+
specialistKey: null, // capability for the human-specialist console; minted on handoff
|
|
41
|
+
ours: {
|
|
42
|
+
mode, // "flexible" | "onboarding"
|
|
43
|
+
platform, // onboarding target {id,name,url,firstWin,path,...} or null
|
|
44
|
+
screenState: null, // last structured screen state {app,page,elements,state,v}
|
|
45
|
+
screenVersion: 0,
|
|
46
|
+
lastHash: null, // last perceptual hash we ran vision on
|
|
47
|
+
goal: mode === "onboarding" && platform ? `Get set up on ${platform.name} and into the dashboard` : "—", // the concrete goal the agent is driving toward
|
|
48
|
+
understanding: "—", // planner's running read of what the user is trying to do
|
|
49
|
+
plan: [], // planner's short grounded task list
|
|
50
|
+
guidance: "—", // planner's tone/pace note
|
|
51
|
+
planApp: null, // the app the current plan was made for (pivot detection)
|
|
52
|
+
frustration: 0, // rolling frustration signal
|
|
53
|
+
turnsSincePlan: 0,
|
|
54
|
+
lastProactive: 0, // timestamp of last proactive utterance
|
|
55
|
+
lastTalkerScreen: "", // "app · page" the agent last addressed (to detect navigation since)
|
|
56
|
+
lastIntent: null, // most recent intent read {intent, frustrated, request}
|
|
57
|
+
lastThink: null, // most recent reasoning verdict {resolved, stuck, blocker, thought, nextTactic}
|
|
58
|
+
prevResolved: false, // the previous turn's task was confirmed done → re-plan on the next message
|
|
59
|
+
done: false, // the whole task/journey is finished on screen → stop guiding
|
|
60
|
+
doneAnnounced: false, // already gave the one wrap-up celebration → stay quiet
|
|
61
|
+
// live workflow tracker — the journey the user is being walked through
|
|
62
|
+
workflow: (mode === "onboarding" && platform && platform.journey) ? {
|
|
63
|
+
title: `Getting set up on ${platform.name}`,
|
|
64
|
+
steps: platform.journey.map((t, i) => ({ title: t, status: i === 0 ? "active" : "todo" })),
|
|
65
|
+
current: 0,
|
|
66
|
+
etaMin: platform.etaMin || null,
|
|
67
|
+
note: "",
|
|
68
|
+
} : null,
|
|
69
|
+
handoff: { requested: false, state: "none" }, // none | connecting | waiting | joined
|
|
70
|
+
knowledgeUsed: [], // snippets retrieved for the last reply (RAG)
|
|
71
|
+
bridge: { seq: 0, log: [], specialist: false }, // shared conversation the human specialist joins
|
|
72
|
+
lastSpokeAt: 0, // when Ava last said anything (reply or proactive)
|
|
73
|
+
goalGuessed: false, // goal came from the silent-browsing intention guesser
|
|
74
|
+
lastGuessAt: 0, // when the intention guesser last ran
|
|
75
|
+
activity: [], // inferred user actions over time [{at, action}] — the activity stream
|
|
76
|
+
history: [], // compact [{role, text}]
|
|
77
|
+
},
|
|
78
|
+
score: { ours: emptyScore() },
|
|
79
|
+
});
|
|
80
|
+
return sessions.get(id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getSession(id) {
|
|
84
|
+
const s = typeof id === "string" ? sessions.get(id) : undefined;
|
|
85
|
+
if (s) s.lastActive = Date.now();
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The specialist console gets its own key instead of the user's session id, so a
|
|
90
|
+
// leaked handoff link can't act as the user, and cancelling the handoff revokes it.
|
|
91
|
+
export function mintSpecialistKey(sess) {
|
|
92
|
+
if (sess.specialistKey) return sess.specialistKey;
|
|
93
|
+
sess.specialistKey = randomUUID();
|
|
94
|
+
byKey.set(sess.specialistKey, sess.id);
|
|
95
|
+
return sess.specialistKey;
|
|
96
|
+
}
|
|
97
|
+
const revokeHooks = [];
|
|
98
|
+
/** Run fn(sess) whenever a specialist key is revoked (the server closes their live link). */
|
|
99
|
+
export function onSpecialistRevoked(fn) { revokeHooks.push(fn); }
|
|
100
|
+
export function revokeSpecialistKey(sess) {
|
|
101
|
+
if (!sess.specialistKey) return;
|
|
102
|
+
byKey.delete(sess.specialistKey);
|
|
103
|
+
sess.specialistKey = null;
|
|
104
|
+
for (const fn of revokeHooks) { try { fn(sess); } catch {} }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The customer hung up: forget the call now instead of waiting for it to idle out. */
|
|
108
|
+
export function endSession(id) {
|
|
109
|
+
const s = sessions.get(id);
|
|
110
|
+
if (!s) return false;
|
|
111
|
+
revokeSpecialistKey(s);
|
|
112
|
+
drop(id);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
export function getSessionBySpecialistKey(key) {
|
|
116
|
+
return typeof key === "string" ? getSession(byKey.get(key)) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function record(sess, arch, usages, hotLatencyMs) {
|
|
120
|
+
const sc = sess.score[arch];
|
|
121
|
+
sc.turns += 1;
|
|
122
|
+
for (const u of usages) {
|
|
123
|
+
sc.promptTokens += u.promptTokens || 0;
|
|
124
|
+
sc.completionTokens += u.completionTokens || 0;
|
|
125
|
+
sc.cost += u.cost || 0;
|
|
126
|
+
}
|
|
127
|
+
const lat = hotLatencyMs != null ? hotLatencyMs : usages.reduce((s, u) => s + (u.latencyMs || 0), 0);
|
|
128
|
+
sc.latencies.push(lat);
|
|
129
|
+
return lat;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Add token/cost without counting a turn (used by the background observe loop).
|
|
133
|
+
export function addTokens(sess, arch, usages) {
|
|
134
|
+
const sc = sess.score[arch];
|
|
135
|
+
for (const u of usages) { sc.promptTokens += u.promptTokens || 0; sc.completionTokens += u.completionTokens || 0; sc.cost += u.cost || 0; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function scoreView(sc) {
|
|
139
|
+
const lat = sc.latencies;
|
|
140
|
+
const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0;
|
|
141
|
+
return {
|
|
142
|
+
turns: sc.turns,
|
|
143
|
+
promptTokens: sc.promptTokens,
|
|
144
|
+
completionTokens: sc.completionTokens,
|
|
145
|
+
totalTokens: sc.promptTokens + sc.completionTokens,
|
|
146
|
+
cost: sc.cost,
|
|
147
|
+
avgLatencyMs: avg,
|
|
148
|
+
perception: sc.perception,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/lib/stt.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Deepgram speech-to-text (nova-3): the cloud fallback that turns a recorded
|
|
2
|
+
// utterance into text when the offline sherpa models aren't available.
|
|
3
|
+
|
|
4
|
+
export const STT_ENABLED = !!process.env.DEEPGRAM_API_KEY;
|
|
5
|
+
const MODEL = process.env.DEEPGRAM_MODEL || "nova-3";
|
|
6
|
+
|
|
7
|
+
// buffer: raw audio bytes; contentType e.g. "audio/webm" or "audio/mpeg".
|
|
8
|
+
export async function transcribe(buffer, contentType) {
|
|
9
|
+
const res = await fetch(`https://api.deepgram.com/v1/listen?model=${MODEL}&smart_format=true&punctuate=true`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Authorization": "Token " + process.env.DEEPGRAM_API_KEY, "Content-Type": contentType || "audio/webm" },
|
|
12
|
+
body: buffer,
|
|
13
|
+
signal: AbortSignal.timeout(30_000),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
let msg = await res.text().catch(() => "");
|
|
17
|
+
try { const j = JSON.parse(msg); msg = j.err_msg || j.reason || msg; } catch {}
|
|
18
|
+
const e = new Error(`Deepgram ${res.status}: ${String(msg).slice(0, 200)}`); e.status = res.status; throw e;
|
|
19
|
+
}
|
|
20
|
+
const j = await res.json();
|
|
21
|
+
return j.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
|
|
22
|
+
}
|
package/lib/tts.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Text-to-speech providers. Voice is presentation only.
|
|
2
|
+
// "kokoro" runs on the SERVER (free, no cloud/credits) so the client just plays
|
|
3
|
+
// audio — works on the oldest device. ElevenLabs/Hume are cloud (paid). "browser"
|
|
4
|
+
// is the on-device Web Speech fallback.
|
|
5
|
+
|
|
6
|
+
import { loadKokoro, kokoroInstalled } from "./kokoro-model.mjs";
|
|
7
|
+
|
|
8
|
+
const HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
|
|
9
|
+
const HAS_HUME = !!process.env.HUME_API_KEY;
|
|
10
|
+
const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default
|
|
11
|
+
|
|
12
|
+
const ELEVEN_VOICE = process.env.ELEVEN_VOICE_ID || "XrExE9yKIg1WjnnlVkGX";
|
|
13
|
+
const ELEVEN_MODEL = process.env.ELEVEN_MODEL || "eleven_turbo_v2_5";
|
|
14
|
+
const ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
|
|
15
|
+
const HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
|
|
16
|
+
const HUME_LABEL = process.env.HUME_LABEL || "Aria";
|
|
17
|
+
const HUME_DESC = process.env.HUME_DESCRIPTION || "Warm, empathetic, natural and reassuring";
|
|
18
|
+
const KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
|
|
19
|
+
|
|
20
|
+
export const TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
|
|
21
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
22
|
+
const MAX_KOKORO_QUEUE = Number(process.env.KOKORO_MAX_QUEUE) || 6;
|
|
23
|
+
|
|
24
|
+
/** Thrown when the local voice already has a full queue; the server answers 429. */
|
|
25
|
+
export class TtsBusyError extends Error { constructor() { super("tts busy"); } }
|
|
26
|
+
|
|
27
|
+
export function providers() {
|
|
28
|
+
return [
|
|
29
|
+
{ id: "kokoro", label: "Kokoro · offline", enabled: HAS_KOKORO, note: "on-server, free" },
|
|
30
|
+
{ id: "elevenlabs", label: ELEVEN_LABEL, enabled: HAS_ELEVEN },
|
|
31
|
+
{ id: "hume", label: HUME_LABEL, enabled: HAS_HUME },
|
|
32
|
+
{ id: "browser", label: "System", enabled: true },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
export function defaultProvider() {
|
|
36
|
+
// Explicit override wins, if that provider is actually available.
|
|
37
|
+
const want = process.env.TTS_DEFAULT;
|
|
38
|
+
if (want === "elevenlabs" && HAS_ELEVEN) return "elevenlabs";
|
|
39
|
+
if (want === "hume" && HAS_HUME) return "hume";
|
|
40
|
+
if (want === "kokoro" && HAS_KOKORO) return "kokoro";
|
|
41
|
+
if (want === "browser") return "browser";
|
|
42
|
+
if (HAS_KOKORO) return "kokoro"; // free + runs on any client
|
|
43
|
+
if (HAS_ELEVEN) return "elevenlabs";
|
|
44
|
+
if (HAS_HUME) return "hume";
|
|
45
|
+
return "browser";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---- Kokoro (server-side, local, free) ---- */
|
|
49
|
+
let kokoroTTS = null, kokoroInit = null;
|
|
50
|
+
async function initKokoro() {
|
|
51
|
+
if (kokoroTTS) return kokoroTTS;
|
|
52
|
+
if (!kokoroInit) kokoroInit = (async () => {
|
|
53
|
+
if (!kokoroInstalled()) console.log(" kokoro: downloading the voice model (~90 MB, first run only)…");
|
|
54
|
+
kokoroTTS = await loadKokoro();
|
|
55
|
+
return kokoroTTS;
|
|
56
|
+
})();
|
|
57
|
+
return kokoroInit;
|
|
58
|
+
}
|
|
59
|
+
export function warmKokoro() { if (HAS_KOKORO) initKokoro().then(() => console.log(" kokoro voice ready")).catch((e) => console.log(" kokoro warm failed:", e.message)); }
|
|
60
|
+
// Serialize generation: concurrent runs on the one ONNX session (prewarm +
|
|
61
|
+
// preview + greeting can overlap) risk mid-stream failures, which the client
|
|
62
|
+
// would misread as "kokoro broken" and fall back to the browser voice.
|
|
63
|
+
// The queue is bounded so one client can't starve everyone else's voice.
|
|
64
|
+
let kokoroLock = Promise.resolve(), kokoroQueued = 0;
|
|
65
|
+
function withKokoroLock(fn) {
|
|
66
|
+
if (kokoroQueued >= MAX_KOKORO_QUEUE) return Promise.reject(new TtsBusyError());
|
|
67
|
+
kokoroQueued += 1;
|
|
68
|
+
const run = kokoroLock.then(fn, fn).finally(() => { kokoroQueued -= 1; });
|
|
69
|
+
kokoroLock = run.then(() => {}, () => {});
|
|
70
|
+
return run;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function kokoro(text) {
|
|
74
|
+
const tts = await initKokoro();
|
|
75
|
+
const audio = await withKokoroLock(() => tts.generate(text, { voice: KOKORO_VOICE }));
|
|
76
|
+
return { audio: Buffer.from(await audio.toBlob().arrayBuffer()), model: "kokoro-82m", mime: "audio/wav" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Break a reply into small CLAUSE-sized chunks for streaming synthesis. CPU
|
|
80
|
+
synthesis time scales with chunk length, and the client starts playing after
|
|
81
|
+
the FIRST chunk — so a whole-sentence first chunk (~1.6s) is the delay we're
|
|
82
|
+
killing. We split at sentence AND clause boundaries (commas, semicolons,
|
|
83
|
+
dashes) and keep the first chunk deliberately short so audio starts in
|
|
84
|
+
~0.3-0.6s; later chunks are larger to keep prosody smooth and cut per-call
|
|
85
|
+
overhead. Splitting at natural pause points keeps it sounding natural. */
|
|
86
|
+
function ttsChunks(text) {
|
|
87
|
+
const parts = (text.match(/[^.!?,;:—–\n]+[.!?,;:—–\n]*/g) || [text]).map((s) => s.trim()).filter(Boolean);
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let cur = "";
|
|
90
|
+
for (const p of parts) {
|
|
91
|
+
const target = chunks.length === 0 ? 42 : 100; // first chunk short → fast first audio
|
|
92
|
+
if (cur && (cur.length + 1 + p.length) > target) { chunks.push(cur); cur = p; }
|
|
93
|
+
else cur = cur ? cur + " " + p : p;
|
|
94
|
+
}
|
|
95
|
+
if (cur) chunks.push(cur);
|
|
96
|
+
return chunks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Streaming Kokoro: synthesize clause-sized chunks in order and yield each as
|
|
100
|
+
soon as it's ready — first audio after the (short) first chunk, not the whole
|
|
101
|
+
reply. onChunk(wavBuffer, chunkText) per chunk; the client schedules them
|
|
102
|
+
back-to-back for gapless playback. */
|
|
103
|
+
export const KOKORO_STREAMS = HAS_KOKORO;
|
|
104
|
+
export async function kokoroStream(text, onChunk) {
|
|
105
|
+
const clean = String(text || "").slice(0, 800).trim();
|
|
106
|
+
if (!clean) throw new Error("empty text");
|
|
107
|
+
const tts = await initKokoro();
|
|
108
|
+
const chunks = ttsChunks(clean);
|
|
109
|
+
return withKokoroLock(async () => {
|
|
110
|
+
let n = 0;
|
|
111
|
+
for (const chunk of chunks) {
|
|
112
|
+
const audio = await tts.generate(chunk, { voice: KOKORO_VOICE });
|
|
113
|
+
await onChunk(Buffer.from(await audio.toBlob().arrayBuffer()), chunk);
|
|
114
|
+
n += 1;
|
|
115
|
+
}
|
|
116
|
+
return n;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function eleven(text) {
|
|
121
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE}?output_format=mp3_44100_128`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "xi-api-key": process.env.ELEVENLABS_API_KEY, "Content-Type": "application/json", "Accept": "audio/mpeg" },
|
|
124
|
+
body: JSON.stringify({ text, model_id: ELEVEN_MODEL, voice_settings: { stability: 0.45, similarity_boost: 0.8, style: 0.3, use_speaker_boost: true } }),
|
|
125
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) throw new Error(`ElevenLabs ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
128
|
+
return { audio: Buffer.from(await res.arrayBuffer()), model: ELEVEN_MODEL, mime: "audio/mpeg" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Streaming ElevenLabs: synthesize clause-sized chunks in order (same chunking
|
|
132
|
+
as Kokoro) so first audio lands in ~0.3-0.5s instead of waiting for the whole
|
|
133
|
+
clip. previous_text is passed so prosody stays continuous across chunks. Each
|
|
134
|
+
chunk is a complete MP3 the client decodes independently. onChunk(mp3, text). */
|
|
135
|
+
export const ELEVEN_STREAMS = HAS_ELEVEN;
|
|
136
|
+
export async function elevenStream(text, onChunk) {
|
|
137
|
+
const clean = String(text || "").slice(0, 800).trim();
|
|
138
|
+
if (!clean) throw new Error("empty text");
|
|
139
|
+
const chunks = ttsChunks(clean);
|
|
140
|
+
let prev = "", n = 0;
|
|
141
|
+
for (const chunk of chunks) {
|
|
142
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE}?output_format=mp3_44100_128`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "xi-api-key": process.env.ELEVENLABS_API_KEY, "Content-Type": "application/json", "Accept": "audio/mpeg" },
|
|
145
|
+
body: JSON.stringify({ text: chunk, model_id: ELEVEN_MODEL, previous_text: prev || undefined, voice_settings: { stability: 0.45, similarity_boost: 0.8, style: 0.3, use_speaker_boost: true } }),
|
|
146
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) { if (n === 0) throw new Error(`ElevenLabs ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`); break; }
|
|
149
|
+
await onChunk(Buffer.from(await res.arrayBuffer()), chunk);
|
|
150
|
+
prev = (prev + " " + chunk).trim().slice(-400);
|
|
151
|
+
n += 1;
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
async function hume(text) {
|
|
156
|
+
const res = await fetch("https://api.hume.ai/v0/tts/file", {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "X-Hume-Api-Key": process.env.HUME_API_KEY, "Content-Type": "application/json" },
|
|
159
|
+
body: JSON.stringify({ utterances: [{ text, voice: { id: HUME_VOICE }, description: HUME_DESC }], format: { type: "mp3" }, num_generations: 1 }),
|
|
160
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok) throw new Error(`Hume ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
163
|
+
return { audio: Buffer.from(await res.arrayBuffer()), model: "octave", mime: "audio/mpeg" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Returns { audio: Buffer, mime, chars, latencyMs, model, provider } or throws.
|
|
167
|
+
export async function synth(provider, text) {
|
|
168
|
+
const clean = String(text || "").slice(0, 800).trim();
|
|
169
|
+
if (!clean) throw new Error("empty text");
|
|
170
|
+
if (provider === "elevenlabs" && !HAS_ELEVEN) throw new Error("elevenlabs not configured");
|
|
171
|
+
if (provider === "hume" && !HAS_HUME) throw new Error("hume not configured");
|
|
172
|
+
if (provider !== "hume" && provider !== "elevenlabs" && (provider !== "kokoro" || !HAS_KOKORO)) {
|
|
173
|
+
const e = new Error(`voice provider "${provider}" is not available`); e.status = 400; throw e; // KOKORO=off means off
|
|
174
|
+
}
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
const out = provider === "hume" ? await hume(clean) : provider === "elevenlabs" ? await eleven(clean) : await kokoro(clean);
|
|
177
|
+
return { ...out, chars: clean.length, latencyMs: Date.now() - t0, provider };
|
|
178
|
+
}
|
package/lib/workflow.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The agent is a general, screen-aware, flexible assistant — no fixed script.
|
|
2
|
+
// Its "job" is whatever the user is doing on screen or asks for. The value of
|
|
3
|
+
// the architecture is that it adapts: perception reads any app, an intent read
|
|
4
|
+
// figures out what they want, a planner keeps a running understanding, and the
|
|
5
|
+
// talker helps — grounded, warm, and never looping.
|
|
6
|
+
|
|
7
|
+
export const PERSONA =
|
|
8
|
+
"You are Ava, a warm, sharp, genuinely helpful assistant who can see the user's screen. " +
|
|
9
|
+
"You help with ANYTHING — whatever app they're in, whatever they're trying to do — grounded in what's actually on their screen. " +
|
|
10
|
+
"You speak in short, natural, human turns.";
|
|
11
|
+
|
|
12
|
+
export const GUARDRAILS = [
|
|
13
|
+
"Ground everything in what's actually on the user's screen; never invent screen contents you can't see.",
|
|
14
|
+
"Meet the user where they are. Whatever app or task they're on, help with THAT — don't push your own agenda.",
|
|
15
|
+
"Never repeat the same instruction twice. If something didn't work, take a different angle.",
|
|
16
|
+
"If they're stuck or frustrated, acknowledge it honestly and offer real choices — try another way, or hand off to a human.",
|
|
17
|
+
"Be concise and concrete. One clear next thing at a time.",
|
|
18
|
+
];
|