-
Notifications
You must be signed in to change notification settings - Fork 1
[integrations] Limitless wearable capture #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| # Limitless Wearable Capture | ||
|
|
||
| > **Land your Limitless Pendant lifelogs in your Open Brain automatically.** A scheduled poller pulls recent recordings from the Limitless API, maps each one to a `meeting` thought using the device's own title and section headings, embeds it, and stores it — no per-item LLM cost. | ||
|
|
||
| --- | ||
|
|
||
| ## What It Does | ||
|
|
||
| A Supabase Edge Function runs on a schedule (every 5 minutes) and asks the Limitless lifelog API for recordings in a rolling time window. Each lifelog becomes one `thoughts` row of type `meeting`: the content is the lifelog title plus its section headings (or a transcript snippet if there are no headings), and the metadata carries the Limitless lifelog id and start/finish times. The shared **wearable-capture-core** engine does the heavy lifting — idempotent dedup on the device's own id, embedding via OpenRouter, and the insert. This adapter only knows how to *list* Limitless lifelogs and *map* one to a thought. | ||
|
|
||
| Because dedup keys on the device's stable lifelog id (not a content hash), overlapping poll windows and re-runs are safe, and a missed run self-heals on the next pass — no local state file. | ||
|
|
||
| --- | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - **The [wearable-capture-core](../wearable-capture-core/) engine installed first.** This adapter imports it from `../_shared/wearable-sync.ts`; without it, the function won't deploy. Follow that README through Step 2 (it also sets `OPENROUTER_API_KEY`, which this adapter relies on for embeddings). | ||
| - A working Open Brain setup (Supabase project with the `thoughts` table and pgvector). | ||
| - A **Limitless** account with a Pendant and an API key. Get the key from the Limitless app → **Developer settings** (Settings → Developer / API). | ||
| - An [OpenRouter](https://openrouter.ai) API key (set when you install the core — used for embeddings). | ||
| - Supabase CLI installed and logged in. | ||
|
|
||
| **Cost**: Limitless API access is included with your Limitless subscription. The only marginal cost here is OpenRouter embeddings (no per-item LLM classification — the adapter reuses the device's own structure), roughly **$0.02–0.10/month** for typical personal volume. | ||
|
|
||
| --- | ||
|
|
||
| ## Credential Tracker | ||
|
|
||
| Fill these in as you go — you'll need them in Steps 2 and 5: | ||
|
|
||
| | Credential | Where it comes from | Value | | ||
| |---|---|---| | ||
| | `LIMITLESS_API_KEY` | Limitless app → Developer settings (Step 2) | | | ||
| | `OPENROUTER_API_KEY` | Set when installing wearable-capture-core ([openrouter.ai/keys](https://openrouter.ai/keys)) | (from core) | | ||
| | `SUPABASE_URL` | Auto-injected by Supabase | (skip) | | ||
| | `SUPABASE_SERVICE_ROLE_KEY` | Auto-injected by Supabase | (skip) | | ||
|
|
||
| > [!WARNING] | ||
| > The Limitless API key is a credential. Set it with `supabase secrets set` — never paste it into code, commits, or screenshots. | ||
|
|
||
| --- | ||
|
|
||
| ## Steps | ||
|
|
||
| ### Step 1 — Install the wearable-capture-core engine | ||
|
|
||
| This adapter is built on the shared engine and can't run without it. | ||
|
|
||
| Follow the [wearable-capture-core README](../wearable-capture-core/) through **Step 2**. That gives you: | ||
|
|
||
| - `supabase/functions/_shared/wearable-sync.ts` (the engine this function imports), and | ||
| - `OPENROUTER_API_KEY` set as a Supabase secret (used for embeddings). | ||
|
|
||
| ✅ **Done when:** `supabase/functions/_shared/wearable-sync.ts` exists and `supabase secrets list` shows `OPENROUTER_API_KEY`. | ||
|
|
||
| --- | ||
|
|
||
| ### Step 2 — Get your Limitless API key and set it | ||
|
|
||
| 1. Open the Limitless app and go to **Settings → Developer settings** (sometimes labelled API). | ||
| 2. Create / copy your API key. | ||
| 3. Set it as a Supabase secret (replace the placeholder with your real key, no angle brackets): | ||
|
|
||
| ```bash | ||
| supabase secrets set LIMITLESS_API_KEY="your_limitless_api_key" | ||
| ``` | ||
|
|
||
| `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 `LIMITLESS_API_KEY` (and `OPENROUTER_API_KEY` from Step 1). | ||
|
|
||
| --- | ||
|
|
||
| ### Step 3 — Drop the function into your Supabase project | ||
|
|
||
| From the root of your Supabase project: | ||
|
|
||
| ```bash | ||
| mkdir -p supabase/functions/wearable-limitless-capture | ||
| ``` | ||
|
|
||
| Copy [`index.ts`](./index.ts) from this folder to `supabase/functions/wearable-limitless-capture/index.ts`. It imports the engine from `../_shared/wearable-sync.ts`, so the relative path lines up once the core is in place from Step 1. | ||
|
|
||
| The function defines a Limitless adapter (`sourceId: "limitless"`, `sourceType: "limitless_lifelog"`) and, on each invocation, calls: | ||
|
|
||
| ```typescript | ||
| const result = await runWearableSync(limitlessAdapter, { sinceHours: 12 }); | ||
| ``` | ||
|
|
||
| It returns the engine's `{ source, pulled, imported, skipped, failed, dryRun }` result as JSON. | ||
|
|
||
| ✅ **Done when:** The file exists at `supabase/functions/wearable-limitless-capture/index.ts` and `deno check index.ts` is clean. | ||
|
|
||
| --- | ||
|
|
||
| ### Step 4 — Deploy the edge function | ||
|
|
||
| ```bash | ||
| supabase functions deploy wearable-limitless-capture | ||
| ``` | ||
|
|
||
| Your function URL will look like: | ||
|
|
||
| ``` | ||
| https://YOUR_PROJECT_REF.supabase.co/functions/v1/wearable-limitless-capture | ||
| ``` | ||
|
|
||
| (where `YOUR_PROJECT_REF` is the subdomain of your actual Supabase project). Keep the full URL handy for Step 5. | ||
|
|
||
| You can trigger it once by hand to confirm it runs (the function reads its credentials from secrets, so no auth header is needed for the smoke test if you deployed with `--no-verify-jwt`; otherwise call it from the scheduled job in Step 5): | ||
|
|
||
| ```bash | ||
| curl -X POST "https://YOUR_PROJECT_REF.supabase.co/functions/v1/wearable-limitless-capture" | ||
| ``` | ||
|
|
||
| A healthy run returns JSON like `{"source":"limitless","pulled":3,"imported":3,"skipped":0,"failed":0,"dryRun":false}`. | ||
|
|
||
| ✅ **Done when:** `supabase functions deploy` prints a success URL and a manual invocation returns a JSON result object. | ||
|
|
||
| --- | ||
|
|
||
| ### Step 5 — Schedule the poller (every 5 minutes) | ||
|
|
||
| Limitless has no webhook, so we poll. Use `pg_cron` + `net.http_post` to hit the function URL on a cron. Run this SQL in the Supabase SQL editor (enable the `pg_cron` and `pg_net` extensions first if they aren't already): | ||
|
|
||
| ```sql | ||
| -- Enable the extensions (no-op if already enabled) | ||
| create extension if not exists pg_cron; | ||
| create extension if not exists pg_net; | ||
|
|
||
| -- Poll Limitless every 5 minutes | ||
| select cron.schedule( | ||
| 'wearable-limitless-capture', | ||
| '*/5 * * * *', | ||
| $$ | ||
| select net.http_post( | ||
| url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/wearable-limitless-capture', | ||
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key', true) | ||
| ), | ||
| body := '{}'::jsonb | ||
| ); | ||
| $$ | ||
| ); | ||
| ``` | ||
|
|
||
| > [!IMPORTANT] | ||
| > `OPENROUTER_API_KEY` must already be set (you set it while installing wearable-capture-core in Step 1) — the engine uses it to embed each thought at capture time. If it's missing, rows still insert but with a NULL embedding for a later backfill. | ||
|
|
||
| > [!NOTE] | ||
| > Replace `YOUR_PROJECT_REF` with your project subdomain. The `sinceHours: 12` lookback in the function means the 5-minute cron has a wide overlap; the engine's id-based dedup makes that overlap free of duplicates and lets a missed run self-heal. | ||
|
|
||
| To change or remove the schedule later: | ||
|
|
||
| ```sql | ||
| -- Inspect | ||
| select * from cron.job where jobname = 'wearable-limitless-capture'; | ||
| -- Remove | ||
| select cron.unschedule('wearable-limitless-capture'); | ||
| ``` | ||
|
|
||
| ✅ **Done when:** `select * from cron.job where jobname = 'wearable-limitless-capture';` shows the job and, after a few minutes, lifelogs start appearing in `thoughts`. | ||
|
|
||
| --- | ||
|
|
||
| ### Step 6 — Verify capture | ||
|
|
||
| After a cron tick (or a manual invocation), confirm rows landed: | ||
|
|
||
| ```sql | ||
| select count(*) from thoughts where metadata->>'wearable_source' = 'limitless'; | ||
| ``` | ||
|
|
||
| For a closer look at a few captured lifelogs: | ||
|
|
||
| ```sql | ||
| select id, content, metadata->>'title' as title, metadata->>'started_at' as started_at | ||
| from thoughts | ||
| where metadata->>'wearable_source' = 'limitless' | ||
| order by created_at desc | ||
| limit 5; | ||
| ``` | ||
|
|
||
| ✅ **Done when:** The count is non-zero and recent rows show your lifelog titles with a populated embedding. | ||
|
|
||
| --- | ||
|
|
||
| ## Expected Outcome | ||
|
|
||
| Every 5 minutes, new Limitless lifelogs become `meeting` thoughts in your brain — embedded, deduplicated on the device's own lifelog id, and tagged with `metadata.source = 'limitless_lifelog'` and `metadata.wearable_source = 'limitless'` for retrieval and provenance. Content is the lifelog title plus its section headings (or a transcript snippet when there are no headings), built from the device's own structure with no LLM call. Overlapping poll windows and re-runs never produce duplicates, and a skipped run self-heals on the next pass. | ||
|
|
||
| --- | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| **Function won't deploy: cannot find `../_shared/wearable-sync.ts`** | ||
| The wearable-capture-core engine isn't installed. Complete Step 1 — copy `wearable-sync.ts` into `supabase/functions/_shared/` — then redeploy. | ||
|
|
||
| **`LIMITLESS_API_KEY is required` in the logs** | ||
| The secret isn't set (or the function was deployed before you set it). Run `supabase secrets set LIMITLESS_API_KEY="..."`, then redeploy. Check with `supabase secrets list`. | ||
|
|
||
| **Limitless API returns 401 / 403** | ||
| The API key is wrong, revoked, or truncated. Regenerate it in the Limitless app → Developer settings and re-set the secret. Note the adapter authenticates with the `X-API-Key` header (not a bearer token). | ||
|
|
||
| **`pulled` is non-zero but `imported` is 0 (all skipped)** | ||
| Those lifelogs are already in the brain — dedup matched their `provider_event_id`. This is the steady state once you've caught up. To confirm, lower nothing; just check the count in Step 6 is growing over time as new recordings come in. | ||
|
|
||
| **Thoughts insert but `embedding` is null** | ||
| `OPENROUTER_API_KEY` isn't set (it comes from the core install). Set it and future captures embed at write time; a later embedding backfill can fill the gaps. | ||
|
|
||
| **Cron job runs but nothing happens** | ||
| Confirm `pg_cron` and `pg_net` are enabled, that the URL in `cron.schedule` is your real project ref, and that the Authorization header resolves to a valid service-role key. Inspect `select * from cron.job_run_details order by start_time desc limit 5;` for HTTP errors, and check `supabase functions logs wearable-limitless-capture`. | ||
|
|
||
| --- | ||
|
|
||
| ## Tool Surface Area | ||
|
|
||
| This integration **registers no new MCP tools**. It is a capture-only path: a scheduled edge function that calls the shared wearable engine to write rows into the existing `thoughts` table. | ||
|
|
||
| | Component | Type | What it does | | ||
| |---|---|---| | ||
| | `wearable-limitless-capture` Edge Function | Supabase scheduled poller (not an MCP server) | Pulls recent lifelogs from the Limitless API and maps each to a `meeting` thought via the adapter. | | ||
| | `wearable-sync.ts` | Shared Deno module (from [wearable-capture-core](../wearable-capture-core/)) | Dedups, embeds (OpenRouter), and inserts into `thoughts`. | | ||
| | `thoughts` table | Existing Open Brain primitive | No schema changes — additive rows only. | | ||
|
|
||
| **External services called:** `api.limitless.ai/v1` (lifelog list, by the adapter) and `openrouter.ai/api/v1` (embeddings, by the core). Both are outbound HTTPS. | ||
|
|
||
| --- | ||
|
|
||
| ## Related | ||
|
|
||
| - [Wearable Capture Core](../wearable-capture-core/) — the engine this adapter is built on (install first). | ||
| - [Omi Wearable Capture](../wearable-omi-capture/) — sibling adapter for the Omi pendant. | ||
| - [Smart Ingest](../smart-ingest/) — LLM extraction + dedup for raw documents (heavier path). | ||
| - [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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "imports": { | ||
| "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10" | ||
| }, | ||
| "tasks": { | ||
| "check": "deno check index.ts", | ||
| "fmt": "deno fmt", | ||
| "lint": "deno lint" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| /** | ||
| * wearable-limitless-capture — Limitless Pendant adapter for wearable-capture-core. | ||
| * | ||
| * Polls the Limitless lifelog API for recent recordings and maps each one to a | ||
| * single `meeting` thought, using the device's OWN structure (title + section | ||
| * headings) — no LLM call. The shared engine (`_shared/wearable-sync.ts`) owns | ||
| * dedup, embedding, and the insert into `thoughts`. | ||
| * | ||
| * Deploy to `supabase/functions/wearable-limitless-capture/index.ts`. | ||
| * Requires the wearable-capture-core engine at `_shared/wearable-sync.ts`. | ||
| */ | ||
| import { runWearableSync, type WearableAdapter, type WearableThought } from "../_shared/wearable-sync.ts"; | ||
|
|
||
| const LIMITLESS_BASE = "https://api.limitless.ai/v1"; | ||
|
|
||
| /** Shape of a single lifelog as returned by the Limitless API (only the fields we read). */ | ||
| interface Lifelog { | ||
| id: string; | ||
| title?: string; | ||
| markdown?: string; | ||
| startTime?: string; | ||
| endTime?: string; | ||
| } | ||
|
|
||
| /** Safety caps so a wide window or a runaway cursor can't fetch unbounded pages. */ | ||
| const MAX_RECORDS = 500; | ||
| const MAX_PAGES = 30; | ||
|
|
||
| /** | ||
| * Pull lifelogs created at/after `sinceISO`, paging forward (ascending) by | ||
| * following `meta.lifelogs.nextCursor` until it's empty, a page is empty, or a | ||
| * cap is hit. | ||
| */ | ||
| async function listSince(sinceISO: string): Promise<Lifelog[]> { | ||
| const apiKey = Deno.env.get("LIMITLESS_API_KEY"); | ||
| if (!apiKey) throw new Error("LIMITLESS_API_KEY is required"); | ||
|
|
||
| const out: Lifelog[] = []; | ||
| let cursor: string | undefined; | ||
|
|
||
| for (let page = 0; page < MAX_PAGES; page++) { | ||
| const params = new URLSearchParams({ | ||
| start: sinceISO, | ||
| timezone: "UTC", | ||
| limit: "50", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For live Limitless polling, this sends Useful? React with 👍 / 👎. |
||
| direction: "asc", | ||
| includeMarkdown: "true", | ||
| includeHeadings: "true", | ||
| }); | ||
| if (cursor) params.set("cursor", cursor); | ||
|
|
||
| const r = await fetch(`${LIMITLESS_BASE}/lifelogs?${params.toString()}`, { | ||
| headers: { "X-API-Key": apiKey }, | ||
| }); | ||
| if (!r.ok) { | ||
| throw new Error(`Limitless lifelogs ${r.status}: ${(await r.text()).slice(0, 200)}`); | ||
| } | ||
|
|
||
| const body = await r.json(); | ||
| const lifelogs: Lifelog[] = body?.data?.lifelogs ?? []; | ||
| if (lifelogs.length === 0) break; | ||
|
|
||
| out.push(...lifelogs); | ||
| if (out.length >= MAX_RECORDS) return out.slice(0, MAX_RECORDS); | ||
|
|
||
| cursor = body?.meta?.lifelogs?.nextCursor ?? undefined; | ||
| if (!cursor) break; | ||
| } | ||
|
|
||
| return out; | ||
| } | ||
|
|
||
| /** Pull section headings from the lifelog markdown: lines like `## Heading` or `### Heading`. */ | ||
| function extractHeadings(markdown: string): string[] { | ||
| const headings: string[] = []; | ||
| for (const line of markdown.split("\n")) { | ||
| const m = line.match(/^##{1,2}\s+(.*)$/); | ||
| if (m) { | ||
| const text = m[1].trim(); | ||
| if (text) headings.push(text); | ||
| } | ||
| } | ||
| return headings; | ||
| } | ||
|
|
||
| /** Fallback body: stitch transcript bullet lines, stripping `- ` and any `(timestamp):` prefix. */ | ||
| function transcriptSnippet(markdown: string): string { | ||
| const parts: string[] = []; | ||
| for (const line of markdown.split("\n")) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed.startsWith("- ")) continue; | ||
| const text = trimmed | ||
| .slice(2) | ||
| .replace(/^\([^)]*\):\s*/, "") | ||
| .trim(); | ||
| if (text) parts.push(text); | ||
| } | ||
| return parts.join(" ").slice(0, 400); | ||
| } | ||
|
|
||
| /** Map one lifelog to a single `meeting` thought from the device's own structure (no LLM). */ | ||
| function recordToThoughts(ll: Lifelog): WearableThought[] { | ||
| const title = (ll.title ?? "Lifelog").trim(); | ||
| const markdown = ll.markdown ?? ""; | ||
|
|
||
| const headings = extractHeadings(markdown); | ||
| const body = headings.length > 0 ? headings.join("; ") : transcriptSnippet(markdown); | ||
|
|
||
| const content = body ? `${title} — ${body}` : title; | ||
|
|
||
| return [{ | ||
| content, | ||
| type: "meeting", | ||
| metadata: { | ||
| limitless_lifelog_id: ll.id, | ||
| title, | ||
| started_at: ll.startTime, | ||
| finished_at: ll.endTime, | ||
| }, | ||
| createdAt: ll.startTime, | ||
| }]; | ||
| } | ||
|
|
||
| const limitlessAdapter: WearableAdapter<Lifelog> = { | ||
| sourceId: "limitless", | ||
| sourceType: "limitless_lifelog", | ||
| listSince, | ||
| recordId: (ll) => ll.id, | ||
| recordToThoughts, | ||
| }; | ||
|
|
||
| Deno.serve(async (): Promise<Response> => { | ||
| try { | ||
| const result = await runWearableSync(limitlessAdapter, { sinceHours: 12 }); | ||
| return new Response(JSON.stringify(result), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| } catch (err) { | ||
| console.error("[wearable-limitless-capture]", (err as Error).message); | ||
| return new Response( | ||
| JSON.stringify({ error: (err as Error).message }), | ||
| { status: 500, headers: { "Content-Type": "application/json" } }, | ||
| ); | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When users follow the documented default
supabase functions deployand then install this cron job, the database session cannot read the Edge Function's auto-injectedSUPABASE_SERVICE_ROLE_KEY;current_setting('app.settings.service_role_key', true)returns NULL unless they separately create that setting, so theAuthorizationheader is NULL and Supabase's default JWT verification rejects the scheduled request before the function runs. Supabase documents JWT verification as the default and recommends Vault for scheduled function tokens (https://supabase.com/docs/guides/functions/function-configuration, https://supabase.com/docs/guides/functions/schedule-functions), so this step should either use Vault / a documented DB setting or explicitly deploy with JWT verification disabled.Useful? React with 👍 / 👎.