Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 3 additions & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export default defineConfig({
// leaving a bare require() for electron-builder to resolve through a
// pnpm symlink at package time. It is pure JS; the native nut.js binding
// it loads stays external (and asar-unpacked) as before.
externalizeDepsPlugin({ exclude: ['@profullstack/remote-input'] }),
// @pairux/meeting-copilot (used by `pairux copilot`) is ESM with
// import-only exports, which a CommonJS main chunk cannot require().
externalizeDepsPlugin({ exclude: ['@profullstack/remote-input', '@pairux/meeting-copilot'] }),
],
build: {
outDir: 'dist/main',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"dependencies": {
"@nut-tree-fork/nut-js": "^4.2.6",
"@pairux/meeting-copilot": "workspace:*",
"@pairux/meeting-translation": "workspace:*",
"@profullstack/remote-input": "workspace:*",
"@radix-ui/react-label": "^2.1.17",
Expand Down
174 changes: 171 additions & 3 deletions apps/desktop/src/main/agent-cli/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/**
* Thin client for the PairUX agent API (/api/v1/agents/*, /api/v1/cli/me).
* Thin client for the PairUX agent API (/api/v1/agents/*, /api/v1/cli/me) and
* the meeting copilot API (/api/copilot/*, /api/sessions/:id/copilot/*).
* Responses come back as { data } or { error }, like every PairUX route.
*/
import type {
CopilotMode,
CopilotRole,
CopilotState,
CopilotStatus,
} from '@pairux/meeting-copilot/types';

export interface RosterEntry {
id: string;
Expand Down Expand Up @@ -64,16 +71,26 @@ export class PairuxClient {
this.fetchImpl = opts.fetchImpl ?? fetch;
}

private async request<T>(path: string, init: RequestInit = {}, auth = false): Promise<T> {
/** The PairUX origin this client talks to, without a trailing slash. */
get baseUrl(): string {
return this.opts.apiUrl;
}

private async headers(init: RequestInit, auth: boolean, accept = 'application/json') {
const headers: Record<string, string> = {
Accept: 'application/json',
Accept: accept,
'User-Agent': this.opts.userAgent ?? 'pairux',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
};
if (auth && this.opts.getAccessToken) {
const token = await this.opts.getAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
}
return headers;
}

private async request<T>(path: string, init: RequestInit = {}, auth = false): Promise<T> {
const headers = await this.headers(init, auth);
const res = await this.fetchImpl(`${this.opts.apiUrl}${path}`, { ...init, headers });
const body = (await res.json().catch(() => ({}))) as { data?: T; error?: string };
if (!res.ok || body.data === undefined) {
Expand Down Expand Up @@ -116,6 +133,30 @@ export class PairuxClient {
});
}

/** Look up a live session by its 6-character join code. */
lookupJoinCode(joinCode: string) {
return this.request<{ id: string; status?: string; scheduled?: boolean }>(
`/api/sessions/join/${encodeURIComponent(joinCode)}`
);
}

/** The copilot API; every call is signed in. */
copilot(): CopilotApi {
return new CopilotApi(this, (path, init) => this.request(path, init, true));
}

/** POST to a text/event-stream endpoint, signed in; the body or an ApiError. */
async openStream(path: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>> {
const init: RequestInit = { method: 'POST', body: '{}', ...(signal ? { signal } : {}) };
const headers = await this.headers(init, true, 'text/event-stream');
const res = await this.fetchImpl(`${this.opts.apiUrl}${path}`, { ...init, headers });
if (!res.ok || !res.body) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, body.error ?? `Request failed (${String(res.status)})`);
}
return res.body;
}

/** Redeem a credit code (adds days of a paid plan). */
redeem(code: string) {
return this.request<{
Expand All @@ -140,6 +181,133 @@ export class PairuxClient {
}
}

/** Signed-in calls to the meeting copilot API. */
export class CopilotApi {
constructor(
private readonly client: PairuxClient,
private readonly call: <T>(path: string, init?: RequestInit) => Promise<T>
) {}

private session(sessionId: string, suffix = '') {
return `/api/sessions/${encodeURIComponent(sessionId)}/copilot${suffix}`;
}

status(sessionId: string) {
return this.call<CopilotStatus>(this.session(sessionId));
}

start(sessionId: string, opts: { role?: CopilotRole; mode?: CopilotMode }) {
return this.call<CopilotState>(this.session(sessionId), {
method: 'POST',
body: JSON.stringify(opts),
});
}

stop(sessionId: string) {
return this.call<CopilotState>(this.session(sessionId), { method: 'DELETE' });
}

ask(sessionId: string, question: string) {
return this.call<{ answer: string }>(this.session(sessionId, '/ask'), {
method: 'POST',
body: JSON.stringify({ question }),
});
}

recap(sessionId: string, seconds?: number) {
return this.call<{ recap: string; seconds: number }>(this.session(sessionId, '/recap'), {
method: 'POST',
body: JSON.stringify(seconds === undefined ? {} : { seconds }),
});
}

/** The live event stream; the caller reads it with readSseEvents. */
stream(sessionId: string, signal?: AbortSignal) {
return this.client.openStream(this.session(sessionId, '/stream'), signal);
}

list() {
return this.call<{ copilots: CopilotSummary[] }>('/api/copilot');
}

get(id: string) {
return this.call<{ copilot: CopilotState; title: string | null }>(
`/api/copilot/${encodeURIComponent(id)}`
);
}

write(id: string, what: 'follow-ups' | 'review') {
return this.call<Pick<CopilotState, 'followUps' | 'review'>>(
`/api/copilot/${encodeURIComponent(id)}/${what}`,
{ method: 'POST', body: '{}' }
);
}

settings() {
return this.call<CopilotSettingsView>('/api/copilot/settings');
}

saveSettings(patch: CopilotSettingsPatch) {
return this.call<CopilotSettingsView>('/api/copilot/settings', {
method: 'PUT',
body: JSON.stringify(patch),
});
}

wallet() {
return this.call<CopilotWallet>('/api/copilot/wallet');
}
}

export const KEY_PROVIDERS = ['anthropic', 'openai', 'elevenlabs'] as const;
export type KeyProvider = (typeof KEY_PROVIDERS)[number];

export interface CopilotSummary {
id: string;
sessionId: string;
role: string;
mode: string;
status: string;
title: string | null;
summary: string;
startedAt: string;
endedAt: string | null;
}

export interface CopilotSettingsView {
mode: CopilotMode;
role: CopilotRole;
keys: Record<KeyProvider, { set: boolean; last4: string | null }>;
/** GET only. */
eligible?: boolean;
byokAvailable?: boolean;
balance?: string;
balanceMicros?: number;
}

export interface CopilotSettingsPatch {
mode?: CopilotMode;
role?: CopilotRole;
keys?: Partial<Record<KeyProvider, string | null>>;
}

export interface CopilotWallet {
balance: string;
balanceMicros: number;
markup: number;
topups: { id: string; priceUsd: number }[];
usage: {
sessionId: string;
provider: string;
kind: string;
units: number;
charge: string;
chargeMicros: number;
status: string;
at: string;
}[];
}

export interface InvitesOverview {
quota: { total: number; used: number; left: number };
invites: {
Expand Down
19 changes: 17 additions & 2 deletions apps/desktop/src/main/agent-cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* pairux who who is in the session
* pairux leave
* pairux room link to a fresh anonymous E2EE room
* pairux copilot ... your AI meeting copilot (see ./copilot.ts)
*
* Every command takes its I/O through `Deps`, so tests drive it end to end
* against a fake server without touching the network, the terminal or ~/.
Expand All @@ -30,6 +31,8 @@ import {
} from './auth';
import { ApiError, PairuxClient, type ChatMessage, type RosterEntry } from './api';
import { newRoomLink } from './room';
import { COPILOT_HELP, runCopilot } from './copilot';
import { UsageError } from './errors';

const MAX_MESSAGE = 500;

Expand All @@ -44,6 +47,8 @@ export interface Deps {
readStdin: () => Promise<string | null>;
/** Tests stop `listen` after N polls; production runs until 410 or Ctrl-C. */
maxPolls?: number;
/** Tests stop `copilot watch` after N stream connections; production reconnects. */
maxStreams?: number;
login?: typeof listenForCallback;
/** The app version, for the User-Agent and `pairux version`. */
version?: string;
Expand Down Expand Up @@ -72,6 +77,9 @@ Usage:
pairux redeem <CODE> Redeem a credit code (adds days of a paid plan)
pairux invites [--json] Your invites left, invites sent and referral link
pairux invites create [--note <s>] Create an invite link (3 per account)
pairux copilot <command> Your AI meeting copilot (Pro/Team): live notes,
suggested questions, recaps, follow-ups, review.
\`pairux copilot help\` lists its commands.

Environment:
PAIRUX_API_URL default https://pairux.com
Expand Down Expand Up @@ -105,8 +113,6 @@ function describe(p: RosterEntry): string {
return `${p.display_name}${tags ? ` (${tags}${client})` : ''}`;
}

class UsageError extends Error {}

export async function run(argv: string[], deps: Deps): Promise<number> {
const [command, ...rest] = argv;
let config: CliConfig = await loadConfig(deps.configPath);
Expand Down Expand Up @@ -421,6 +427,15 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
}
}

case 'copilot': {
if (!rest[0] || ['help', '--help', '-h'].includes(rest[0])) {
deps.out(COPILOT_HELP);
return 0;
}
if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.');
return await runCopilot(rest, { deps, client, config });
}

case 'leave': {
const agent = requireAgent();
await client.leave(agent.participantId).catch((error: unknown) => {
Expand Down
Loading
Loading