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.
Files changed (104) hide show
  1. package/.env.example +63 -0
  2. package/CLA.md +43 -0
  3. package/COMMERCIAL-LICENSE.md +43 -0
  4. package/LICENSE +73 -0
  5. package/README.md +240 -2
  6. package/THIRD_PARTY_NOTICES.md +38 -0
  7. package/TRADEMARKS.md +31 -0
  8. package/bin/ava-engine.mjs +12 -0
  9. package/lib/asr.mjs +147 -0
  10. package/lib/cli/apply.mjs +58 -0
  11. package/lib/cli/config.mjs +95 -0
  12. package/lib/cli/main.mjs +187 -0
  13. package/lib/cli/setup-server.mjs +195 -0
  14. package/lib/cli/setup.mjs +233 -0
  15. package/lib/fetch-models.mjs +104 -0
  16. package/lib/guard.mjs +92 -0
  17. package/lib/kokoro-model.mjs +51 -0
  18. package/lib/models.mjs +47 -0
  19. package/lib/openrouter.mjs +153 -0
  20. package/lib/ours.mjs +766 -0
  21. package/lib/platforms.mjs +201 -0
  22. package/lib/state.mjs +150 -0
  23. package/lib/stt.mjs +22 -0
  24. package/lib/tts.mjs +178 -0
  25. package/lib/workflow.mjs +18 -0
  26. package/package.json +80 -4
  27. package/public/app.js +1708 -0
  28. package/public/demo.js +420 -0
  29. package/public/favicon.svg +1 -0
  30. package/public/fonts/OFL.txt +96 -0
  31. package/public/fonts/inter-tight-var.woff2 +0 -0
  32. package/public/fonts/inter-var.woff2 +0 -0
  33. package/public/fonts/plex-mono-400.woff2 +0 -0
  34. package/public/fonts/plex-mono-500.woff2 +0 -0
  35. package/public/img/cta-1600.avif +0 -0
  36. package/public/img/cta-1600.webp +0 -0
  37. package/public/img/cta-2400.avif +0 -0
  38. package/public/img/cta-2400.webp +0 -0
  39. package/public/img/cta-960.avif +0 -0
  40. package/public/img/cta-960.webp +0 -0
  41. package/public/img/flows-1600.avif +0 -0
  42. package/public/img/flows-1600.webp +0 -0
  43. package/public/img/flows-2400.avif +0 -0
  44. package/public/img/flows-2400.webp +0 -0
  45. package/public/img/flows-960.avif +0 -0
  46. package/public/img/flows-960.webp +0 -0
  47. package/public/img/hero-1600.avif +0 -0
  48. package/public/img/hero-1600.webp +0 -0
  49. package/public/img/hero-2400.avif +0 -0
  50. package/public/img/hero-2400.webp +0 -0
  51. package/public/img/hero-960.avif +0 -0
  52. package/public/img/hero-960.webp +0 -0
  53. package/public/img/p-csm-480.avif +0 -0
  54. package/public/img/p-csm-480.webp +0 -0
  55. package/public/img/p-csm-800.avif +0 -0
  56. package/public/img/p-csm-800.webp +0 -0
  57. package/public/img/p-docs-480.avif +0 -0
  58. package/public/img/p-docs-480.webp +0 -0
  59. package/public/img/p-docs-800.avif +0 -0
  60. package/public/img/p-docs-800.webp +0 -0
  61. package/public/img/p-quiet-480.avif +0 -0
  62. package/public/img/p-quiet-480.webp +0 -0
  63. package/public/img/p-quiet-800.avif +0 -0
  64. package/public/img/p-quiet-800.webp +0 -0
  65. package/public/img/p-tickets-480.avif +0 -0
  66. package/public/img/p-tickets-480.webp +0 -0
  67. package/public/img/p-tickets-800.avif +0 -0
  68. package/public/img/p-tickets-800.webp +0 -0
  69. package/public/img/p-trial-480.avif +0 -0
  70. package/public/img/p-trial-480.webp +0 -0
  71. package/public/img/p-trial-800.avif +0 -0
  72. package/public/img/p-trial-800.webp +0 -0
  73. package/public/img/sees-1600.avif +0 -0
  74. package/public/img/sees-1600.webp +0 -0
  75. package/public/img/sees-2400.avif +0 -0
  76. package/public/img/sees-2400.webp +0 -0
  77. package/public/img/sees-960.avif +0 -0
  78. package/public/img/sees-960.webp +0 -0
  79. package/public/img/speaks-1600.avif +0 -0
  80. package/public/img/speaks-1600.webp +0 -0
  81. package/public/img/speaks-2400.avif +0 -0
  82. package/public/img/speaks-2400.webp +0 -0
  83. package/public/img/speaks-960.avif +0 -0
  84. package/public/img/speaks-960.webp +0 -0
  85. package/public/img/voice-1600.avif +0 -0
  86. package/public/img/voice-1600.webp +0 -0
  87. package/public/img/voice-2400.avif +0 -0
  88. package/public/img/voice-2400.webp +0 -0
  89. package/public/img/voice-960.avif +0 -0
  90. package/public/img/voice-960.webp +0 -0
  91. package/public/index.html +440 -0
  92. package/public/landing.html +975 -0
  93. package/public/pcm-worklet.js +11 -0
  94. package/public/perceptionWorker.js +103 -0
  95. package/public/rtc.js +60 -0
  96. package/public/setup.css +94 -0
  97. package/public/setup.html +129 -0
  98. package/public/setup.js +264 -0
  99. package/public/specialist.html +92 -0
  100. package/public/specialist.js +132 -0
  101. package/public/style.css +836 -0
  102. package/public/vendor/lenis.min.js +3 -0
  103. package/scripts/fetch-models.sh +15 -0
  104. package/server.mjs +502 -0
