Remove architecture doc
This commit is contained in:
-320
@@ -1,320 +0,0 @@
|
|||||||
# pi-perplexity Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
An oh-my-pi plugin 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.
|
|
||||||
|
|
||||||
## System Context
|
|
||||||
|
|
||||||
```
|
|
||||||
oh-my-pi (coding-agent)
|
|
||||||
|
|
|
||||||
+-- plugin loader (discovers pi-perplexity via package.json "omp" manifest)
|
|
||||||
|
|
|
||||||
+-- pi-perplexity (CustomToolFactory)
|
|
||||||
|
|
|
||||||
+-- perplexity_search tool (CustomTool)
|
|
||||||
| |
|
|
||||||
| +-- Auth: JWT from macOS app or email OTP
|
|
||||||
| +-- Search: POST SSE to www.perplexity.ai
|
|
||||||
| +-- Parse: incremental event merging
|
|
||||||
| +-- Render: TUI components for call/result
|
|
||||||
|
|
|
||||||
+-- Token storage (SQLite via omp's AgentStorage, or standalone file)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
### JWT Acquisition (two paths, tried in order)
|
|
||||||
|
|
||||||
**Path 1 — macOS Desktop App Extraction (zero-interaction)**
|
|
||||||
|
|
||||||
The Perplexity macOS Catalyst app (`ai.perplexity.mac`) stores its auth JWT in NSUserDefaults, readable by any same-UID process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
defaults read ai.perplexity.mac authToken
|
|
||||||
```
|
|
||||||
|
|
||||||
If the app is installed and logged in, this returns the JWT immediately. No browser, no user interaction.
|
|
||||||
|
|
||||||
Skip this path if `PI_AUTH_NO_BORROW=1` is set.
|
|
||||||
|
|
||||||
**Path 2 — Email OTP (interactive, fallback)**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://www.perplexity.ai/api/auth/csrf
|
|
||||||
-> { csrfToken: string }
|
|
||||||
|
|
||||||
POST https://www.perplexity.ai/api/auth/signin-email
|
|
||||||
body: { email, csrfToken }
|
|
||||||
-> Sends OTP code to email
|
|
||||||
|
|
||||||
(user enters OTP)
|
|
||||||
|
|
||||||
POST https://www.perplexity.ai/api/auth/signin-otp
|
|
||||||
body: { email, otp, csrfToken }
|
|
||||||
-> { token: "<JWT>" }
|
|
||||||
```
|
|
||||||
|
|
||||||
All auth requests use these headers:
|
|
||||||
```
|
|
||||||
User-Agent: Perplexity/641 CFNetwork/1568 Darwin/25.2.0
|
|
||||||
X-App-ApiVersion: 2.18
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT Handling
|
|
||||||
|
|
||||||
- Expiry extracted from JWT payload `exp` claim: `decoded.exp * 1000 - 5min`
|
|
||||||
- Fallback expiry: 1 hour from acquisition if decode fails
|
|
||||||
- No automated refresh — Perplexity JWTs are long-lived; re-login on expiry
|
|
||||||
- Storage: persisted to disk so login survives restarts
|
|
||||||
|
|
||||||
### Token Storage
|
|
||||||
|
|
||||||
The JWT is stored as:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
type: "oauth",
|
|
||||||
access: "<JWT>",
|
|
||||||
expires: <exp_ms_minus_5min>,
|
|
||||||
email?: "<user@example.com>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two storage strategies (choose during implementation):
|
|
||||||
|
|
||||||
1. **Integrate with omp's AgentStorage** — query `listAuthCredentials("perplexity")` from the agent.db SQLite. Requires access to the db path via `getAgentDbPath()`.
|
|
||||||
2. **Standalone JSON file** — `~/.config/pi-perplexity/auth.json`. Simpler, no dependency on omp internals, portable.
|
|
||||||
|
|
||||||
On search, check stored JWT expiry (with 5-minute buffer). If expired, prompt re-login.
|
|
||||||
|
|
||||||
## Search Protocol
|
|
||||||
|
|
||||||
### Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
POST https://www.perplexity.ai/rest/sse/perplexity_ask
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <JWT>
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept: text/event-stream
|
|
||||||
Origin: https://www.perplexity.ai
|
|
||||||
Referer: https://www.perplexity.ai/
|
|
||||||
User-Agent: Perplexity/641 CFNetwork/1568 Darwin/25.2.0
|
|
||||||
X-App-ApiClient: default
|
|
||||||
X-App-ApiVersion: 2.18
|
|
||||||
X-Perplexity-Request-Reason: submit
|
|
||||||
X-Request-ID: <random-uuid>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query_str": "<effective_query>",
|
|
||||||
"params": {
|
|
||||||
"query_str": "<effective_query>",
|
|
||||||
"search_focus": "internet",
|
|
||||||
"mode": "copilot",
|
|
||||||
"model_preference": "pplx_pro_upgraded",
|
|
||||||
"sources": ["web"],
|
|
||||||
"attachments": [],
|
|
||||||
"frontend_uuid": "<random-uuid>",
|
|
||||||
"frontend_context_uuid": "<random-uuid>",
|
|
||||||
"version": "2.18",
|
|
||||||
"language": "en-US",
|
|
||||||
"timezone": "<Intl.DateTimeFormat().resolvedOptions().timeZone>",
|
|
||||||
"search_recency_filter": null | "hour" | "day" | "week" | "month" | "year",
|
|
||||||
"is_incognito": true,
|
|
||||||
"use_schematized_api": true,
|
|
||||||
"skip_search_enabled": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key parameters:
|
|
||||||
- `model_preference: "pplx_pro_upgraded"` — Pro subscription model
|
|
||||||
- `mode: "copilot"` — multi-step reasoning (Pro feature)
|
|
||||||
- `is_incognito: true` — does not save to Perplexity history
|
|
||||||
- `effective_query` — system prompt prepended to user query with `\n\n` separator (no separate system message in this API)
|
|
||||||
|
|
||||||
### Response: SSE Event Stream
|
|
||||||
|
|
||||||
Each `data:` line contains a JSON event. Events are **incremental snapshots** that must be merged.
|
|
||||||
|
|
||||||
#### Event Shape
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface StreamEvent {
|
|
||||||
status?: string; // "COMPLETED" on final
|
|
||||||
final?: boolean; // true on last event
|
|
||||||
text?: string; // plain text answer (fallback)
|
|
||||||
blocks?: StreamBlock[]; // structured content blocks
|
|
||||||
sources_list?: StreamSource[];// source references (alternative format)
|
|
||||||
display_model?: string; // model name used
|
|
||||||
uuid?: string; // request ID
|
|
||||||
error_code?: string;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StreamBlock {
|
|
||||||
intended_usage?: string; // "markdown_block" | "ask_text" | "web_results"
|
|
||||||
markdown_block?: {
|
|
||||||
answer?: string;
|
|
||||||
chunks?: string[]; // incremental text chunks
|
|
||||||
chunk_starting_offset?: number;
|
|
||||||
};
|
|
||||||
web_result_block?: {
|
|
||||||
web_results?: WebResult[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebResult {
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
snippet?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StreamSource {
|
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
snippet?: string;
|
|
||||||
date?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Merging Strategy
|
|
||||||
|
|
||||||
Events are incremental — each new event is merged into a running snapshot:
|
|
||||||
|
|
||||||
1. **Top-level fields**: shallow merge (`{ ...existing, ...incoming }`)
|
|
||||||
2. **Blocks**: keyed by `intended_usage` — new blocks with same key replace/merge with existing
|
|
||||||
3. **Markdown chunks**: if `chunk_starting_offset` is 0, replace all chunks; otherwise splice at offset
|
|
||||||
4. **Sources**: accumulated, not replaced; `sources_list` preserved from earlier events if absent in later ones
|
|
||||||
|
|
||||||
Stream terminates when `event.final === true` or `event.status === "COMPLETED"`.
|
|
||||||
|
|
||||||
#### Answer Extraction (priority order)
|
|
||||||
|
|
||||||
1. `blocks` where `intended_usage` contains `"markdown"` -> join `chunks[]` or use `answer` field
|
|
||||||
2. `blocks` where `intended_usage === "ask_text"` -> same logic
|
|
||||||
3. `event.text` fallback
|
|
||||||
|
|
||||||
#### Source Extraction (priority order)
|
|
||||||
|
|
||||||
1. `blocks` where `intended_usage === "web_results"` -> `web_result_block.web_results[]`
|
|
||||||
2. `event.sources_list[]` fallback
|
|
||||||
3. Deduplicate by URL
|
|
||||||
|
|
||||||
## Plugin Interface
|
|
||||||
|
|
||||||
### oh-my-pi CustomTool Contract
|
|
||||||
|
|
||||||
The plugin exports a `CustomToolFactory`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type CustomToolFactory = (api: CustomToolAPI) =>
|
|
||||||
CustomTool | CustomTool[] | Promise<CustomTool | CustomTool[]>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `CustomTool` implements:
|
|
||||||
```typescript
|
|
||||||
interface CustomTool<TParams, TDetails> {
|
|
||||||
name: string; // "perplexity_search"
|
|
||||||
label: string; // "Perplexity Search"
|
|
||||||
description: string; // tool description for LLM
|
|
||||||
parameters: TSchema; // TypeBox schema
|
|
||||||
execute(toolCallId, params, onUpdate, ctx, signal): Promise<AgentToolResult>;
|
|
||||||
renderCall?(args, theme): Component; // TUI call display
|
|
||||||
renderResult?(result, options, theme): Component; // TUI result display
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Parameters
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
query: string; // required
|
|
||||||
recency?: "hour" | "day" | "week" | "month" | "year";
|
|
||||||
limit?: number; // max sources to return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Output
|
|
||||||
|
|
||||||
Text block formatted for LLM consumption:
|
|
||||||
```
|
|
||||||
## Answer
|
|
||||||
<synthesized answer with inline citations>
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
N sources
|
|
||||||
[1] Title (age)
|
|
||||||
https://url
|
|
||||||
snippet...
|
|
||||||
|
|
||||||
## Meta
|
|
||||||
Provider: perplexity (oauth)
|
|
||||||
Model: <display_model>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Package Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
pi-perplexity/
|
|
||||||
package.json # omp plugin manifest
|
|
||||||
tsconfig.json
|
|
||||||
src/
|
|
||||||
index.ts # CustomToolFactory entry point
|
|
||||||
auth/
|
|
||||||
jwt.ts # JWT decode, expiry extraction
|
|
||||||
login.ts # macOS app extraction + email OTP flow
|
|
||||||
storage.ts # Token persistence (read/write/check expiry)
|
|
||||||
search/
|
|
||||||
client.ts # HTTP request to SSE endpoint
|
|
||||||
stream.ts # SSE parsing + event merging
|
|
||||||
types.ts # All type definitions
|
|
||||||
format.ts # Response formatting for LLM output
|
|
||||||
render/
|
|
||||||
call.ts # TUI renderCall component
|
|
||||||
result.ts # TUI renderResult component
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Required
|
|
||||||
- `@sinclair/typebox` — injected by omp via `CustomToolAPI.typebox`, no direct dependency needed
|
|
||||||
- `@oh-my-pi/pi-tui` — for TUI `Component` type in renderers (peer dependency)
|
|
||||||
|
|
||||||
### None (zero runtime dependencies)
|
|
||||||
- `fetch` — global (Bun/Node 18+)
|
|
||||||
- `crypto.randomUUID()` — global
|
|
||||||
- `atob` / `Buffer` — global
|
|
||||||
- `Bun.$` — for `defaults read` on macOS
|
|
||||||
- `Intl.DateTimeFormat` — global
|
|
||||||
|
|
||||||
The plugin should have **zero npm dependencies**. All HTTP, SSE parsing, and JWT decoding use platform APIs.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
| Error | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| No JWT found (not logged in) | Return error text to agent: "Not authenticated. Run `omp login perplexity`." |
|
|
||||||
| JWT expired | Attempt re-login via macOS app extraction; if fails, return error prompting manual login |
|
|
||||||
| HTTP 401/403 | JWT revoked or expired; clear stored token, return auth error |
|
|
||||||
| HTTP 429 | Rate limited; return error with retry suggestion |
|
|
||||||
| SSE `error_code` in stream | Extract `error_message`, throw SearchError |
|
|
||||||
| Network failure | Return error text to agent |
|
|
||||||
| Empty response (no answer, no sources) | Return "No results found" |
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- JWT stored on disk with user-only permissions (0600)
|
|
||||||
- `is_incognito: true` prevents queries from appearing in Perplexity history
|
|
||||||
- No API key needed — subscription auth only
|
|
||||||
- Token never logged; only expiry metadata logged at debug level
|
|
||||||
Reference in New Issue
Block a user