Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
237 changes: 237 additions & 0 deletions integrations/wearable-limitless-capture/README.md
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a stored token for scheduled Edge calls

When users follow the documented default supabase functions deploy and then install this cron job, the database session cannot read the Edge Function's auto-injected SUPABASE_SERVICE_ROLE_KEY; current_setting('app.settings.service_role_key', true) returns NULL unless they separately create that setting, so the Authorization header 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 👍 / 👎.

),
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.
10 changes: 10 additions & 0 deletions integrations/wearable-limitless-capture/deno.json
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"
}
}
146 changes: 146 additions & 0 deletions integrations/wearable-limitless-capture/index.ts
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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the Limitless page size within the API cap

For live Limitless polling, this sends limit=50 on every /v1/lifelogs request, but the current Limitless developer docs cap the lifelog list limit at 10 and instruct callers to use cursor pagination for more results (https://www.limitless.ai/developers). If the API enforces that documented max, every scheduled run takes the !r.ok path and imports nothing; use a page size of 10 and continue following nextCursor.

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" } },
);
}
});
Loading
Loading