diff --git a/integrations/wearable-omi-capture/README.md b/integrations/wearable-omi-capture/README.md new file mode 100644 index 000000000..6dbec4be1 --- /dev/null +++ b/integrations/wearable-omi-capture/README.md @@ -0,0 +1,404 @@ +# Omi Wearable Capture + +> **Turn your Omi pendant into a passive feed for your Open Brain.** A Supabase +> Edge Function polls Omi every few minutes and atomizes each conversation — its +> title, every action item, every event, and each ~60-second transcript chunk — +> into individually searchable, individually attributed `thoughts`. It also +> captures Omi's distilled memories as their own edit-aware stream. + +--- + +## What It Does + +Omi records your spoken conversations and returns them already structured — a +title, an overview, a category, action items, events, and the raw transcript +segments. This integration is an **adapter** on top of +[`wearable-capture-core`](../wearable-capture-core/): it pulls recent Omi +conversations on a schedule and **atomizes** each one using Omi's _own_ +structure, so there's **no per-item LLM classification cost**. The shared core +owns the write path — per-atom dedup on a salted fingerprint, provenance +metadata, embedding via OpenRouter (`openai/text-embedding-3-small`), and the +insert into `thoughts`. + +Each non-discarded conversation produces several atoms: + +| Atom | Thought type | Attribution | +| -------------------------------------- | ------------ | --------------------------------------------------- | +| title + overview | `meeting` | `machine` (generator `omi`) | +| each action item | `task` | `machine` (generator `omi`) | +| each event | `meeting` | `machine` (generator `omi`) | +| each ~60s / ~600-char transcript chunk | `meeting` | `self` / `other` / `mixed` / `unknown` (by speaker) | + +Capturing the **transcript chunks** is the point of the atomic rework: a +summary-only capture threw away everything actually said inside a long +conversation. Now each chunk is its own row, attributed to whoever spoke it. The +chunk count is soft-warned past ~80 but **never truncated**. + +Separately, the adapter captures Omi's **memories** — the distilled facts Omi +accumulates about you (`GET /v1/dev/user/memories`) — as an `omi_memory` stream. +A `manually_added` memory is attributed `self`; a device-inferred one is +`machine`. Memories are **edit-aware**: when Omi edits a memory, the next pass +re-imports it (one batch lookup, then insert-or-patch — never a per-memory table +scan). + +Because it's a poller, there's no webhook to register and nothing public to +secure — the function reaches out to Omi, not the other way around. + +--- + +## Prerequisites + +- **[Wearable Capture Core](../wearable-capture-core/) installed first.** This + adapter imports the shared engine from `../_shared/wearable-sync.ts`. Follow + that integration's README to copy `wearable-sync.ts` into + `supabase/functions/_shared/` and set `OPENROUTER_API_KEY`. (For convenience, + this folder bundles an identical copy of the engine in `_shared/` so the + function typechecks standalone — the `deno.json` import map points the deploy + path at it for local `deno check`.) +- A working Open Brain setup (Supabase project with the `thoughts` table and + pgvector). +- An Omi account with a personal developer API key (shaped `omi_dev_...`). +- An [OpenRouter](https://openrouter.ai) API key — already set if you installed + the core. +- Supabase CLI installed and logged in. +- `pg_cron` and `pg_net` available in your Supabase project (both ship enabled + on Supabase; Step 5 turns them on if needed). + +**Cost**: Omi's API is included with the device. The only marginal cost is +OpenRouter embeddings (no classification — the adapter reuses Omi's own +structure). Each conversation now yields several atoms instead of one summary, +so expect roughly **$0.05–0.20/month** for typical personal volume — still +embeddings-only. + +--- + +## Credential Tracker + +Fill these in as you go — you'll need them in Steps 2 and 5: + +| Credential | Where it comes from | Value | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------- | +| `OMI_API_KEY` | Omi app/dashboard → Developer (Step 2) | | +| `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) | (set by the core) | +| `SUPABASE_URL` | Auto-injected by Supabase | (skip) | +| `SUPABASE_SERVICE_ROLE_KEY` | Auto-injected by Supabase | (skip) | +| `WEARABLE_SELF_LABELS` _(optional)_ | Comma-separated speaker labels that are _you_ (e.g. your name), merged with the device-generic `you/me/self` | | +| `YOUR_PROJECT_REF` | Your Supabase project subdomain (Step 5) | | +| `CRON_SECRET` | Invent one in Step 5 (function key for cron) | | + +--- + +## Steps + +### Step 1 — Install the shared engine (prerequisite) + +This adapter is built on the **Wearable Capture Core** engine and won't deploy +without it. + +Follow [`wearable-capture-core`](../wearable-capture-core/) now if you haven't: + +1. Copy `wearable-sync.ts` into `supabase/functions/_shared/wearable-sync.ts`. +2. `supabase secrets set OPENROUTER_API_KEY="sk-or-v1-your-openrouter-key"`. + +✅ **Done when:** `supabase/functions/_shared/wearable-sync.ts` exists and +`OPENROUTER_API_KEY` shows in `supabase secrets list`. + +--- + +### Step 2 — Get your Omi API key + +1. Open the Omi app (or developer dashboard) and go to the Developer / API + section. +2. Create a personal API key. It's shaped like `omi_dev_abc123...`. +3. Copy it into your tracker as `OMI_API_KEY`. + +> [!WARNING] +> The Omi key is a credential. Don't paste it into code, commits, or screenshots +> — it goes into Supabase secrets only (Step 4). + +✅ **Done when:** You have a key beginning `omi_dev_`. You can sanity-check it: + +```bash +curl -H "Authorization: Bearer omi_dev_your_key" \ + "https://api.omi.me/v1/dev/user/conversations?include_transcript=true&limit=1&offset=0" +``` + +A working key returns a JSON **array** (possibly empty `[]`); an invalid key +returns a `401`/`403`. + +--- + +### Step 3 — Drop the function into your Supabase project + +From the root of your Supabase project: + +```bash +mkdir -p supabase/functions/wearable-omi-capture +``` + +Create `supabase/functions/wearable-omi-capture/index.ts` with the contents of +[`index.ts`](./index.ts) from this folder. The only external dependency is the +shared core, imported at the deploy path: + +```typescript +import { + atomFingerprint, + type Attribution, + fetchWithRetry, + runWearableSync, + type SyncResult, + type WearableAdapter, + type WearableAtom, +} from "../_shared/wearable-sync.ts"; +``` + +The adapter defines `listSince` (pages Omi newest-first and filters to the time +window, since Omi has no `since` parameter), `recordId` (Omi's conversation id), +and `recordToAtoms` (title + overview, one per action item, one per event, and +one per transcript chunk — skipping `discarded` conversations). `Deno.serve` +runs the conversation pass via the core and then the memory pass, and returns a +combined JSON result. + +It accepts optional query parameters for manual testing: `?dry_run=1` (compute, +write nothing), `?since_hours=N` (override the 12-hour window), `?no_memories=1` +(skip the memory stream). + +✅ **Done when:** The file exists at +`supabase/functions/wearable-omi-capture/index.ts` and +`deno check index.ts _shared/wearable-sync.ts` is clean (the +`_shared/wearable-sync.ts` from Step 1 must be present for the import to +resolve). + +--- + +### Step 4 — Set the Omi secret + +```bash +supabase secrets set OMI_API_KEY="omi_dev_your_key" +# optional: label your own speech so transcript chunks attribute to "self" +supabase secrets set WEARABLE_SELF_LABELS="Your Name,Nickname" +``` + +`OPENROUTER_API_KEY` is already set from the core (Step 1). `SUPABASE_URL` and +`SUPABASE_SERVICE_ROLE_KEY` are injected automatically by the Supabase runtime, +so you don't set those yourself. + +✅ **Done when:** `supabase secrets list` shows `OMI_API_KEY` and +`OPENROUTER_API_KEY`. + +--- + +### Step 5 — Deploy and schedule it (every 5 minutes) + +**5a. Deploy the function** + +```bash +supabase functions deploy wearable-omi-capture +``` + +Your function URL will look like: + +``` +https://YOUR_PROJECT_REF.supabase.co/functions/v1/wearable-omi-capture +``` + +(where `YOUR_PROJECT_REF` is the subdomain of your Supabase project). Keep it +handy. + +**5b. Schedule it with pg_cron + pg_net** + +Run this SQL in the Supabase SQL editor. It runs the function every 5 minutes. +Replace `YOUR_PROJECT_REF` with your project ref and `YOUR_CRON_SECRET` with a +value you invent (any random string — it just needs to match a +function-invocation key your project accepts; use your +`SUPABASE_SERVICE_ROLE_KEY` or an anon key if your function requires JWT, or any +bearer if deployed `--no-verify-jwt`). + +```sql +-- Enable the schedulers (no-ops if already enabled). +create extension if not exists pg_cron; +create extension if not exists pg_net; + +-- Poll Omi every 5 minutes. +select cron.schedule( + 'wearable-omi-capture-5m', + '*/5 * * * *', + $$ + select net.http_post( + url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/wearable-omi-capture', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer YOUR_CRON_SECRET' + ), + body := '{}'::jsonb + ); + $$ +); +``` + +The function's 12-hour rolling window means a missed run (or a paused schedule) +self-heals on the next pass — overlapping windows are safe because the core +dedups per atom on a salted fingerprint. + +To change or remove the schedule later: + +```sql +select cron.unschedule('wearable-omi-capture-5m'); +``` + +> [!IMPORTANT] +> `OPENROUTER_API_KEY` must already be set (from the core) or atoms insert with +> a `null` embedding. That's recoverable — a later embedding backfill fills them +> — but set the key now to embed at capture time. + +✅ **Done when:** +`select * from cron.job where jobname = 'wearable-omi-capture-5m';` shows the +schedule, and within ~5 minutes new Omi atoms start appearing in `thoughts`. + +--- + +### Step 6 — Verify capture + +After a cron run (or invoke the function once manually), confirm rows landed: + +```sql +-- conversation atoms +select count(*) from thoughts where metadata->>'wearable_source' = 'omi'; +-- memory stream +select count(*) from thoughts where metadata->>'source' = 'omi_memory'; +``` + +For a closer look at what was captured, including atom kind and attribution: + +```sql +select + metadata->>'atom_kind' as kind, + metadata->>'attribution' as attribution, + left(content, 80) as preview, + created_at +from thoughts +where metadata->>'wearable_source' = 'omi' +order by created_at desc +limit 15; +``` + +You should see a mix of `overview` / `action_item` / `event` / +`transcript_chunk` kinds, with the transcript chunks carrying `self` / `other` / +`mixed` attribution. + +✅ **Done when:** the counts are non-zero and growing across cron runs, with +multiple atom kinds tagged `metadata.wearable_source = 'omi'` and an +`omi_memory` stream. + +--- + +## Expected Outcome + +Every 5 minutes the function pulls Omi conversations from roughly the last 12 +hours, atomizes each, skips atoms it has already captured (deduped per atom on a +salted fingerprint), and writes the new ones. A capture pass returns a combined +result, e.g.: + +```json +{ + "conversations": { + "source": "omi", + "pulled": 6, + "recordsImported": 4, + "atomsInserted": 37, + "atomsSkipped": 12, + "failed": 0, + "attribution": { "machine": 9, "self": 14, "mixed": 11, "unknown": 3 }, + "dryRun": false + }, + "memories": { + "pulled": 49, + "inserted": 3, + "updated": 1, + "skipped": 45, + "failed": 0 + }, + "dryRun": false +} +``` + +Conversations Omi flagged as `discarded` are silently ignored. Re-runs and +overlapping windows are safe and idempotent — there's no local state file, so a +missed run self-heals on the next pass. + +--- + +## Troubleshooting + +**`OMI_API_KEY is required`** The secret isn't set on the deployed function. Run +`supabase secrets set OMI_API_KEY="omi_dev_your_key"` and redeploy. + +**`Omi conversations 401` / `403` in the logs** The Omi key is wrong, expired, +or lacks developer access. Re-check it with the `curl` from Step 2, then reset +the secret. Inspect logs with `supabase functions logs wearable-omi-capture`. + +**Nothing is captured, but the function returns `200`** Check +`conversations.pulled` / `conversations.atomsInserted` in the JSON result. If +`pulled` is `0`, no Omi conversations started inside the 12-hour window — talk +to your Omi or widen the window with `?since_hours=48`. If `pulled` is non-zero +but `atomsInserted` is `0`, those atoms were already captured (expected on every +run after the first) or the conversations were all `discarded`. + +**Transcript chunks aren't attributed to me (`self`)** Omi labels the wearer +with whatever speaker name it resolved. Set `WEARABLE_SELF_LABELS` to the +label(s) Omi uses for you (comma-separated); they're merged with the +device-generic `you` / `me` / `self`. + +**Atoms insert but `embedding` is null** `OPENROUTER_API_KEY` isn't set. The +core inserts without an embedding by design (backfill-friendly); set the key +from the core's Step 2 to embed at capture time. + +**Duplicate rows for the same conversation** The core dedups per atom on +`sourceType | provider_event_id | atom_index | content`. If you see duplicates, +confirm the cron isn't pointed at an older copy of the function and that +`recordId` returns `c.id` (not a content hash). + +**Cron never fires** Confirm `pg_cron` is enabled (`select * from cron.job;`) +and that `net.http_post` rows are being created +(`select * from net._http_response order by created desc limit 5;` shows +responses). A `401` in the response body means your `Authorization` bearer in +the cron SQL doesn't match what the function expects. + +--- + +## Tool Surface Area + +This integration **registers no new MCP tools**. It is a capture-only ingestion +path: a scheduled Supabase Edge Function that polls Omi and writes rows into the +existing `thoughts` table via the shared `wearable-sync` engine. + +| Component | Type | What it does | +| ------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wearable-omi-capture` Edge Function | Supabase poller (not an MCP server) | On a cron, atomizes recent Omi conversations (title + overview, action items, events, transcript chunks) and captures Omi memories, handing them to the core for dedup + embed + insert. | +| `wearable-sync.ts` | Shared Deno module (`_shared/`) | The engine this adapter is built on — per-atom dedup, provenance, embedding (OpenRouter), insert. See [wearable-capture-core](../wearable-capture-core/). | +| `thoughts` table | Existing Open Brain primitive | No schema changes — additive rows only. | + +**External services called:** `api.omi.me/v1/dev` (list conversations + +memories) and `openrouter.ai/api/v1` (embeddings). Both are outbound HTTPS; the +function exposes no inbound webhook beyond its own Supabase URL, which the cron +calls. + +**Auditing:** Because this integration adds no MCP tools, there's no MCP tool +surface to audit for it directly. If you install it alongside MCP servers that +read from `thoughts`, audit those per the +[MCP Tool Audit & Optimization Guide](../../docs/05-tool-audit.md). + +--- + +## Related + +- [Wearable Capture Core](../wearable-capture-core/) — the shared engine this + adapter is built on (**install first**). +- [Limitless Wearable Capture](../wearable-limitless-capture/) — sibling adapter + for the Limitless Pendant. +- [Telegram Capture](../telegram-capture/) — webhook-based quick capture (push, + not poll). +- [MCP Tool Audit & Optimization Guide](../../docs/05-tool-audit.md) — + recommended reading for any integration contributor. +- [Contributing guide](../../CONTRIBUTING.md) — required reading before + submitting changes. diff --git a/integrations/wearable-omi-capture/_shared/wearable-sync.ts b/integrations/wearable-omi-capture/_shared/wearable-sync.ts new file mode 100644 index 000000000..b46fdb0dc --- /dev/null +++ b/integrations/wearable-omi-capture/_shared/wearable-sync.ts @@ -0,0 +1,356 @@ +/** + * wearable-sync — generic ATOMIC capture engine for always-on wearables. + * + * A small, reusable core that turns ANY polling wearable (Omi, Limitless, and + * future devices) into Open Brain thoughts — at the granularity of ATOMS, not + * one summary per recording. A long conversation becomes many searchable rows + * (its title, each action item, each transcript chunk, …), each carrying its own + * provenance. Each device supplies a tiny `WearableAdapter`; this engine owns + * everything the adapters share: + * + * 1. pull records since a rolling time window (the adapter makes the call), + * 2. atomize each record into one or more atoms (the adapter, using the + * device's OWN structured output — no per-item LLM cost), + * 3. skip atoms already captured (idempotent dedup on a SALTED per-atom + * content fingerprint, so re-runs and overlapping windows are safe), + * 4. tag each atom with provenance (attribution / attributed_to / generator), + * 5. embed the text (OpenRouter, OB1's standard) and insert into `thoughts`. + * + * Design rules (per OB1 CONTRIBUTING): + * - Never modifies the `thoughts` schema — additive rows only. The atom + * fingerprint lives in `metadata.content_fingerprint`, deduped with a GIN- + * indexed JSONB containment query, so the engine works on the baseline + * `thoughts` schema with no migration. (If you run a schema that adds a + * UNIQUE index, a duplicate insert is also caught and treated as a skip.) + * - No secrets in code — every credential comes from Deno.env. + * - Idempotency lives in the brain, not a local file, so re-runs and + * overlapping windows are safe and the engine self-heals after outages. + * + * Deploy this file to `supabase/functions/_shared/wearable-sync.ts`; each + * per-wearable adapter (e.g. `wearable-omi-capture`) imports it. + */ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +/** + * Who an atom is attributed to. + * - `self` — only the brain owner speaks/authored it. + * - `other` — only other named people. + * - `mixed` — the owner and at least one other named person. + * - `machine` — the device generated it (a title, a summary, an extracted item). + * - `unknown` — speech with no resolvable speaker. + * `external` is reserved for a future cross-source backfill and is never emitted here. + */ +export type Attribution = "self" | "other" | "machine" | "mixed" | "unknown"; + +/** One atom produced from a wearable record. The adapter sets the provenance + * fields using the device's own structure; the engine merges them into + * `metadata` and computes the fingerprint. `type` maps to the thought type + * (default 'meeting'). */ +export interface WearableAtom { + /** Stable position of this atom within its record (part of the fingerprint salt). */ + atomIndex: number; + /** What kind of atom this is, e.g. 'title' | 'overview' | 'action_item' | 'event' | 'transcript_chunk' | 'section' | 'memory'. */ + atomKind: string; + content: string; + type?: string; + importance?: number; + attribution: Attribution; + /** Speaker labels / names that contributed to this atom (for `attributed_to`). */ + attributedTo?: string[]; + /** The device that machine-generated this atom (e.g. 'omi'); null for human speech. */ + generator?: string | null; + /** True when the brain owner is a speaker here — lets an optional later step self-link. */ + selfPresent?: boolean; + /** The owner's role in a self/mixed atom, for optional self-linking. */ + role?: "author" | "participant" | null; + createdAt?: string; + qualityScore?: number; + /** Atom-specific extras merged into `metadata` (e.g. section_label, speakers). */ + metadata?: Record; +} + +/** The per-wearable contract. Implement these and the engine does the rest. + * `Record` is opaque to the engine — whatever the device API returns. */ +export interface WearableAdapter { + /** Stable short id for the device, e.g. "omi", "limitless". Used for dedup + provenance. */ + sourceId: string; + /** The brain `source_type` to tag thoughts with, e.g. "omi", "limitless_lifelog". */ + sourceType: string; + /** Pull records created/started at or after `sinceISO` (UTC ISO 8601). */ + listSince(sinceISO: string): Promise; + /** The device's own stable id for a record (idempotency salt — survives content edits). */ + recordId(record: Record): string; + /** Atomize a record using the device's OWN structure (no LLM call). */ + recordToAtoms(record: Record): WearableAtom[]; +} + +export interface SyncOptions { + /** Rolling lookback window in hours (default 12). A wider window self-heals longer outages. */ + sinceHours?: number; + /** Don't write — just report what would be captured. */ + dryRun?: boolean; + /** Embed atom text via OpenRouter before insert (default true; false leaves NULL embeddings + * for a later backfill). */ + embed?: boolean; + /** Optional pre-built client (tests). Defaults to a service-role client from env. */ + client?: SupabaseClient; +} + +export interface SyncResult { + source: string; + /** Records pulled from the device this pass. */ + pulled: number; + /** Records that produced at least one NEW atom. */ + recordsImported: number; + /** Atoms written (or, in a dry run, that would be written). */ + atomsInserted: number; + /** Atoms already present (deduped) or empty. */ + atomsSkipped: number; + /** Records that errored mid-pass. */ + failed: number; + /** Count of atoms by attribution, for at-a-glance provenance. */ + attribution: Record; + dryRun: boolean; +} + +export interface FetchRetryOptions { + /** Max 429 retries before giving up and returning the 429 response (default 3). */ + maxRetries?: number; + /** Per-attempt timeout in ms (default 30000). */ + timeoutMs?: number; +} + +const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; + +/** + * `fetch()` with a per-attempt timeout and Retry-After-aware, capped backoff on + * HTTP 429. Adapters use this for their device API calls so a transient rate + * limit slows a pass instead of aborting it. Non-429 responses (including other + * errors) are returned as-is for the caller to handle. + */ +export async function fetchWithRetry( + url: string | URL, + init: RequestInit = {}, + opts: FetchRetryOptions = {}, +): Promise { + const maxRetries = opts.maxRetries ?? 3; + const timeoutMs = opts.timeoutMs ?? 30000; + for (let attempt = 0;; attempt++) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const resp = await fetch(url, { ...init, signal: ctrl.signal }); + if (resp.status === 429 && attempt < maxRetries) { + const retryAfter = Number(resp.headers.get("retry-after")) || 0; + const wait = Math.min( + Math.max(retryAfter * 1000, 2000 * (attempt + 1)), + 15000, + ); + await resp.body?.cancel().catch(() => {}); // free the connection before backing off + await new Promise((r) => setTimeout(r, wait)); + continue; + } + return resp; + } finally { + clearTimeout(timer); + } + } +} + +/** + * Salted per-atom identity: `sha256(source|provider_event_id|atom_index|content)`. + * Salting with the recording id and the atom's position means two atoms with + * identical text still get distinct fingerprints, while a re-run of the same + * atom is stable — which is exactly what makes overlapping windows idempotent. + */ +export async function atomFingerprint( + source: string, + providerEventId: string, + atomIndex: number, + content: string, +): Promise { + const data = new TextEncoder().encode( + `${source}|${providerEventId}|${atomIndex}|${content}`, + ); + const digest = await crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** Embed text via OpenRouter (openai/text-embedding-3-small — OB1's default). + * Returns null if no key is set, so the engine still inserts (embedding backfilled later). */ +async function embedText(text: string): Promise { + const key = Deno.env.get("OPENROUTER_API_KEY"); + if (!key) return null; + const r = await fetchWithRetry(`${OPENROUTER_BASE}/embeddings`, { + method: "POST", + headers: { + "Authorization": `Bearer ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/text-embedding-3-small", + input: text.slice(0, 8000), + }), + }); + if (!r.ok) { + throw new Error( + `OpenRouter embeddings ${r.status}: ${(await r.text()).slice(0, 200)}`, + ); + } + const d = await r.json(); + return d?.data?.[0]?.embedding ?? null; +} + +function defaultClient(): SupabaseClient { + const url = Deno.env.get("SUPABASE_URL"); + const key = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!url || !key) { + throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required"); + } + return createClient(url, key); +} + +/** + * Run one capture pass for a wearable. Idempotent + additive: safe to call on a + * tight schedule (e.g. every 5 minutes via cron). Each record is atomized, and + * each atom is deduped on its salted fingerprint before insert. + */ +export async function runWearableSync( + adapter: WearableAdapter, + opts: SyncOptions = {}, +): Promise { + const supabase = opts.client ?? defaultClient(); + const sinceHours = opts.sinceHours ?? 12; + const sinceISO = new Date(Date.now() - sinceHours * 3600 * 1000) + .toISOString(); + const dryRun = opts.dryRun ?? false; + const doEmbed = opts.embed ?? true; + + const records = await adapter.listSince(sinceISO); + let recordsImported = 0, atomsInserted = 0, atomsSkipped = 0, failed = 0; + const attribution: Record = {}; + + for (const record of records) { + const providerEventId = adapter.recordId(record); + if (!providerEventId) continue; + try { + const atoms = adapter.recordToAtoms(record); + if (atoms.length === 0) continue; + + // ONE indexed lookup per recording for the atoms already captured for THIS + // record (the metadata GIN index serves the containment match). Batching + // here — instead of a query per atom — follows the brain's "never per-row + // filter on a JSONB key" rule. + const seen = new Set(); + if (!dryRun) { + const { data: existing, error: selErr } = await supabase + .from("thoughts") + .select("metadata") + .contains("metadata", { + wearable_source: adapter.sourceId, + provider_event_id: providerEventId, + }); + if (selErr) throw selErr; + for (const row of existing ?? []) { + const fp = (row as { metadata?: Record }).metadata + ?.content_fingerprint; + if (typeof fp === "string") seen.add(fp); + } + } + + let newAtoms = 0; + for (const atom of atoms) { + attribution[atom.attribution] = (attribution[atom.attribution] ?? 0) + + 1; + const content = atom.content?.trim(); + if (!content) { + atomsSkipped++; + continue; + } + + const fingerprint = await atomFingerprint( + adapter.sourceType, + providerEventId, + atom.atomIndex, + content, + ); + if (seen.has(fingerprint)) { + atomsSkipped++; + continue; + } + seen.add(fingerprint); + + const metadata: Record = { + ...(atom.metadata ?? {}), + source: adapter.sourceType, + wearable_source: adapter.sourceId, + provider_event_id: providerEventId, + atom_index: atom.atomIndex, + atom_kind: atom.atomKind, + attribution: atom.attribution, + generator: atom.generator ?? null, + content_fingerprint: fingerprint, + captured_via: "wearable-atomic", + type: atom.type ?? "meeting", + importance: atom.importance ?? 3, + }; + if (atom.attributedTo?.length) { + metadata.attributed_to = atom.attributedTo; + } + if (atom.selfPresent) { + metadata.self_present = true; + if (atom.role) metadata.role = atom.role; + } + if (typeof atom.qualityScore === "number") { + metadata.quality_score = atom.qualityScore; + } + + if (dryRun) { + atomsInserted++; + newAtoms++; + continue; + } + + const row: Record = { content, metadata }; + if (atom.createdAt) row.created_at = atom.createdAt; + if (doEmbed) { + const emb = await embedText(content); + if (emb) row.embedding = emb; + } + const { error: insErr } = await supabase.from("thoughts").insert(row); + if (insErr) { + // A unique violation only happens if you run a schema with a UNIQUE + // index on the fingerprint — it means a concurrent/overlapping pass + // beat us to this atom. Treat as a skip, not a failure. + if (/duplicate key|23505/i.test(insErr.message ?? "")) { + atomsSkipped++; + continue; + } + throw insErr; + } + atomsInserted++; + newAtoms++; + } + if (newAtoms > 0) recordsImported++; + } catch (e) { + failed++; + console.error( + `[wearable-sync:${adapter.sourceId}] ${providerEventId}: ${ + (e as Error).message + }`, + ); + } + } + + return { + source: adapter.sourceId, + pulled: records.length, + recordsImported, + atomsInserted, + atomsSkipped, + failed, + attribution, + dryRun, + }; +} diff --git a/integrations/wearable-omi-capture/deno.json b/integrations/wearable-omi-capture/deno.json new file mode 100644 index 000000000..bfa87cb1a --- /dev/null +++ b/integrations/wearable-omi-capture/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10", + "../_shared/wearable-sync.ts": "./_shared/wearable-sync.ts" + }, + "tasks": { + "check": "deno check index.ts _shared/wearable-sync.ts", + "fmt": "deno fmt", + "lint": "deno lint" + } +} diff --git a/integrations/wearable-omi-capture/index.ts b/integrations/wearable-omi-capture/index.ts new file mode 100644 index 000000000..b4cb96275 --- /dev/null +++ b/integrations/wearable-omi-capture/index.ts @@ -0,0 +1,635 @@ +/** + * wearable-omi-capture — Omi pendant adapter for the wearable-capture-core engine. + * + * Omi (https://omi.me) is an always-on wearable that records spoken + * conversations and returns them already structured: a title, an overview, a + * category, a list of action items and events, plus the raw transcript segments. + * This adapter atomizes that device-native structure into Open Brain thoughts — + * NO LLM call of its own: + * + * - one `meeting` atom from title + overview (machine-generated), + * - one `task` atom per action item (machine-generated), + * - one `meeting` atom per event (machine-generated), + * - one `meeting` atom per ~60s / ~600-char transcript CHUNK, attributed to its + * speakers (self / other / mixed / unknown) — the detail a summary loses. + * + * It also captures Omi's distilled **memories** (`GET /v1/dev/user/memories`) as a + * separate `omi_memory` stream with edit-aware dedup (re-import when Omi edits a + * memory). The shared core (`_shared/wearable-sync.ts`) owns the conversation + * write path: per-atom dedup on a salted fingerprint, provenance metadata, + * embedding via OpenRouter, and the insert into `thoughts`. + * + * Deploy this file to `supabase/functions/wearable-omi-capture/index.ts`. It + * imports the core from `../_shared/wearable-sync.ts`, so install + * wearable-capture-core FIRST (see this integration's README). The `_shared/` + * copy in this folder is a vendored copy of that same engine, present so the + * function typechecks standalone; the deno.json import map points the deploy + * path at it for local `deno check`. + * + * Omi API facts this adapter relies on: + * - Auth: `Authorization: Bearer ` (personal key, `omi_dev_...`). + * - Base: https://api.omi.me/v1/dev + * - Conversations: GET /user/conversations?include_transcript=true&limit&offset + * -> a BARE JSON array (not wrapped). No `since` param: page recent and + * filter to the window. Segment fields: speaker_id/speaker_name/start/end. + * - Memories: GET /user/memories?limit=500 -> a BARE JSON array of distilled facts. + */ +import { + atomFingerprint, + type Attribution, + fetchWithRetry, + runWearableSync, + type SyncResult, + type WearableAdapter, + type WearableAtom, +} from "../_shared/wearable-sync.ts"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +const OMI_BASE = "https://api.omi.me/v1/dev"; +const PAGE_SIZE = 50; +/** Safety cap on pages, so a far-back window can't loop forever. */ +const MAX_PAGES = 20; +/** Transcript chunk shape — the device's segments are flat, so we window them. */ +const CHUNK_MAX_SECONDS = 60; +const CHUNK_MAX_CHARS = 600; +/** Soft cap: a conversation past this many chunks is logged, never truncated. */ +const CHUNK_SOFT_WARN = 80; +const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; + +// ── types ───────────────────────────────────────────────────────────────────── + +/** A single Omi transcript line. */ +interface OmiSegment { + speaker_id?: string | number; + speaker_name?: string; + start?: number; + end?: number; + text?: string; +} + +/** Omi's device-native structured summary of a conversation. */ +interface OmiStructured { + title?: string; + overview?: string; + category?: string; + emoji?: string; + /** Each item is either a plain string or an object with `description`/`content`. */ + action_items?: Array; + /** Each event is either a plain string or an object with `title`/`description`. */ + events?: Array; +} + +/** One Omi conversation as returned by the list endpoint. */ +interface OmiConversation { + id: string; + created_at?: string; + started_at?: string; + finished_at?: string; + discarded?: boolean; + structured?: OmiStructured; + transcript_segments?: OmiSegment[]; +} + +/** One Omi memory (distilled fact) as returned by the memories endpoint. */ +interface OmiMemory { + id: string; + content?: string; + category?: string; + tags?: string[]; + scoring?: unknown; + reviewed?: boolean; + manually_added?: boolean; + edited?: boolean; + visibility?: string; + created_at?: string; + updated_at?: string; +} + +// ── speaker classification (generic — no hardcoded personal names) ───────────── + +/** Device-generic labels for the wearer. Add your own (e.g. your name) via the + * `WEARABLE_SELF_LABELS` env var (comma-separated) — never hardcode a name. */ +const DEFAULT_SELF_LABELS = ["you", "user", "me", "self", "myself"]; +const GENERIC_SPEAKER_RE = + /^(unknown|speaker[\s_]*\d+|spk[\s_]*\d+|user\s*\d+)$/i; + +function selfLabelSet(): Set { + const extra = (Deno.env.get("WEARABLE_SELF_LABELS") ?? "") + .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); + return new Set([...DEFAULT_SELF_LABELS, ...extra]); +} +const SELF_LABELS = selfLabelSet(); + +function isSelfSpeaker(name: string): boolean { + return SELF_LABELS.has(name.trim().toLowerCase()); +} +function isGenericSpeaker(name: string): boolean { + const s = name.trim(); + return !s || GENERIC_SPEAKER_RE.test(s); +} + +/** Classify a set of speaker labels into an attribution + self presence + role. */ +function classifySpeakers( + speakers: string[], +): { + attribution: Attribution; + selfPresent: boolean; + role: "author" | "participant" | null; +} { + let hasSelf = false, hasNamedOther = false; + for (const s of speakers) { + if (isSelfSpeaker(s)) hasSelf = true; + else if (!isGenericSpeaker(s)) hasNamedOther = true; + } + let attribution: Attribution; + if (hasSelf && hasNamedOther) attribution = "mixed"; + else if (hasSelf) attribution = "self"; + else if (hasNamedOther) attribution = "other"; + else attribution = "unknown"; + const role = hasSelf + ? (attribution === "self" ? "author" : "participant") + : null; + return { attribution, selfPresent: hasSelf, role }; +} + +// ── filler filter (drop all-filler transcript chunks) ────────────────────────── + +const FILLER_RE = + /^(yeah|yep|yup|ok|okay|right|mhmm|mm+|uh+|um+|haha+|ha|wow|woah|whoa|exactly|true|nice|cool|sure|yes|no|nope)[\s.!?,]*$/i; +function isAllFiller(text: string): boolean { + const t = text.trim(); + return t.length < 25 || FILLER_RE.test(t); +} + +const trim = (s: unknown): string => + String(s ?? "").replace(/\s+/g, " ").trim(); + +// ── Omi conversation atomization ─────────────────────────────────────────────── + +interface OmiChunk { + segments: Array<{ speaker: string; text: string }>; + startSec: number; + speakers: Set; +} + +/** Window flat segments into ~maxSeconds / ~maxChars chunks (Omi has no native sections). */ +function chunkSegments(segments: OmiSegment[]): OmiChunk[] { + const chunks: OmiChunk[] = []; + let cur: (OmiChunk & { chars: number }) | null = null; + for (const s of segments) { + const text = trim(s.text); + if (!text) continue; + const start = Number(s.start ?? 0); + if ( + cur && + (start - cur.startSec > CHUNK_MAX_SECONDS || cur.chars > CHUNK_MAX_CHARS) + ) { + cur = null; + } + if (!cur) { + cur = { + segments: [], + startSec: start, + chars: 0, + speakers: new Set(), + }; + chunks.push(cur); + } + const speaker = trim(s.speaker_name ?? s.speaker_id) || "Unknown"; + cur.segments.push({ speaker, text }); + cur.chars += text.length; + cur.speakers.add(speaker); + } + return chunks; +} + +/** Normalise an action item / event (string OR object) to its text, or "" to skip. */ +function itemText(item: unknown, keys: string[]): string { + if (typeof item === "string") return item.trim(); + if (item && typeof item === "object") { + for (const k of keys) { + const v = (item as Record)[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + } + return ""; +} + +const conversationStart = (c: OmiConversation): string | undefined => + c.started_at ?? c.created_at; + +/** + * Atomize one Omi conversation using the device's OWN structure (no LLM): + * - title + overview -> machine `meeting` atom, + * - each action item -> machine `task` atom, + * - each event -> machine `meeting` atom, + * - each transcript chunk -> `meeting` atom attributed to its speakers. + * Discarded conversations produce nothing. The chunk count is soft-warned past + * CHUNK_SOFT_WARN but never truncated. + */ +function atomizeConversation(c: OmiConversation): WearableAtom[] { + if (c.discarded === true) return []; + const atoms: WearableAtom[] = []; + const st = c.structured ?? {}; + const startedAt = conversationStart(c); + let idx = 0; + + // title + overview (machine summary) + const title = trim(st.title) || "Omi conversation"; + const overview = trim(st.overview); + if (overview || trim(st.title)) { + atoms.push({ + atomIndex: idx++, + atomKind: "overview", + content: `${title}${overview ? " — " + overview : ""}`.slice(0, 2000), + type: "meeting", + attribution: "machine", + generator: "omi", + createdAt: startedAt, + qualityScore: 55, + metadata: { is_overview: true, category: st.category ?? null }, + }); + } + + // action items (machine-extracted tasks) + for (const raw of st.action_items ?? []) { + const text = itemText(raw, ["description", "content"]); + if (text.length < 5) continue; + atoms.push({ + atomIndex: idx++, + atomKind: "action_item", + content: text.slice(0, 500), + type: "task", + attribution: "machine", + generator: "omi", + createdAt: startedAt, + qualityScore: 50, + metadata: { derived_from: "omi_action_item" }, + }); + } + + // events (machine-extracted) + for (const raw of st.events ?? []) { + const text = itemText(raw, ["title", "description"]); + if (text.length < 5) continue; + atoms.push({ + atomIndex: idx++, + atomKind: "event", + content: text.slice(0, 500), + type: "meeting", + attribution: "machine", + generator: "omi", + createdAt: startedAt, + qualityScore: 50, + metadata: { is_event: true }, + }); + } + + // transcript chunks (human speech — the detail a summary-only import loses) + const chunks = chunkSegments(c.transcript_segments ?? []); + if (chunks.length > CHUNK_SOFT_WARN) { + console.warn( + `[wearable-omi-capture] conversation ${c.id} produced ${chunks.length} chunks (> ${CHUNK_SOFT_WARN}); keeping all`, + ); + } + const startMs = startedAt ? Date.parse(startedAt) : NaN; + for (const ch of chunks) { + if (isAllFiller(ch.segments.map((u) => u.text).join(" "))) continue; + const speakers = [...ch.speakers]; + const cls = classifySpeakers(speakers); + const body = ch.segments.map((u) => `${u.speaker}: ${u.text}`).join("\n"); + const createdAt = Number.isFinite(startMs) && Number.isFinite(ch.startSec) + ? new Date(startMs + ch.startSec * 1000).toISOString() + : startedAt; + atoms.push({ + atomIndex: idx++, + atomKind: "transcript_chunk", + content: body.slice(0, 4000), + type: "meeting", + attribution: cls.attribution, + attributedTo: speakers, + generator: null, + selfPresent: cls.selfPresent, + role: cls.role, + createdAt, + qualityScore: 50, + metadata: { speakers, segment_count: ch.segments.length }, + }); + } + + return atoms; +} + +// ── the conversation adapter (driven by the shared core) ─────────────────────── + +function omiKey(): string { + const key = Deno.env.get("OMI_API_KEY"); + if (!key) throw new Error("OMI_API_KEY is required"); + return key; +} + +/** Fetch one page of conversations (newest first), as the bare array Omi returns. */ +async function fetchConversationPage( + offset: number, +): Promise { + const url = + `${OMI_BASE}/user/conversations?include_transcript=true&limit=${PAGE_SIZE}&offset=${offset}`; + const r = await fetchWithRetry(url, { + headers: { + "Authorization": `Bearer ${omiKey()}`, + "Accept": "application/json", + }, + }); + if (!r.ok) { + throw new Error( + `Omi conversations ${r.status}: ${(await r.text()).slice(0, 200)}`, + ); + } + const body = await r.json(); + return Array.isArray(body) ? (body as OmiConversation[]) : []; +} + +const omiAdapter: WearableAdapter = { + sourceId: "omi", + sourceType: "omi", + + /** + * Omi has no `since` parameter, so page newest-first (limit 50, increasing + * offset) and keep only conversations started at/after the window. Stop once a + * page yields an item older than the window — pages are newest-first, so + * everything beyond it is older too. + */ + async listSince(sinceISO: string): Promise { + const sinceMs = Date.parse(sinceISO); + const kept: OmiConversation[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const batch = await fetchConversationPage(page * PAGE_SIZE); + if (batch.length === 0) break; + let reachedOlder = false; + for (const c of batch) { + const startedRaw = conversationStart(c); + const startedMs = startedRaw ? Date.parse(startedRaw) : NaN; + if (Number.isNaN(startedMs)) continue; // can't window an undated conversation + if (startedMs >= sinceMs) kept.push(c); + else { + reachedOlder = true; + break; + } + } + if (reachedOlder || batch.length < PAGE_SIZE) break; + } + return kept; + }, + + recordId: (c) => c.id, + recordToAtoms: (c) => atomizeConversation(c), +}; + +// ── Omi memories (separate stream, edit-aware dedup) ─────────────────────────── + +interface MemoryResult { + pulled: number; + inserted: number; + updated: number; + skipped: number; + failed: number; +} + +/** Embed text via OpenRouter using the core's retry-aware fetch. Null if no key + * (the row inserts without an embedding; a later backfill fills it). */ +async function embedText(text: string): Promise { + const key = Deno.env.get("OPENROUTER_API_KEY"); + if (!key) return null; + const r = await fetchWithRetry(`${OPENROUTER_BASE}/embeddings`, { + method: "POST", + headers: { + "Authorization": `Bearer ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/text-embedding-3-small", + input: text.slice(0, 8000), + }), + }); + if (!r.ok) { + throw new Error( + `OpenRouter embeddings ${r.status}: ${(await r.text()).slice(0, 200)}`, + ); + } + const d = await r.json(); + return d?.data?.[0]?.embedding ?? null; +} + +async function fetchMemories(): Promise { + const r = await fetchWithRetry(`${OMI_BASE}/user/memories?limit=500`, { + headers: { + "Authorization": `Bearer ${omiKey()}`, + "Accept": "application/json", + }, + }); + if (r.status === 429) { + console.warn( + "[wearable-omi-capture] memories rate-limited (429); skipping this pass", + ); + return []; + } + if (!r.ok) { + throw new Error( + `Omi memories ${r.status}: ${(await r.text()).slice(0, 200)}`, + ); + } + const body = await r.json(); + return Array.isArray(body) ? (body as OmiMemory[]) : []; +} + +/** Map one memory to its provenance. A manually-added memory is `self`-authored; + * a device-inferred one is `machine`-generated. */ +function memoryAttribution(mem: OmiMemory): { + attribution: Attribution; + generator: string | null; + selfPresent: boolean; + role: "author" | null; +} { + const manual = mem.manually_added === true; + return { + attribution: manual ? "self" : "machine", + generator: manual ? null : "omi", + selfPresent: manual, + role: manual ? "author" : null, + }; +} + +/** + * Capture Omi memories as their own `omi_memory` stream. Edit-aware: one BATCH + * lookup of existing memory rows (never a per-memory `metadata->>id` scan), then + * insert new memories and patch ones Omi has edited since we last saw them. + */ +async function syncMemories( + client: SupabaseClient, + opts: { dryRun: boolean; embed: boolean }, +): Promise { + const result: MemoryResult = { + pulled: 0, + inserted: 0, + updated: 0, + skipped: 0, + failed: 0, + }; + const memories = await fetchMemories(); + result.pulled = memories.length; + if (memories.length === 0) return result; + + // ONE batch fetch of existing omi_memory rows (GIN-indexed containment) -> + // map by Omi's memory id. Per-memory filtering on a JSONB key is unindexed and + // would scan the whole table once per memory. + const existingById = new Map(); + if (!opts.dryRun) { + const { data, error } = await client + .from("thoughts") + .select("id, metadata") + .contains("metadata", { wearable_source: "omi", source: "omi_memory" }); + if (error) throw error; + for (const row of data ?? []) { + const md = + (row as { id: string; metadata?: Record }).metadata ?? + {}; + const mid = md.omi_memory_id; + if (typeof mid === "string") { + existingById.set(mid, { + id: (row as { id: string }).id, + updatedAt: md.omi_updated_at as string, + }); + } + } + } + + for (const mem of memories) { + if (!mem.id) continue; + const content = trim(mem.content); + if (!content) { + result.skipped++; + continue; + } + const attr = memoryAttribution(mem); + const isPrivate = String(mem.visibility ?? "").toLowerCase() === "private"; + const fingerprint = await atomFingerprint("omi_memory", mem.id, 0, content); + const metadata: Record = { + source: "omi_memory", + wearable_source: "omi", + provider_event_id: mem.id, + omi_memory_id: mem.id, + atom_index: 0, + atom_kind: "memory", + attribution: attr.attribution, + generator: attr.generator, + content_fingerprint: fingerprint, + captured_via: "wearable-atomic", + type: "reference", + importance: 3, + category: mem.category ?? null, + tags: mem.tags ?? null, + scoring: mem.scoring ?? null, + reviewed: mem.reviewed ?? null, + manually_added: mem.manually_added ?? false, + edited: mem.edited ?? null, + visibility: mem.visibility ?? null, + omi_updated_at: mem.updated_at ?? null, + // The baseline `thoughts` schema has no sensitivity column, so a private + // memory is flagged in metadata (additive) for downstream filtering. + ...(isPrivate ? { sensitivity_tier: "restricted" } : {}), + }; + if (attr.selfPresent) metadata.self_present = true; + + if (opts.dryRun) { + result.inserted++; + continue; + } + + const existing = existingById.get(mem.id); + if (existing) { + // Re-import only when Omi has edited the memory since we captured it. + if ( + mem.updated_at && existing.updatedAt && + mem.updated_at > existing.updatedAt + ) { + const patch: Record = { content, metadata }; + if (opts.embed) { + const emb = await embedText(content); + if (emb) patch.embedding = emb; + } + const { error } = await client.from("thoughts").update(patch).eq( + "id", + existing.id, + ); + if (error) { + result.failed++; + console.error( + `[wearable-omi-capture] memory ${mem.id} update failed: ${error.message}`, + ); + } else result.updated++; + } else result.skipped++; + continue; + } + + const row: Record = { content, metadata }; + if (mem.created_at) row.created_at = mem.created_at; + if (opts.embed) { + const emb = await embedText(content); + if (emb) row.embedding = emb; + } + const { error } = await client.from("thoughts").insert(row); + if (error) { + if (/duplicate key|23505/i.test(error.message ?? "")) result.skipped++; + else { + result.failed++; + console.error( + `[wearable-omi-capture] memory ${mem.id} insert failed: ${error.message}`, + ); + } + } else result.inserted++; + } + return result; +} + +// ── entry point ──────────────────────────────────────────────────────────────── + +function supabaseClient(): SupabaseClient { + const url = Deno.env.get("SUPABASE_URL"); + const key = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!url || !key) { + throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required"); + } + return createClient(url, key); +} + +Deno.serve(async (req: Request): Promise => { + try { + const url = new URL(req.url); + const dryRun = url.searchParams.get("dry_run") === "1"; + const sinceHours = Number(url.searchParams.get("since_hours")) || 12; + const noMemories = url.searchParams.get("no_memories") === "1"; + const embed = Deno.env.get("OPENROUTER_API_KEY") != null; + + const client = supabaseClient(); + const conversations: SyncResult = await runWearableSync(omiAdapter, { + sinceHours, + dryRun, + embed, + client, + }); + const memories = noMemories + ? null + : await syncMemories(client, { dryRun, embed }); + + return new Response(JSON.stringify({ conversations, memories, dryRun }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error(`[wearable-omi-capture] ${(err as Error).message}`); + return new Response( + JSON.stringify({ error: (err as Error).message }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } +}); diff --git a/integrations/wearable-omi-capture/metadata.json b/integrations/wearable-omi-capture/metadata.json new file mode 100644 index 000000000..0dfeadbc8 --- /dev/null +++ b/integrations/wearable-omi-capture/metadata.json @@ -0,0 +1,29 @@ +{ + "name": "Omi Wearable Capture", + "description": "Poll the Omi pendant and atomize each conversation into Open Brain thoughts — title + overview, one task per action item, one per event, and ~60s transcript chunks attributed to their speakers — plus Omi's distilled memories as an edit-aware omi_memory stream. Uses the device's own structure (no per-item LLM cost); per-atom salted-fingerprint dedup and provenance via the wearable-capture-core engine.", + "category": "integrations", + "author": { + "name": "Alan Shurafa", + "github": "alanshurafa" + }, + "version": "0.2.0", + "requires": { + "open_brain": true, + "services": ["Omi", "OpenRouter"], + "tools": ["Supabase CLI"] + }, + "tags": [ + "wearable", + "omi", + "voice", + "capture", + "poller", + "atomic", + "provenance", + "memory" + ], + "difficulty": "intermediate", + "estimated_time": "20 minutes", + "created": "2026-06-16", + "updated": "2026-06-16" +}