diff --git a/AGENTS.md b/AGENTS.md index 61e49a2..8bd0d36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,174 +1,80 @@ -# AGENTS.md — pi-perplexity +# pi-perplexity -## What This Is +## Stack -A **pi extension** (plugin for `@mariozechner/pi-coding-agent`) that provides web search via a Perplexity Pro/Max subscription. Uses OAuth JWT authentication against Perplexity's internal SSE endpoint — no API credits consumed, only the subscription. +- TypeScript extension for `@mariozechner/pi-coding-agent`; loaded directly via jiti, no build artifact. +- Node >=18.14.1 runtime APIs only: `fetch`, `crypto.randomUUID()`, `Buffer.from(..., "base64url")`, `Intl`. +- Pi-bundled packages live in `peerDependencies` with `"*"`: `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@sinclair/typebox`. -## Build / Test / Lint +## Commands ```bash -# Type check npm run typecheck - -# Run tests npm test - -# Quick smoke test — factory returns valid tool shape node --import @mariozechner/jiti/register --input-type=module -e "import('./src/index.ts').then((m)=>{ const entry = m.default?.default ?? m.default; if (typeof entry !== 'function') throw new Error('default export missing'); })" ``` -No build step. Extensions are loaded via jiti — TypeScript runs directly. +## File-Scoped Commands -## Project Structure +| Task | Command | +|------|---------| +| Single test file | `npm run test:build && node --test --experimental-test-module-mocks ".tmp-test/test/search/stream.test.js"` | +| Live E2E opt-in | `PI_PERPLEXITY_E2E=1 npm test` | -``` -pi-perplexity/ - package.json # pi extension manifest (see "omp"/"pi" field) - tsconfig.json - AGENTS.md # You are here - architecture.md # Full protocol spec — SSE format, auth flows, event schemas - plan.md # Implementation phases and acceptance criteria - docs/ - pi_docs_extension.md # Official pi extension system documentation - pi_platform_reference.md # Pi platform reference (SDK, RPC, sessions, settings, packages) - src/ - index.ts # CustomToolFactory entry — default export - auth/ - jwt.ts # JWT base64url decode, expiry extraction - login.ts # macOS app extraction + email OTP flow - storage.ts # Token persistence (~/.config/pi-perplexity/auth.json) - search/ - types.ts # All type definitions (StreamEvent, SearchResult, etc.) - client.ts # HTTP POST to SSE endpoint, orchestrates stream + merge - stream.ts # SSE line parser + incremental event merging - format.ts # SearchResult → LLM-readable text output - render/ - call.ts # TUI renderCall component - result.ts # TUI renderResult component -``` +## Structure -## Critical Constraints +- `src/index.ts` — extension entry and `perplexity_search` tool definition. +- `src/auth/` — JWT extraction, email OTP login, token storage. +- `src/search/` — Perplexity SSE request, stream merge, result formatting, models. +- `src/render/` — TUI renderers for tool calls and results. +- `src/commands/` — slash commands such as login/config. +- `test/` — automated Node test suite; `.tmp-test/` is generated by `npm run test:build`. +- `local_tests/` — manual/debug scripts and captured dumps; do not treat as automated coverage. +- `docs/design-decisions.md` — rationale for non-obvious behavior. -### Zero Runtime Dependencies -This plugin has **zero npm dependencies**. All HTTP, SSE parsing, JWT decoding, and UUID generation use platform globals: -- `fetch` — global (Node 18.14.1+ for `Headers.getSetCookie()` support) -- `crypto.randomUUID()` — global -- `atob` / `Buffer.from(payload, "base64url")` — global -- `Intl.DateTimeFormat` — global +## Extension Conventions -Do NOT add dependencies to `dependencies` in package.json. +- Entry point exports a default factory from `src/index.ts`. +- Tool name is `perplexity_search`. +- Tool `execute` signature is `(toolCallId, params, signal, onUpdate, ctx)`. +- Tool results use `{ content: [{ type: "text", text }], details: { ... } }`. +- String enum params use `StringEnum` from `@mariozechner/pi-ai`; `Type.Union([Type.Literal(...)])` breaks Google models. +- Stream/API types stay loose: all reverse-engineered event fields are optional. -### Peer Dependencies Only -These are bundled by pi and must go in `peerDependencies` with `"*"` range: -- `@sinclair/typebox` — schema definitions (injected at runtime via `api.typebox`) -- `@mariozechner/pi-tui` — TUI Component types for renderers +## Perplexity Protocol Gotchas -### Reverse-Engineered API -The Perplexity SSE endpoint is **not a public API**. It can break without notice. -- Keep types loose — all fields optional -- Keep the client thin — minimal assumptions about response shape -- Specific User-Agent and headers are required (see architecture.md § Request) -- `is_incognito: true` always — don't pollute user's Perplexity history +- Endpoint: `POST https://www.perplexity.ai/rest/sse/perplexity_ask`. +- Required Perplexity constants live in `src/constants.ts`. +- Searches default to `is_incognito: true` to avoid polluting user history. +- The response is `data:` JSON lines with `[DONE]`, not a fully standard SSE stream. +- Events are incremental snapshots: shallow-merge top level, merge blocks by `intended_usage`, splice markdown via `chunk_starting_offset`, accumulate sources. +- Completion is `event.final === true` or `event.status === "COMPLETED"`. +- Answer extraction order: markdown blocks → `ask_text` blocks → event text. +- Source extraction order: `web_results` block → `sources_list`; deduplicate by URL. -## Coding Conventions +## Auth Gotchas -### TypeScript -- `strict: true`, `noEmit: true` -- Target: ESNext, module: ESNext, moduleResolution: bundler -- Use `interface` for data shapes, `type` for unions/aliases -- All stream event fields are optional — the API is unstable +- Auth tries macOS app token first via `defaults read ai.perplexity.mac authToken`, unless `PI_AUTH_NO_BORROW=1`. +- Email OTP fallback uses `ctx.ui.input()`. +- Tokens are stored at `~/.config/pi-perplexity/auth.json` with mode `0600`. +- JWT expiry is `decoded.exp * 1000` with a 5 minute buffer; decode failures fall back to 1 hour. +- AUTH errors preserve the cached token; users explicitly clear/re-auth with `/perplexity-login --force`. -### Extension API -- Entry point: `src/index.ts` exports `default` function or factory -- Import types from `@mariozechner/pi-coding-agent` -- Use `StringEnum` from `@mariozechner/pi-ai` for string enum params — `Type.Union`/`Type.Literal` breaks Google's API -- Tool execute signature: `execute(toolCallId, params, signal, onUpdate, ctx)` -- Return shape: `{ content: [{ type: "text", text }], details: { ... } }` +## Boundaries -### Naming -- Tool name: `perplexity_search` (snake_case, matches pi convention) -- File names: lowercase, descriptive (`stream.ts`, `client.ts`, `jwt.ts`) -- Types: PascalCase (`StreamEvent`, `SearchResult`, `StoredToken`) -- Constants: UPPER_SNAKE or camelCase for compound values +### Always -### Error Handling -- Never throw raw errors from tool execute — always return error text in `content` -- Use a `SearchError` class for typed errors with code and message -- HTTP 401/403: clear stored token, return auth error to agent -- HTTP 429: return rate limit message with retry suggestion -- Network failures: return error text, don't crash -- Empty responses: return "No results found" -- JWT errors: fallback expiry of 1 hour if decode fails +- Pass `AbortSignal` through to network calls. +- Return user-readable error text from tool execution instead of throwing raw errors. +- Keep runtime dependency-free; use platform APIs for HTTP, SSE parsing, JWT decoding, and UUIDs. -## Auth Flow (two paths, tried in order) +### Ask First -1. **macOS Desktop App** (zero-interaction): `defaults read ai.perplexity.mac authToken` - - Skip if `PI_AUTH_NO_BORROW=1` is set - - Returns null on non-macOS or if app not installed -2. **Email OTP** (interactive fallback): CSRF → send OTP → verify OTP - - Uses `ctx.ui.input()` for OTP prompt +- Changing Perplexity request headers, model IDs, or auth flow behavior. +- Adding package dependencies or generated build artifacts. +- Deleting captured local test dumps or token-related files. -Token stored at `~/.config/pi-perplexity/auth.json` with `0600` permissions. -JWT expiry: `decoded.exp * 1000 - 5min` buffer. No auto-refresh — re-login on expiry. +### Never -## SSE Stream Protocol - -Endpoint: `POST https://www.perplexity.ai/rest/sse/perplexity_ask` - -Events are **incremental snapshots** that must be merged: -- Top-level fields: shallow merge -- Blocks: keyed by `intended_usage` — merge, don't replace array -- Markdown chunks: respect `chunk_starting_offset` for splice -- Sources: accumulate, never replace; preserve from earlier events - -Stream terminates when `event.final === true` or `event.status === "COMPLETED"`. - -Answer extraction priority: markdown blocks → ask_text blocks → event.text fallback. -Source extraction priority: web_results block → sources_list fallback. Deduplicate by URL. - -Full protocol details: `architecture.md` § Search Protocol, § Response: SSE Event Stream. - -## Tool Output Format - -``` -## Answer - - -## Sources -N sources -[1] Title (2d ago) - https://url - snippet preview... - -## Meta -Provider: perplexity (oauth) -Model: -``` - -- Age: human-readable relative time ("2d ago", "3h ago", "just now") -- Snippets: truncated to 240 chars -- Source count: respect `limit` parameter - -## Key References - -| What | Where | -|------|-------| -| Full protocol spec (headers, body, SSE events) | `architecture.md` | -| Implementation phases and acceptance criteria | `plan.md` | -| Pi extension system overview | `docs/pi_docs_extension.md` | -| Pi platform reference (SDK, RPC, sessions, settings, packages) | `docs/pi_platform_reference.md` | - -## Design Decisions - -See `docs/design-decisions.md` for rationale on non-obvious choices. - -## Common Gotchas - -- `Type.Union([Type.Literal("a"), ...])` does NOT work for Google models — use `StringEnum` from `@mariozechner/pi-ai` -- Tool `execute` param order is `(toolCallId, params, signal, onUpdate, ctx)` — signal before onUpdate -- The SSE stream is NOT standard Server-Sent Events — it uses `data:` lines with JSON but requires custom parsing for multi-line data fields and `[DONE]` marker -- Perplexity SSE events are incremental snapshots, not deltas — each event contains the full state up to that point, but blocks must still be merged by `intended_usage` key -- macOS `defaults read` via `node:child_process` — handle non-zero exit code (app not installed) gracefully -- JWT `exp` claim is in seconds, not milliseconds — multiply by 1000 -- Token file must be written with `0600` permissions — use `node:fs/promises` and enforce mode on existing files -- Always pass `AbortSignal` through to `fetch` for cancellation support +- Never commit secrets, credentials, `.env`, cached auth tokens, or live Perplexity response dumps containing private data. +- Never clear cached auth automatically on transient 401/403 responses. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md