@@ -0,0 +1,51 @@
1
+ // The offline Kokoro voice model: where it's cached, whether it's there, and
2
+ // loading it with download progress. Shared by the server and the setup wizard.
3
+ //
4
+ // It's cached under the models folder (~/.ava-engine/models/kokoro for the CLI),
5
+ // not inside node_modules, so it survives `npx` updates instead of downloading
6
+ // again after every new version.
7
+
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ export const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
13
+ export const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
14
+ export const KOKORO_SIZE = "~90 MB";
15
+
16
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ export function kokoroCacheDir() {
18
+ return process.env.KOKORO_CACHE_DIR || join(process.env.AVA_MODELS_DIR || join(ROOT, "models"), "kokoro");
19
+ }
20
+
21
+ /** True once the model's weights are on disk (no network needed to start). */
22
+ export function kokoroInstalled(dir = kokoroCacheDir()) {
23
+ const onnx = join(dir, ...KOKORO_MODEL.split("/"), "onnx");
24
+ try { return existsSync(onnx) && readdirSync(onnx).some((f) => f.endsWith(".onnx")); } catch { return false; }
25
+ }
26
+
27
+ /**
28
+ * Load Kokoro, downloading it first if needed.
29
+ * onProgress({ loaded, total, pct }) reports the overall download.
30
+ */
31
+ export async function loadKokoro({ onProgress } = {}) {
32
+ const { env } = await import("@huggingface/transformers");
33
+ env.cacheDir = kokoroCacheDir();
34
+ const { KokoroTTS } = await import("kokoro-js");
35
+ const files = new Map(); // file → { loaded, total }
36
+ // Count only the large files (the weights). The small config/tokenizer files
37
+ // finish first and would make the bar jump to 100% and back.
38
+ const progress_callback = (e) => {
39
+ if (!onProgress || e.status !== "progress" || !(e.total > 1_000_000)) return;
40
+ files.set(e.file, { loaded: e.loaded, total: e.total });
41
+ let loaded = 0, total = 0;
42
+ for (const f of files.values()) { loaded += f.loaded; total += f.total; }
43
+ onProgress({ loaded, total, pct: Math.min(100, Math.floor((loaded / total) * 100)) });
44
+ };
45
+ // transformers.js warns about a missing content-length on every download; it's
46
+ // harmless and would break the setup wizard's progress bar, so hide just that line.
47
+ const warn = console.warn;
48
+ console.warn = (...args) => { if (!String(args[0]).includes("content-length")) warn(...args); };
49
+ try { return await KokoroTTS.from_pretrained(KOKORO_MODEL, { dtype: KOKORO_DTYPE, device: "cpu", progress_callback }); }
50
+ finally { console.warn = warn; }
51
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Central model config. Override any of these via env (see .env.example).
2
+ // Slugs are OpenRouter model ids. Change freely — the UI shows whatever is set.
3
+
4
+ export const MODELS = {
5
+ talker: process.env.MODEL_TALKER || "meta-llama/llama-3.3-70b-instruct", // the SPOKEN reply — latency-critical; on Groq ~0.2s TTFT (vs ~0.7s on gemini-flash-lite)
6
+ perception: process.env.MODEL_PERCEPTION || "google/gemini-2.5-flash",
7
+ planner: process.env.MODEL_PLANNER || "anthropic/claude-sonnet-4.5",
8
+ intent: process.env.MODEL_INTENT || "google/gemini-2.5-flash-lite",
9
+ };
10
+
11
+ // Per-role provider routing (OpenRouter). Only the talker is latency-critical —
12
+ // it's the reply the user actually hears — so we prefer Groq's very low
13
+ // time-to-first-token. allow_fallbacks keeps replies working (just slower) if
14
+ // Groq is briefly unavailable. Set TALKER_PROVIDER=auto to let OpenRouter pick.
15
+ export const PROVIDER = {
16
+ talker: process.env.TALKER_PROVIDER === "auto" ? undefined
17
+ : { order: [process.env.TALKER_PROVIDER || "groq"], allow_fallbacks: true },
18
+ // Perception (vision) is on the turn's hot path when the screen changed, so it
19
+ // wants a fast provider too. Only pinned when PERCEPTION_PROVIDER is set (the
20
+ // default vision model may not be served by that provider).
21
+ perception: process.env.PERCEPTION_PROVIDER && process.env.PERCEPTION_PROVIDER !== "auto"
22
+ ? { order: [process.env.PERCEPTION_PROVIDER], allow_fallbacks: true } : undefined,
23
+ };
24
+
25
+ // Fallback price table ($ per 1M tokens) used only if OpenRouter doesn't
26
+ // return a usage.cost for a call. Approximate; edit to taste. [in, out]
27
+ export const PRICES = {
28
+ "google/gemini-2.5-flash": [0.30, 2.50],
29
+ "google/gemini-2.5-flash-lite": [0.10, 0.40],
30
+ "anthropic/claude-sonnet-4.5": [3.00, 15.00],
31
+ "google/gemini-2.5-pro": [1.25, 10.00],
32
+ "openai/gpt-4o-mini": [0.15, 0.60],
33
+ };
34
+
35
+ export function estimateCost(model, promptTokens, completionTokens) {
36
+ const p = PRICES[model];
37
+ if (!p) return 0;
38
+ return (promptTokens / 1e6) * p[0] + (completionTokens / 1e6) * p[1];
39
+ }
40
+
41
+ // Friendly display label per role.
42
+ export const ROLE_LABEL = {
43
+ talker: "Talker",
44
+ perception: "Perception",
45
+ planner: "Planner",
46
+ intent: "Intent",
47
+ };
@@ -0,0 +1,153 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { estimateCost } from "./models.mjs";
3
+
4
+ // The HTTP request a model call belongs to. When that client disconnects mid-turn,
5
+ // its in-flight calls are aborted instead of spending tokens nobody will read.
6
+ export const requestScope = new AsyncLocalStorage();
7
+ function callSignal(ms) {
8
+ const client = requestScope.getStore()?.signal;
9
+ return client ? AbortSignal.any([AbortSignal.timeout(ms), client]) : AbortSignal.timeout(ms);
10
+ }
11
+
12
+ const OR_URL = "https://openrouter.ai/api/v1/chat/completions";
13
+ export const MOCK = !process.env.OPENROUTER_API_KEY;
14
+ // Optional app attribution on openrouter.ai (https://openrouter.ai/docs/app-attribution).
15
+ const ATTRIBUTION = {
16
+ ...(process.env.OPENROUTER_REFERER ? { "HTTP-Referer": process.env.OPENROUTER_REFERER } : {}),
17
+ "X-Title": process.env.OPENROUTER_TITLE || "Ava",
18
+ };
19
+ // Hard caps so one hung upstream call can never freeze the observe loop or a turn.
20
+ const CHAT_TIMEOUT_MS = 30_000;
21
+ const STREAM_TIMEOUT_MS = 60_000;
22
+
23
+ // Rough token estimate for mock mode + for context-size display.
24
+ export function estTokens(x) {
25
+ if (x == null) return 0;
26
+ if (typeof x === "string") return Math.ceil(x.length / 4);
27
+ // OpenAI-style content array: text parts by length, image parts flat ~258/tile.
28
+ if (Array.isArray(x)) {
29
+ let t = 0;
30
+ for (const p of x) {
31
+ if (p?.type === "text") t += Math.ceil((p.text || "").length / 4);
32
+ else if (p?.type === "image_url") t += 900; // ~one downscaled screen frame
33
+ }
34
+ return t;
35
+ }
36
+ return Math.ceil(JSON.stringify(x).length / 4);
37
+ }
38
+
39
+ function messagesTokens(messages) {
40
+ return messages.reduce((s, m) => s + estTokens(m.content) + 4, 0);
41
+ }
42
+
43
+ /**
44
+ * One chat completion. Returns:
45
+ * { text, model, role, promptTokens, completionTokens, cost, latencyMs, cached }
46
+ * In MOCK mode returns a deterministic canned reply from `mock(messages)`.
47
+ */
48
+ export async function chat({ model, role, messages, maxTokens = 350, temperature = 0.5, mock, provider }) {
49
+ const t0 = Date.now();
50
+
51
+ if (MOCK) {
52
+ const text = (mock ? mock(messages) : "[mock reply]");
53
+ const promptTokens = messagesTokens(messages);
54
+ const completionTokens = estTokens(text);
55
+ // Simulate realistic per-role latency so the UI feels real.
56
+ const base = role === "planner" ? 3200 : role === "perception" ? 520 : 620;
57
+ const latencyMs = base + Math.round((Math.random() - 0.5) * 220);
58
+ await new Promise((r) => setTimeout(r, Math.min(latencyMs, 900))); // don't actually wait the full planner time
59
+ return {
60
+ text, model, role, promptTokens, completionTokens,
61
+ cost: estimateCost(model, promptTokens, completionTokens),
62
+ latencyMs, mocked: true,
63
+ };
64
+ }
65
+
66
+ const body = {
67
+ model,
68
+ messages,
69
+ max_tokens: maxTokens,
70
+ temperature,
71
+ usage: { include: true },
72
+ ...(provider ? { provider } : {}),
73
+ };
74
+ const res = await fetch(OR_URL, {
75
+ method: "POST",
76
+ headers: {
77
+ "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
78
+ "Content-Type": "application/json",
79
+ ...ATTRIBUTION,
80
+ },
81
+ body: JSON.stringify(body),
82
+ signal: callSignal(CHAT_TIMEOUT_MS),
83
+ });
84
+
85
+ const latencyMs = Date.now() - t0;
86
+ if (!res.ok) {
87
+ const detail = await res.text().catch(() => "");
88
+ const err = new Error(`OpenRouter ${res.status}: ${detail.slice(0, 300)}`);
89
+ err.status = res.status;
90
+ throw err;
91
+ }
92
+ const data = await res.json();
93
+ const choice = data.choices?.[0];
94
+ const text = choice?.message?.content ?? "";
95
+ const u = data.usage || {};
96
+ const promptTokens = u.prompt_tokens ?? 0;
97
+ const completionTokens = u.completion_tokens ?? 0;
98
+ const cost = typeof u.cost === "number" ? u.cost : estimateCost(model, promptTokens, completionTokens);
99
+ return { text, model, role, promptTokens, completionTokens, cost, latencyMs, mocked: false };
100
+ }
101
+
102
+ /**
103
+ * Streaming chat completion. Calls onDelta(textChunk) as tokens arrive.
104
+ * Returns the same usage shape as chat() once complete.
105
+ */
106
+ export async function chatStream({ model, role, messages, maxTokens = 220, temperature = 0.5, mock, onDelta, provider }) {
107
+ const t0 = Date.now();
108
+
109
+ if (MOCK) {
110
+ const text = mock ? mock(messages) : "[mock reply]";
111
+ for (const w of text.match(/\S+\s*/g) || [text]) { onDelta(w); await new Promise((r) => setTimeout(r, 28)); }
112
+ const promptTokens = messagesTokens(messages), completionTokens = estTokens(text);
113
+ return { text, model, role, promptTokens, completionTokens, cost: estimateCost(model, promptTokens, completionTokens), latencyMs: Date.now() - t0, mocked: true };
114
+ }
115
+
116
+ const res = await fetch(OR_URL, {
117
+ method: "POST",
118
+ headers: {
119
+ "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
120
+ "Content-Type": "application/json",
121
+ ...ATTRIBUTION,
122
+ },
123
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens, temperature, stream: true, usage: { include: true }, ...(provider ? { provider } : {}) }),
124
+ signal: callSignal(STREAM_TIMEOUT_MS),
125
+ });
126
+ if (!res.ok) {
127
+ const detail = await res.text().catch(() => "");
128
+ throw new Error(`OpenRouter ${res.status}: ${detail.slice(0, 300)}`);
129
+ }
130
+ const reader = res.body.getReader();
131
+ const dec = new TextDecoder();
132
+ let buf = "", text = "", usage = null;
133
+ for (;;) {
134
+ const { value, done } = await reader.read();
135
+ if (done) break;
136
+ buf += dec.decode(value, { stream: true });
137
+ let idx;
138
+ while ((idx = buf.indexOf("\n")) >= 0) {
139
+ const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1);
140
+ if (!line.startsWith("data:")) continue;
141
+ const d = line.slice(5).trim();
142
+ if (d === "[DONE]") continue;
143
+ let j; try { j = JSON.parse(d); } catch { continue; }
144
+ const delta = j.choices?.[0]?.delta?.content;
145
+ if (delta) { text += delta; onDelta(delta); }
146
+ if (j.usage) usage = j.usage;
147
+ }
148
+ }
149
+ const promptTokens = usage?.prompt_tokens ?? 0;
150
+ const completionTokens = usage?.completion_tokens ?? 0;
151
+ const cost = typeof usage?.cost === "number" ? usage.cost : estimateCost(model, promptTokens, completionTokens);
152
+ return { text, model, role, promptTokens, completionTokens, cost, latencyMs: Date.now() - t0, mocked: false };
153
+ }