pplx-npx-search 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to pplx-cli will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-05-22
9
+
10
+ ### Added
11
+ - **Standard artifacts.** Query-producing commands now save run folders by default with `meta.json`, `query.txt`, `answer.md`, `result.json`, and `sources.json`.
12
+ - **Artifact controls.** `--out <dir>`, `--artifact-id <id>`, and `--no-artifact` support local agent workflows and deterministic run folders.
13
+ - **Perplexity Computer handoff.** `pplx computer` creates artifact-first task prompts for Perplexity Computer and validates `computer-result.json` outputs for local agents.
14
+
15
+ ## [0.2.2] - 2026-05-21
16
+
17
+ ### Added
18
+ - **Configurable stream timeout.** `search`, `reason`, and `research` now accept `--timeout-ms <duration>`, with support for raw milliseconds plus `s` and `m` suffixes.
19
+
20
+ ### Changed
21
+ - `pplx research` now defaults to a 10-minute stream timeout so Deep Research can finish instead of hitting the old 2-minute ceiling.
22
+ - `pplx --version` now reads from `package.json`, keeping CLI output aligned with npm releases.
23
+
8
24
  ## [0.2.1] - 2026-05-18
9
25
 
10
26
  First public release worth telling people about. (v0.2.0 was unpublished before this release; do not use it.)
