Skip to content
Open
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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,13 @@ BOOP_USER_PHONE=
# BOOP_UPSTREAM_CHECK=true

# ---- Embeddings (optional — paid providers for memory recall) ----
# Tried in order: Voyage → OpenAI → local Transformers.js (Xenova/bge-large-
# en-v1.5, 1024-dim, free, ~440MB one-time download). Leave both blank to
# use the local fallback. All three produce 1024-dim vectors so the Convex
# index stays compatible regardless of which is active.
# Tried in order: Voyage → OpenAI → Novita → local Transformers.js (Xenova/
# bge-large-en-v1.5, 1024-dim, free, ~440MB one-time download). Leave all
# blank to use the local fallback. All produce 1024-dim vectors so the
# Convex index stays compatible regardless of which is active.
# VOYAGE_API_KEY=
# OPENAI_API_KEY=
# NOVITA_API_KEY=

# ---- Server ----
PORT=3456
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Format:

---

## Unreleased — Novita embeddings provider

- Added: `NOVITA_API_KEY` as an embeddings provider option, tried after Voyage/OpenAI and before the local fallback. Uses `baai/bge-m3` via Novita's OpenAI-compatible `/openai/v1/embeddings` endpoint, which also produces 1024-dim vectors compatible with the existing Convex index.

## Unreleased — Optional local Apple data