@@ -46,6 +62,8 @@ First public release worth telling people about. (v0.2.0 was unpublished before
46
62
  - SSE streaming for real-time answers
47
63
  - Optional Playwright and Chrome CDP transports
48
64
 
65
+ [0.3.0]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.2...v0.3.0
66
+ [0.2.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.1...v0.2.2
49
67
  [0.2.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.1...v0.2.1
50
68
  [0.1.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.0...v0.1.1
51
69
  [0.1.0]: https://github.com/thatsrajan/pplx-cli/releases/tag/v0.1.0
package/README.md CHANGED
@@ -101,6 +101,7 @@ pplx search "what is quantum computing"
101
101
  pplx reason "explain the Riemann hypothesis"
102
102
  pplx research "compare React vs Vue in 2026"
103
103
  pplx labs "hello world" # free, no auth needed
104
+ pplx computer new "compare dinner options nearby"
104
105
  pplx models # list available models
105
106
  ```
106
107
 
@@ -127,6 +128,8 @@ pplx search "query" | head -1
127
128
  pplx search "query" --json || echo "failed"
128
129
  ```
129
130
 
131
+ Deep Research is slower than normal search. `pplx research` defaults to a 10-minute stream timeout; override it per run with `--timeout-ms 600000`, `--timeout-ms 120s`, or `--timeout-ms 10m`.
132
+
130
133
  Recommended agent invocation:
131
134
 
132
135
  ```bash
@@ -143,12 +146,46 @@ pplx search "research this topic" --json --raw --mode pro
143
146
  ],
144
147
  "query": "...",
145
148
  "mode": "pro",
146
- "model": "..."
149
+ "model": "...",
150
+ "artifactDir": "/Users/you/.config/pplx/artifacts/...",
151
+ "artifactId": "..."
147
152
  }
148
153
  ```
149
154
 
150
155
  ---
151
156
 
157
+ ## Artifacts
158
+
159
+ Query-producing commands save artifacts by default:
160
+
161
+ - `pplx search`
162
+ - `pplx reason`
163
+ - `pplx research`
164
+ - `pplx labs`
165
+ - bare `pplx "query"`
166
+ - `pplx computer new`
167
+
168
+ Each run gets a folder containing `meta.json`, `query.txt`, `answer.md`, `result.json`, and `sources.json`. Use `--out <dir>` to choose the destination for one run, or set `"artifactDir"` in `~/.config/pplx/config.json` to make it persistent. Use `--artifact-id <id>` when an agent needs a deterministic run folder, and `--no-artifact` to disable saving for one search-style run.
169
+
170
+ ```bash
171
+ pplx search "compare laptops under $1500" --out ~/Dropbox/pplx-runs
172
+ pplx research "best places to stay in Kyoto" --artifact-id kyoto-research
173
+ pplx search "what is 2+2" --no-artifact --json --raw
174
+ ```
175
+
176
+ `pplx computer` is an artifact handoff for Perplexity Computer. It creates a task prompt and a result contract without calling private Computer APIs:
177
+
178
+ ```bash
179
+ pplx computer new "compare dinner options nearby" --out ~/Dropbox/pplx-runs
180
+ pplx computer open <run-id> --copy
181
+ pplx computer status <run-id> --out ~/Dropbox/pplx-runs
182
+ pplx computer import <run-id> --out ~/Dropbox/pplx-runs --json
183
+ ```
184
+
185
+ Computer runs include `task.md`, `result.schema.json`, and `computer-result.json`. Paste `task.md` into Perplexity Computer; when the task is done, place the structured result in `computer-result.json` so local agents can read it.
186
+
187
+ ---
188
+
152
189
  ## Options
153
190
 
154
191
  | Flag | Description |
@@ -160,6 +197,10 @@ pplx search "research this topic" --json --raw --mode pro
160
197
  | `--chrome` | Use Chrome CDP bridge instead of HTTP |
161
198
  | `--playwright` | Use Playwright headless Chromium |
162
199
  | `--no-playwright` | Force HTTP transport even if config enables Playwright |
200
+ | `--timeout-ms 120000\|120s\|10m` | Overall stream timeout |
201
+ | `--out <dir>` | Directory for saved artifacts |
202
+ | `--artifact-id <id>` | Deterministic artifact id for this run |
203
+ | `--no-artifact` | Disable artifact saving for one search-style run |
163
204
  | `--curl` | Force curl-impersonate (auto-downloads if missing) |
164
205
  | `--allow-anonymous` | Allow anonymous Perplexity responses when cookies are expired |
165
206
  | `--incognito` | Do not save the query to Perplexity history |
@@ -190,7 +231,8 @@ Optional config file at `~/.config/pplx/config.json`:
190
231
  "model": "claude-3.5-sonnet",
191
232
  "lang": "en-US",
192
233
  "playwright": true,
193
- "playwrightHeadless": false
234
+ "playwrightHeadless": false,
235
+ "artifactDir": "/Users/you/Dropbox/pplx-runs"
194
236
  }
195
237
  ```
196
238
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pplx-npx-search",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for Perplexity AI with cookie-based auth. Headless, agent-friendly, no API key required.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pplx": "./bin/pplx.js"
7
+ "pplx": "bin/pplx.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/pplx.js",
@@ -0,0 +1,85 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CONFIG_DIR } from './constants.js';
6
+
7
+ export const DEFAULT_ARTIFACT_DIR = join(CONFIG_DIR, 'artifacts');
8
+ export const ARTIFACT_SCHEMA_VERSION = 1;
9
+
10
+ function expandHome(value) {
11
+ if (!value) return value;
12
+ if (value === '~') return homedir();
13
+ if (value.startsWith('~/')) return join(homedir(), value.slice(2));
14
+ return value;
15
+ }
16
+
17
+ export function resolveArtifactDir({ out, config = {} } = {}) {
18
+ const selected = out || config.artifactDir || DEFAULT_ARTIFACT_DIR;
19
+ const expanded = expandHome(selected);
20
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
21
+ }
22
+
23
+ export function createArtifactId(id) {
24
+ if (id) {
25
+ if (!/^[A-Za-z0-9._-]+$/.test(id) || id === '.' || id === '..') {
26
+ throw new Error('artifact id may only contain letters, numbers, dots, underscores, and hyphens');
27
+ }
28
+ return id;
29
+ }
30
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
31
+ return `${stamp}-${randomUUID().slice(0, 8)}`;
32
+ }
33
+
34
+ export function makeArtifactContext({ command, query, opts = {}, config = {} }) {
35
+ if (opts.artifact === false) return null;
36
+ const artifactId = createArtifactId(opts.artifactId);
37
+ const artifactRoot = resolveArtifactDir({ out: opts.out, config });
38
+ const artifactDir = join(artifactRoot, artifactId);
39
+ return { command, query, artifactId, artifactRoot, artifactDir };
40
+ }
41
+
42
+ export function writeJson(file, value) {
43
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
44
+ }
45
+
46
+ export function writeStandardArtifact(ctx, payload) {
47
+ if (!ctx) return null;
48
+ mkdirSync(ctx.artifactDir, { recursive: true });
49
+
50
+ const createdAt = payload.createdAt || new Date().toISOString();
51
+ const sources = payload.sources || [];
52
+ const result = {
53
+ query: ctx.query,
54
+ answer: payload.answer || '',
55
+ sources,
56
+ command: ctx.command,
57
+ mode: payload.mode || null,
58
+ model: payload.model || null,
59
+ artifactId: ctx.artifactId,
60
+ artifactDir: ctx.artifactDir,
61
+ createdAt,
62
+ };
63
+ const meta = {
64
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
65
+ command: ctx.command,
66
+ query: ctx.query,
67
+ mode: payload.mode || null,
68
+ model: payload.model || null,
69
+ artifactId: ctx.artifactId,
70
+ artifactDir: ctx.artifactDir,
71
+ createdAt,
72
+ status: payload.status || 'complete',
73
+ };
74
+
75
+ writeJson(join(ctx.artifactDir, 'meta.json'), meta);
76
+ writeFileSync(join(ctx.artifactDir, 'query.txt'), `${ctx.query}\n`, 'utf8');
77
+ writeFileSync(join(ctx.artifactDir, 'answer.md'), payload.answer || '', 'utf8');
78
+ writeJson(join(ctx.artifactDir, 'result.json'), result);
79
+ writeJson(join(ctx.artifactDir, 'sources.json'), sources);
80
+ return { artifactId: ctx.artifactId, artifactDir: ctx.artifactDir };
81
+ }
82
+
83
+ export function readJsonFile(file) {
84
+ return JSON.parse(readFileSync(file, 'utf8'));
85
+ }
package/src/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
1
3
  import { program } from 'commander';
2
4
  import chalk from 'chalk';
3
5
  import ora from 'ora';
@@ -10,6 +12,18 @@ import { formatSources } from './format.js';
10
12
  import { LABS_MODELS, MODEL_MAP } from './constants.js';
11
13
  import { setUseCurl } from './http.js';
12
14
  import { loadConfig } from './config.js';
15
+ import { resolveTimeoutMs } from './timeout.js';
16
+ import { makeArtifactContext, resolveArtifactDir, writeStandardArtifact } from './artifacts.js';
17
+ import {
18
+ createComputerRun,
19
+ copyTextToClipboard,
20
+ importComputerResult,
21
+ inspectComputerRun,
22
+ openComputerUrl,
23
+ readTaskFile,
24
+ } from './computer.js';
25
+
26
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
27
 
14
28
  // --- Output state ---
15
29
  let rawMode = false;
@@ -84,11 +98,14 @@ async function extractAndValidateBrowser(browser, profile) {
84
98
  program
85
99
  .name('pplx')
86
100
  .description('CLI for Perplexity AI')
87
- .version('0.1.1');
101
+ .version(pkg.version);
88
102
 
89
103
  program.option('--verbose', 'Enable verbose logging');
90
104
  program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
91
105
  program.option('--raw', 'Plain text output, no colors, no spinner');
106
+ program.option('--out <dir>', 'Directory for saved artifacts');
107
+ program.option('--no-artifact', 'Disable artifact saving for this run');
108
+ program.option('--artifact-id <id>', 'Deterministic artifact id for this run');
92
109
 
93
110
  program.hook('preAction', (thisCmd) => {
94
111
  const gopts = thisCmd.optsWithGlobals ? thisCmd.optsWithGlobals() : thisCmd.opts();
@@ -103,6 +120,35 @@ program.hook('preAction', (thisCmd) => {
103
120
  }
104
121
  });
105
122
 
123
+ function getOpts(commandOrOpts) {
124
+ const globals = program.opts();
125
+ const locals = commandOrOpts.optsWithGlobals
126
+ ? { ...commandOrOpts.optsWithGlobals(), ...commandOrOpts.opts() }
127
+ : commandOrOpts;
128
+ const merged = { ...globals, ...locals };
129
+ if (globals.artifact === false || locals.artifact === false) merged.artifact = false;
130
+ return merged;
131
+ }
132
+
133
+ function addArtifactOptions(command, { allowDisable = true } = {}) {
134
+ command
135
+ .option('--out <dir>', 'Directory for saved artifacts')
136
+ .option('--artifact-id <id>', 'Deterministic artifact id for this run');
137
+ if (allowDisable) command.option('--no-artifact', 'Disable artifact saving for this run');
138
+ return command;
139
+ }
140
+
141
+ function maybePrintArtifactInfo(info, opts) {
142
+ if (!info || opts.json || rawMode) return;
143
+ console.log(chalk.dim(`\nArtifact: ${info.artifactDir}`));
144
+ }
145
+
146
+ function resolveRunDir(runId, opts = {}) {
147
+ if (isAbsolute(runId) || runId.includes('/')) return resolve(runId);
148
+ const cfg = loadConfig();
149
+ return join(resolveArtifactDir({ out: opts.out, config: cfg }), runId);
150
+ }
151
+
106
152
  // Auth command
107
153
  program
108
154
  .command('auth')
@@ -255,9 +301,9 @@ program
255
301
  });
256
302
 
257
303
  // Shared search logic
258
- async function doSearch(query, opts) {
304
+ async function doSearch(query, opts, commandName = 'search') {
259
305
  const cfg = loadConfig();
260
- opts = { ...cfg, ...opts };
306
+ opts = { ...cfg, ...getOpts(opts) };
261
307
  if (opts.curl) setUseCurl(true);
262
308
 
263
309
  const cookies = loadCookies() || {};
@@ -274,8 +320,22 @@ async function doSearch(query, opts) {
274
320
  }
275
321
 
276
322
  const mode = opts.mode || 'pro';
323
+ let timeoutMs;
324
+ try {
325
+ timeoutMs = resolveTimeoutMs({ ...opts, mode });
326
+ } catch (e) {
327
+ console.error(chalk.red(e.message));
328
+ process.exit(1);
329
+ }
277
330
  const sources = opts.sources ? opts.sources.split(',') : ['web'];
278
331
  const lang = opts.lang || 'en-US';
332
+ let artifactCtx = null;
333
+ try {
334
+ artifactCtx = makeArtifactContext({ command: commandName, query, opts, config: cfg });
335
+ } catch (e) {
336
+ console.error(chalk.red(e.message));
337
+ process.exit(1);
338
+ }
279
339
 
280
340
  try {
281
341
  let lastAnswer = '';
@@ -291,6 +351,7 @@ async function doSearch(query, opts) {
291
351
  chrome: opts.chrome,
292
352
  playwright: opts.playwright,
293
353
  curl: opts.curl,
354
+ timeoutMs,
294
355
  })) {
295
356
  lastData = data;
296
357
 
@@ -313,12 +374,21 @@ async function doSearch(query, opts) {
313
374
  // Output single final JSON object
314
375
  const answer = lastData?.answer || lastAnswer || '';
315
376
  const webResults = lastData?.web_results || [];
377
+ const normalizedSources = webResults.map(r => ({ title: r.name || r.title, url: r.url }));
378
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
379
+ answer,
380
+ sources: normalizedSources,
381
+ mode,
382
+ model: opts.model || 'default',
383
+ });
316
384
  const jsonOut = {
317
385
  answer,
318
- sources: webResults.map(r => ({ title: r.name || r.title, url: r.url })),
386
+ sources: normalizedSources,
319
387
  query,
320
388
  mode,
321
389
  model: opts.model || 'default',
390
+ artifactDir: artifactInfo?.artifactDir,
391
+ artifactId: artifactInfo?.artifactId,
322
392
  };
323
393
  console.log(JSON.stringify(jsonOut));
324
394
  if (!answer) process.exit(1);
@@ -335,6 +405,14 @@ async function doSearch(query, opts) {
335
405
  if (!rawMode && opts.citations !== false && lastData?.web_results) {
336
406
  console.log(formatSources(lastData.web_results, { full: opts.citationsFull }));
337
407
  }
408
+ const webResults = lastData?.web_results || [];
409
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
410
+ answer: lastAnswer,
411
+ sources: webResults.map(r => ({ title: r.name || r.title, url: r.url })),
412
+ mode,
413
+ model: opts.model || 'default',
414
+ });
415
+ maybePrintArtifactInfo(artifactInfo, opts);
338
416
  } catch (e) {
339
417
  console.error(chalk.red('\nError:'), e.message);
340
418
  if (e.message.includes('403')) {
@@ -345,7 +423,7 @@ async function doSearch(query, opts) {
345
423
  }
346
424
 
347
425
  // Search command
348
- program
426
+ addArtifactOptions(program
349
427
  .command('search [query]')
350
428
  .description('Search with Perplexity (default: pro mode)')
351
429
  .option('-m, --mode <mode>', 'Search mode: auto, pro, reasoning, deep-research', 'pro')
@@ -361,15 +439,17 @@ program
361
439
  .option('--chrome', 'Use Chrome CDP bridge instead of HTTP')
362
440
  .option('--playwright', 'Use Playwright headless Chromium instead of HTTP')
363
441
  .option('--no-playwright', 'Disable Playwright even if config enables it')
364
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
442
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
443
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
365
444
  .action(async (queryArg, opts) => {
445
+ opts = getOpts(opts);
366
446
  if (opts.raw) { rawMode = true; chalk.level = 0; }
367
447
  const query = await resolveQuery(queryArg);
368
- await doSearch(query, opts);
448
+ await doSearch(query, opts, 'search');
369
449
  });
370
450
 
371
451
  // Shorthand: reason
372
- program
452
+ addArtifactOptions(program
373
453
  .command('reason [query]')
374
454
  .description('Reasoning mode search')
375
455
  .option('--model <model>', 'Model name')
@@ -378,14 +458,16 @@ program
378
458
  .option('--chrome', 'Use Chrome CDP bridge')
379
459
  .option('--playwright', 'Use Playwright headless Chromium')
380
460
  .option('--no-playwright', 'Disable Playwright even if config enables it')
381
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
461
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
462
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
382
463
  .action(async (queryArg, opts) => {
464
+ opts = getOpts(opts);
383
465
  const query = await resolveQuery(queryArg);
384
- await doSearch(query, { ...opts, mode: 'reasoning' });
466
+ await doSearch(query, { ...opts, mode: 'reasoning' }, 'reason');
385
467
  });
386
468
 
387
469
  // Shorthand: research
388
- program
470
+ addArtifactOptions(program
389
471
  .command('research [query]')
390
472
  .description('Deep research mode')
391
473
  .option('--json', 'Output raw JSON')
@@ -393,12 +475,14 @@ program
393
475
  .option('--chrome', 'Use Chrome CDP bridge')
394
476
  .option('--playwright', 'Use Playwright headless Chromium')
395
477
  .option('--no-playwright', 'Disable Playwright even if config enables it')
396
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
478
+ .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
479
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
397
480
  .action(async (queryArg, opts) => {
481
+ opts = getOpts(opts);
398
482
  const query = await resolveQuery(queryArg);
399
483
  const spinner = makeSpinner('Deep research in progress...').start();
400
484
  try {
401
- await doSearch(query, { ...opts, mode: 'deep-research', _spinner: spinner });
485
+ await doSearch(query, { ...opts, mode: 'deep-research', _spinner: spinner }, 'research');
402
486
  } catch (e) {
403
487
  spinner.fail(e.message);
404
488
  process.exit(1);
@@ -406,13 +490,22 @@ program
406
490
  });
407
491
 
408
492
  // Labs command
409
- program
493
+ addArtifactOptions(program
410
494
  .command('labs [query]')
411
495
  .description('Query open-source models (no auth needed)')
412
496
  .option('--model <model>', `Model: ${LABS_MODELS.join(', ')}`, 'sonar')
413
- .option('--json', 'Output raw JSON')
497
+ .option('--json', 'Output single JSON object with answer, events, and artifact metadata'))
414
498
  .action(async (queryArg, opts) => {
499
+ opts = getOpts(opts);
500
+ const cfg = loadConfig();
415
501
  const query = await resolveQuery(queryArg);
502
+ let artifactCtx = null;
503
+ try {
504
+ artifactCtx = makeArtifactContext({ command: 'labs', query, opts, config: cfg });
505
+ } catch (e) {
506
+ console.error(chalk.red(e.message));
507
+ process.exit(1);
508
+ }
416
509
  const spinner = makeSpinner('Connecting to labs...').start();
417
510
  const client = new LabsClient();
418
511
  try {
@@ -420,9 +513,10 @@ program
420
513
  spinner.stop();
421
514
 
422
515
  let lastOutput = '';
516
+ const events = [];
423
517
  for await (const data of client.ask(query, opts.model)) {
518
+ events.push(data);
424
519
  if (opts.json) {
425
- console.log(JSON.stringify(data));
426
520
  continue;
427
521
  }
428
522
  const output = data.output || '';
@@ -432,14 +526,129 @@ program
432
526
  }
433
527
  }
434
528
  if (!opts.json) process.stdout.write('\n');
529
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
530
+ answer: lastOutput,
531
+ sources: [],
532
+ mode: 'labs',
533
+ model: opts.model,
534
+ });
535
+ if (opts.json) {
536
+ console.log(JSON.stringify({
537
+ answer: lastOutput,
538
+ events,
539
+ query,
540
+ mode: 'labs',
541
+ model: opts.model,
542
+ artifactDir: artifactInfo?.artifactDir,
543
+ artifactId: artifactInfo?.artifactId,
544
+ }));
545
+ } else {
546
+ maybePrintArtifactInfo(artifactInfo, opts);
547
+ }
435
548
  } catch (e) {
436
549
  spinner.fail('Labs error: ' + e.message);
550
+ if (isQuiet()) console.error(chalk.red('Labs error:'), e.message);
437
551
  process.exit(1);
438
552
  } finally {
439
553
  client.close();
440
554
  }
441
555
  });
442
556
 
557
+ // Computer artifact handoff workflow
558
+ const computer = program
559
+ .command('computer')
560
+ .description('Create and manage Perplexity Computer artifact handoffs');
561
+
562
+ addArtifactOptions(computer
563
+ .command('new [task]')
564
+ .description('Create a Perplexity Computer task artifact')
565
+ .option('--template <name>', 'Computer task template', 'compare')
566
+ .option('--json', 'Output run metadata as JSON'), { allowDisable: false })
567
+ .action(async (taskArg, opts) => {
568
+ opts = getOpts(opts);
569
+ const task = await resolveQuery(taskArg);
570
+ try {
571
+ const run = createComputerRun({
572
+ task,
573
+ template: opts.template,
574
+ opts,
575
+ config: loadConfig(),
576
+ });
577
+ if (opts.json) {
578
+ console.log(JSON.stringify(run));
579
+ return;
580
+ }
581
+ console.log(chalk.green(`✓ Computer task artifact created: ${run.artifactDir}`));
582
+ console.log(chalk.dim(` Task: ${run.taskPath}`));
583
+ console.log(chalk.dim(` Result: ${run.resultPath}`));
584
+ } catch (e) {
585
+ console.error(chalk.red(e.message));
586
+ process.exit(1);
587
+ }
588
+ });
589
+
590
+ addArtifactOptions(computer
591
+ .command('open <run>')
592
+ .description('Open Perplexity Computer and optionally copy task.md')
593
+ .option('--copy', 'Copy task.md to the clipboard'), { allowDisable: false })
594
+ .action((runId, opts) => {
595
+ opts = getOpts(opts);
596
+ const runDir = resolveRunDir(runId, opts);
597
+ try {
598
+ if (opts.copy) {
599
+ const taskText = readTaskFile(runDir);
600
+ if (!copyTextToClipboard(taskText)) {
601
+ console.log(chalk.yellow('Clipboard copy is only supported on macOS.'));
602
+ }
603
+ }
604
+ if (!openComputerUrl()) {
605
+ console.log(chalk.yellow('Opening Perplexity Computer is only supported on macOS.'));
606
+ console.log('https://www.perplexity.ai/computer');
607
+ return;
608
+ }
609
+ console.log(chalk.green(`✓ Opened Perplexity Computer for ${runDir}`));
610
+ if (opts.copy) console.log(chalk.dim(' Copied task.md to clipboard.'));
611
+ } catch (e) {
612
+ console.error(chalk.red(e.message));
613
+ process.exit(1);
614
+ }
615
+ });
616
+
617
+ addArtifactOptions(computer
618
+ .command('status <run>')
619
+ .description('Inspect a Perplexity Computer artifact run')
620
+ .option('--json', 'Output status as JSON'), { allowDisable: false })
621
+ .action((runId, opts) => {
622
+ opts = getOpts(opts);
623
+ const status = inspectComputerRun(resolveRunDir(runId, opts));
624
+ if (opts.json) {
625
+ console.log(JSON.stringify(status));
626
+ return;
627
+ }
628
+ const label = status.status === 'complete'
629
+ ? chalk.green('[✓] Complete')
630
+ : status.status === 'pending'
631
+ ? chalk.yellow('[○] Pending')
632
+ : chalk.red(status.status === 'invalid' ? '[!] Invalid' : '[✗] Missing');
633
+ console.log(`${label} ${status.artifactDir}`);
634
+ if (status.reason) console.log(chalk.dim(` ${status.reason}`));
635
+ });
636
+
637
+ addArtifactOptions(computer
638
+ .command('import <run>')
639
+ .description('Print a completed Perplexity Computer result')
640
+ .option('--json', 'Output compact JSON'), { allowDisable: false })
641
+ .action((runId, opts) => {
642
+ opts = getOpts(opts);
643
+ try {
644
+ const result = importComputerResult(resolveRunDir(runId, opts));
645
+ console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
646
+ } catch (e) {
647
+ console.error(chalk.red(e.message));
648
+ process.exit(1);
649
+ }
650
+ });
651
+
443
652
  // Models command
444
653
  program
445
654
  .command('models')
@@ -457,9 +666,9 @@ program
457
666
  // Default: treat bare args as search
458
667
  program
459
668
  .argument('[query...]', 'Quick search (shorthand for pplx search)')
460
- .action(async (query) => {
669
+ .action(async (query, opts) => {
461
670
  if (query.length > 0) {
462
- await doSearch(query.join(' '), {});
671
+ await doSearch(query.join(' '), getOpts(opts || program), 'search');
463
672
  }
464
673
  });
465
674
 
@@ -0,0 +1,193 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import {
5
+ ARTIFACT_SCHEMA_VERSION,
6
+ makeArtifactContext,
7
+ readJsonFile,
8
+ writeJson,
9
+ } from './artifacts.js';
10
+
11
+ export const COMPUTER_URL = 'https://www.perplexity.ai/computer';
12
+ export const COMPUTER_RESULT_FILE = 'computer-result.json';
13
+ export const PENDING_COMPUTER_RESULT = {
14
+ summary: '',
15
+ winner: '',
16
+ confidence: 'low',
17
+ items: [],
18
+ sources: [],
19
+ checked_at: '',
20
+ notes: [],
21
+ _status: 'pending',
22
+ };
23
+
24
+ const RESULT_SCHEMA = {
25
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
26
+ title: 'pplx computer comparison result',
27
+ type: 'object',
28
+ required: ['summary', 'winner', 'confidence', 'items', 'sources', 'checked_at', 'notes'],
29
+ properties: {
30
+ summary: { type: 'string' },
31
+ winner: { type: 'string' },
32
+ confidence: { enum: ['low', 'medium', 'high'] },
33
+ items: { type: 'array' },
34
+ sources: { type: 'array' },
35
+ checked_at: { type: 'string' },
36
+ notes: { type: 'array' },
37
+ },
38
+ };
39
+
40
+ export function buildComputerTask({ task, template = 'compare', resultPath }) {
41
+ if (template !== 'compare') {
42
+ throw new Error(`unsupported computer template: ${template}`);
43
+ }
44
+
45
+ return `# Perplexity Computer Task
46
+
47
+ You are running a live comparison task for a local agent workflow.
48
+
49
+ ## User request
50
+
51
+ ${task}
52
+
53
+ ## Instructions
54
+
55
+ - Use live web pages and direct source pages where possible, not only search snippets.
56
+ - Compare any relevant prices, fees, availability, location, timing, quality signals, eligibility rules, and constraints.
57
+ - This template is intentionally broad: it can cover products, real estate, restaurants, food prices, travel, rewards portals, services, and other comparison tasks.
58
+ - Preserve source URLs for every material claim.
59
+ - Include the time the information was checked.
60
+ - Mark uncertainty explicitly. Do not invent missing values.
61
+ - Prefer structured evidence over prose.
62
+
63
+ ## Output target
64
+
65
+ Write the final result as JSON matching \`result.schema.json\`.
66
+
67
+ If you can access the local filesystem, save it here:
68
+
69
+ \`${resultPath}\`
70
+
71
+ If you cannot access the filesystem, return the JSON in the chat so the local agent or user can place it in that file.
72
+ `;
73
+ }
74
+
75
+ export function createComputerRun({ task, template = 'compare', opts = {}, config = {} }) {
76
+ const ctx = makeArtifactContext({ command: 'computer', query: task, opts, config });
77
+ if (!ctx) {
78
+ throw new Error('computer runs require artifacts; omit --no-artifact for this command');
79
+ }
80
+
81
+ mkdirSync(ctx.artifactDir, { recursive: true });
82
+ const createdAt = new Date().toISOString();
83
+ const resultPath = join(ctx.artifactDir, COMPUTER_RESULT_FILE);
84
+ const taskText = buildComputerTask({ task, template, resultPath });
85
+ const meta = {
86
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
87
+ command: 'computer',
88
+ query: task,
89
+ template,
90
+ artifactId: ctx.artifactId,
91
+ artifactDir: ctx.artifactDir,
92
+ createdAt,
93
+ status: 'pending',
94
+ };
95
+ const result = {
96
+ query: task,
97
+ answer: '',
98
+ sources: [],
99
+ command: 'computer',
100
+ mode: 'computer',
101
+ model: null,
102
+ artifactId: ctx.artifactId,
103
+ artifactDir: ctx.artifactDir,
104
+ createdAt,
105
+ status: 'pending',
106
+ computerResultFile: COMPUTER_RESULT_FILE,
107
+ };
108
+
109
+ writeJson(join(ctx.artifactDir, 'meta.json'), meta);
110
+ writeFileSync(join(ctx.artifactDir, 'query.txt'), `${task}\n`, 'utf8');
111
+ writeFileSync(join(ctx.artifactDir, 'answer.md'), taskText, 'utf8');
112
+ writeJson(join(ctx.artifactDir, 'result.json'), result);
113
+ writeJson(join(ctx.artifactDir, 'sources.json'), []);
114
+ writeFileSync(join(ctx.artifactDir, 'task.md'), taskText, 'utf8');
115
+ writeJson(join(ctx.artifactDir, 'result.schema.json'), RESULT_SCHEMA);
116
+ writeJson(resultPath, PENDING_COMPUTER_RESULT);
117
+
118
+ return {
119
+ artifactId: ctx.artifactId,
120
+ artifactDir: ctx.artifactDir,
121
+ taskPath: join(ctx.artifactDir, 'task.md'),
122
+ resultPath,
123
+ };
124
+ }
125
+
126
+ function assertComputerResult(value) {
127
+ const missing = ['summary', 'winner', 'confidence', 'items', 'sources', 'checked_at', 'notes']
128
+ .filter((key) => !(key in value));
129
+ if (missing.length) return { ok: false, reason: `missing fields: ${missing.join(', ')}` };
130
+ if (!['low', 'medium', 'high'].includes(value.confidence)) {
131
+ return { ok: false, reason: 'confidence must be low, medium, or high' };
132
+ }
133
+ if (!Array.isArray(value.items)) return { ok: false, reason: 'items must be an array' };
134
+ if (!Array.isArray(value.sources)) return { ok: false, reason: 'sources must be an array' };
135
+ if (!Array.isArray(value.notes)) return { ok: false, reason: 'notes must be an array' };
136
+ return { ok: true, reason: null };
137
+ }
138
+
139
+ export function inspectComputerRun(runDir) {
140
+ const artifactDir = resolve(runDir);
141
+ const resultPath = join(artifactDir, COMPUTER_RESULT_FILE);
142
+ const metaPath = join(artifactDir, 'meta.json');
143
+ if (!existsSync(metaPath)) {
144
+ return { status: 'missing', artifactDir, resultPath, reason: 'meta.json not found' };
145
+ }
146
+ if (!existsSync(resultPath)) {
147
+ return { status: 'pending', artifactDir, resultPath, reason: `${COMPUTER_RESULT_FILE} not found` };
148
+ }
149
+ try {
150
+ const result = readJsonFile(resultPath);
151
+ if (result._status === 'pending') {
152
+ return { status: 'pending', artifactDir, resultPath, reason: `${COMPUTER_RESULT_FILE} is still pending` };
153
+ }
154
+ const validation = assertComputerResult(result);
155
+ return {
156
+ status: validation.ok ? 'complete' : 'invalid',
157
+ artifactDir,
158
+ resultPath,
159
+ reason: validation.reason,
160
+ result,
161
+ };
162
+ } catch (e) {
163
+ return { status: 'invalid', artifactDir, resultPath, reason: e.message };
164
+ }
165
+ }
166
+
167
+ export function importComputerResult(runDir) {
168
+ const status = inspectComputerRun(runDir);
169
+ if (status.status !== 'complete') {
170
+ throw new Error(`computer result is ${status.status}: ${status.reason}`);
171
+ }
172
+ return status.result;
173
+ }
174
+
175
+ export function copyTextToClipboard(text) {
176
+ if (process.platform === 'darwin') {
177
+ execFileSync('pbcopy', { input: text });
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ export function openComputerUrl() {
184
+ if (process.platform === 'darwin') {
185
+ execFileSync('open', [COMPUTER_URL]);
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+
191
+ export function readTaskFile(runDir) {
192
+ return readFileSync(join(resolve(runDir), 'task.md'), 'utf8');
193
+ }
package/src/labs.js CHANGED
@@ -45,6 +45,9 @@ export class LabsClient {
45
45
  headers: { 'user-agent': HEADERS['user-agent'] },
46
46
  });
47
47
  const pollText = await pollResp.text();
48
+ if (!pollResp.ok) {
49
+ throw new Error(`Labs polling failed (${pollResp.status}): ${pollText.slice(0, 120)}`);
50
+ }
48
51
 
49
52
  const handshake = parseEngineIO(pollText);
50
53
  this.sid = handshake.sid;
package/src/search.js CHANGED
@@ -126,6 +126,7 @@ async function* searchWithChrome(query, cookies, opts) {
126
126
  method: 'POST',
127
127
  headers: { 'content-type': 'application/json' },
128
128
  body,
129
+ timeout: opts.timeoutMs,
129
130
  }
130
131
  )) {
131
132
  parser.feed(chunk);
@@ -177,6 +178,7 @@ async function* searchWithPlaywright(query, cookies, opts) {
177
178
  method: 'POST',
178
179
  headers: { 'content-type': 'application/json' },
179
180
  body,
181
+ timeout: opts.timeoutMs,
180
182
  }
181
183
  )) {
182
184
  parser.feed(chunk);
@@ -219,6 +221,7 @@ async function* searchWithHttp(query, cookies, opts) {
219
221
  'cookie': cookieHeader(sessionCookies),
220
222
  },
221
223
  body,
224
+ timeout: opts.timeoutMs,
222
225
  });
223
226
 
224
227
  if (!resp.ok) {
package/src/timeout.js ADDED
@@ -0,0 +1,40 @@
1
+ export const DEFAULT_SEARCH_TIMEOUT_MS = 120000;
2
+ export const DEFAULT_RESEARCH_TIMEOUT_MS = 600000;
3
+
4
+ export function parseTimeoutMs(value, label = 'timeout') {
5
+ if (value == null || value === '') return null;
6
+
7
+ if (typeof value === 'number') {
8
+ if (Number.isFinite(value) && value > 0) return Math.trunc(value);
9
+ throw new Error(`${label} must be a positive number of milliseconds`);
10
+ }
11
+
12
+ const text = String(value).trim().toLowerCase();
13
+ const match = text.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
14
+ if (!match) {
15
+ throw new Error(`${label} must be a positive duration like 120000, 120s, or 10m`);
16
+ }
17
+
18
+ const amount = Number(match[1]);
19
+ const unit = match[2] ?? 'ms';
20
+ const multipliers = { ms: 1, s: 1000, m: 60000 };
21
+ const timeoutMs = amount * multipliers[unit];
22
+
23
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
24
+ throw new Error(`${label} must be a positive duration`);
25
+ }
26
+
27
+ return Math.trunc(timeoutMs);
28
+ }
29
+
30
+ export function resolveTimeoutMs(opts = {}) {
31
+ const explicit = parseTimeoutMs(opts.timeoutMs, '--timeout-ms');
32
+ if (explicit != null) return explicit;
33
+
34
+ const configured = parseTimeoutMs(opts.timeout, 'config timeout');
35
+ if (configured != null) return configured;
36
+
37
+ return opts.mode === 'deep-research'
38
+ ? DEFAULT_RESEARCH_TIMEOUT_MS
39
+ : DEFAULT_SEARCH_TIMEOUT_MS;
40
+ }