- Added: optional Mac-only, read-only local Apple data connectors for iMessage/SMS history, Apple Notes, and Apple Reminders. The integration is off by default and requires both the master Apple data toggle and a per-source connect action before tools are exposed.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Built on:
- **Dispatcher + workers** pattern: a lean interaction agent decides what to do, spawns focused sub-agents that actually do the work.
- **Pure dispatcher** — the interaction agent has only memory + spawn + automation + draft tools. Web access, files, and integrations are explicitly denied to it; sub-agents get `WebSearch` / `WebFetch` / the integrations.
- **Tiered memory** (short / long / permanent) with post-turn extraction, decay, and cleaning.
- **Vector search** for recall with a local BGE-large fallback, or optional Voyage/OpenAI embeddings.
- **Vector search** for recall with a local BGE-large fallback, or optional Voyage/OpenAI/Novita embeddings.
- **Memory consolidation** — a daily 3-phase adversarial pipeline (proposer → adversary → judge) that merges duplicates, resolves contradictions, and prunes noise. Uses the configured runtime, with provider-specific model defaults. Runs every 24h by default, also triggerable manually via `POST /consolidate`.
- **Automations** — the agent can schedule recurring work from a text ("every morning at 8 summarize my calendar") and push results back to iMessage.
- **Draft-and-send** — any external action stages a draft first; the agent only commits when the user confirms.
Expand Down Expand Up @@ -436,7 +436,7 @@ Everything lives in `.env.local` (auto-created by `npm run setup`). See `.env.ex
| `BOOP_UPSTREAM_CHECK` | no | Set to `false` to disable the new-version banner on `npm run dev`. Default: on. |
| `PORT` | no | Default `3456`. |
| `PUBLIC_URL` | no | Base URL used in the Sendblue webhook. Composio handles its own OAuth callbacks on `platform.composio.dev`, so this is just for inbound iMessage. |
| `VOYAGE_API_KEY` **or** `OPENAI_API_KEY` | optional | Unlocks vector recall. Falls back to substring. |
| `VOYAGE_API_KEY`, `OPENAI_API_KEY`, **or** `NOVITA_API_KEY` | optional | Unlocks vector recall. Falls back to substring. |
| `COMPOSIO_API_KEY` | optional | Enables integrations. Without it, plain chat + memory + automations still work. Get one at [app.composio.dev/developers](https://app.composio.dev/developers?utm_source=chris&utm_medium=youtube&utm_campaign=collab). |
| `COMPOSIO_USER_ID` | optional | Stable user id Composio keys connections under. Defaults to `boop-default`. |
| `ANTHROPIC_API_KEY` | optional | Bypass the Claude Code subscription for the Claude runtime. |
Expand Down Expand Up @@ -593,7 +593,7 @@ boop-agent/
│ ├── heartbeat.ts # Stale-agent sweep
│ ├── consolidation.ts # 3-phase adversarial pipeline (proposer → adversary → judge)
│ ├── usage.ts # aggregateUsageFromResult helper (shared cost aggregation)
│ ├── embeddings.ts # Voyage / OpenAI wrapper
│ ├── embeddings.ts # Voyage / OpenAI / Novita wrapper
│ ├── composio.ts # Composio SDK wrapper (session + toolkit scoping)
│ ├── composio-routes.ts # /composio/* HTTP routes for the Debug UI
│ ├── browser-routes.ts # /browser/* HTTP routes for Local browser use
Expand Down
3 changes: 2 additions & 1 deletion debug/src/components/EmbeddingBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useSocket } from "../lib/useSocket.js";

interface Status {
provider: "voyage" | "openai" | "local";
provider: "voyage" | "openai" | "novita" | "local";
total: number;
withEmbedding: number;
withoutEmbedding: number;
Expand All @@ -13,6 +13,7 @@ interface Status {
const PROVIDER_LABEL: Record<Status["provider"], string> = {
voyage: "Voyage (paid)",
openai: "OpenAI (paid)",
novita: "Novita (paid)",
local: "local (free, BGE-large)",
};

Expand Down
33 changes: 28 additions & 5 deletions server/embeddings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Thin embeddings wrapper. Tries Voyage → OpenAI → local Transformers.js
* (Xenova/bge-large-en-v1.5). All three produce 1024-dim vectors so the
* Convex vector index stays compatible regardless of which provider runs.
* Thin embeddings wrapper. Tries Voyage → OpenAI → Novita → local
* Transformers.js (Xenova/bge-large-en-v1.5). All produce 1024-dim vectors
* so the Convex vector index stays compatible regardless of which provider
* runs.
*
* Local fallback ensures `recall()` always works — no API key required.
* First local call downloads ~1.3GB and caches under Boop's local data folder.
Expand All @@ -14,6 +15,7 @@ import { fileURLToPath } from "node:url";

const VOYAGE_MODEL = "voyage-3";
const OPENAI_MODEL = "text-embedding-3-large";
const NOVITA_MODEL = "baai/bge-m3";
const LOCAL_MODEL = "Xenova/bge-large-en-v1.5";
const DIMENSIONS = 1024;
const LOCAL_CACHE_DIR = resolve(
Expand All @@ -28,11 +30,12 @@ const LOCAL_CACHE_DIR = resolve(
let extractor: FeatureExtractionPipeline | null = null;
let loading: Promise<FeatureExtractionPipeline> | null = null;

export type EmbeddingProvider = "voyage" | "openai" | "local";
export type EmbeddingProvider = "voyage" | "openai" | "novita" | "local";

export function activeProvider(): EmbeddingProvider {
if (process.env.VOYAGE_API_KEY) return "voyage";
if (process.env.OPENAI_API_KEY) return "openai";
if (process.env.NOVITA_API_KEY) return "novita";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Local selection retains Novita

When a user with an existing NOVITA_API_KEY reruns setup and selects Local, setup preserves that key and this branch continues selecting Novita, causing unexpected paid embedding requests instead of local inference.

return "local";
}

Expand Down Expand Up @@ -78,6 +81,23 @@ async function embedOpenAI(text: string): Promise<number[]> {
return json.data[0].embedding;
}

async function embedNovita(text: string): Promise<number[]> {
const res = await fetch("https://api.novita.ai/openai/v1/embeddings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.NOVITA_API_KEY}`,
},
body: JSON.stringify({
model: NOVITA_MODEL,
input: text,
}),
});
if (!res.ok) throw new Error(`novita embeddings ${res.status}: ${await res.text()}`);
const json = (await res.json()) as { data: { embedding: number[] }[] };
return json.data[0].embedding;
}

async function getLocalExtractor(): Promise<FeatureExtractionPipeline> {
if (extractor) return extractor;
if (loading) return loading;
Expand Down Expand Up @@ -125,7 +145,9 @@ async function embedLocal(text: string): Promise<number[]> {
// recall() doesn't pay the ~5–15s model load. Safe to call at server
// startup — failures are logged, not thrown.
export function preloadLocalModel(): void {
if (process.env.VOYAGE_API_KEY || process.env.OPENAI_API_KEY) return;
if (process.env.VOYAGE_API_KEY || process.env.OPENAI_API_KEY || process.env.NOVITA_API_KEY) {
return;
}
getLocalExtractor().catch((err) => {
console.warn("[embeddings] local model preload failed:", err);
});
Expand All @@ -135,6 +157,7 @@ export async function embed(text: string): Promise<number[] | null> {
try {
if (process.env.VOYAGE_API_KEY) return await embedVoyage(text);
if (process.env.OPENAI_API_KEY) return await embedOpenAI(text);
if (process.env.NOVITA_API_KEY) return await embedNovita(text);
return await embedLocal(text);
} catch (err) {
console.warn("[embeddings] failed:", err);
Expand Down