@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.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.
Files changed (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -1,14 +1,28 @@
1
1
  import { $env, ptree } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
- import { buildResult, loadPage } from "./types";
3
+ import { buildResult, formatMediaDuration, loadPage } from "./types";
4
4
 
5
5
  interface GitHubUrl {
6
- type: "blob" | "tree" | "repo" | "issue" | "issues" | "pull" | "pulls" | "discussion" | "discussions" | "other";
6
+ type:
7
+ | "blob"
8
+ | "tree"
9
+ | "repo"
10
+ | "issue"
11
+ | "issues"
12
+ | "pull"
13
+ | "pulls"
14
+ | "discussion"
15
+ | "discussions"
16
+ | "actions-run"
17
+ | "actions-job"
18
+ | "other";
7
19
  owner: string;
8
20
  repo: string;
9
21
  ref?: string;
10
22
  path?: string;
11
23
  number?: number;
24
+ runId?: number;
25
+ jobId?: number;
12
26
  }
13
27
 
14
28
  interface GitHubIssueComment {
@@ -20,7 +34,7 @@ interface GitHubIssueComment {
20
34
  /**
21
35
  * Parse GitHub URL into components
22
36
  */
23
- function parseGitHubUrl(url: string): GitHubUrl | null {
37
+ export function parseGitHubUrl(url: string): GitHubUrl | null {
24
38
  try {
25
39
  const parsed = new URL(url);
26
40
  if (parsed.hostname !== "github.com") return null;
@@ -54,6 +68,20 @@ function parseGitHubUrl(url: string): GitHubUrl | null {
54
68
  return { type: "pulls", owner, repo };
55
69
  case "pulls":
56
70
  return { type: "pulls", owner, repo };
71
+ case "actions": {
72
+ // /actions/runs/{runId} → run summary + jobs
73
+ // /actions/runs/{runId}/job/{jobId} → single job (web URL uses singular "job")
74
+ // /actions/runs/{runId}/jobs/{jobId} → single job (API-style plural)
75
+ if (subParts[0] === "runs" && /^\d+$/.test(subParts[1] ?? "")) {
76
+ const runId = parseInt(subParts[1], 10);
77
+ const seg = subParts[2];
78
+ if ((seg === "job" || seg === "jobs") && /^\d+$/.test(subParts[3] ?? "")) {
79
+ return { type: "actions-job", owner, repo, runId, jobId: parseInt(subParts[3], 10) };
80
+ }
81
+ return { type: "actions-run", owner, repo, runId };
82
+ }
83
+ return { type: "other", owner, repo };
84
+ }
57
85
  case "discussions":
58
86
  if (subParts.length > 0 && /^\d+$/.test(subParts[0])) {
59
87
  return { type: "discussion", owner, repo, number: parseInt(subParts[0], 10) };
@@ -371,6 +399,212 @@ async function renderGitHubRepo(
371
399
  return { content: md, ok: true };
372
400
  }
373
401
 
402
+ interface GitHubActionsStep {
403
+ name: string;
404
+ status: string;
405
+ conclusion: string | null;
406
+ number: number;
407
+ started_at: string | null;
408
+ completed_at: string | null;
409
+ }
410
+
411
+ interface GitHubActionsJob {
412
+ id: number;
413
+ run_id: number;
414
+ name: string;
415
+ status: string;
416
+ conclusion: string | null;
417
+ started_at: string | null;
418
+ completed_at: string | null;
419
+ html_url: string | null;
420
+ steps?: GitHubActionsStep[];
421
+ runner_name?: string | null;
422
+ labels?: string[];
423
+ workflow_name?: string | null;
424
+ head_branch?: string | null;
425
+ head_sha?: string;
426
+ }
427
+
428
+ interface GitHubActionsRun {
429
+ id: number;
430
+ name?: string | null;
431
+ display_title?: string;
432
+ run_number: number;
433
+ run_attempt?: number;
434
+ event: string;
435
+ status: string;
436
+ conclusion: string | null;
437
+ head_branch?: string | null;
438
+ head_sha?: string;
439
+ html_url: string;
440
+ created_at: string;
441
+ updated_at: string;
442
+ run_started_at?: string;
443
+ actor?: { login: string };
444
+ triggering_actor?: { login: string };
445
+ }
446
+
447
+ /** Combine status + conclusion into a single label, e.g. `completed (failure)`. */
448
+ function statusLabel(status: string, conclusion: string | null | undefined): string {
449
+ return conclusion ? `${status} (${conclusion})` : status;
450
+ }
451
+
452
+ /** Wall-clock duration between two ISO timestamps, formatted HH:MM:SS / MM:SS. Empty when unknown. */
453
+ function actionDuration(start?: string | null, end?: string | null): string {
454
+ if (!start || !end) return "";
455
+ const ms = Date.parse(end) - Date.parse(start);
456
+ if (!Number.isFinite(ms) || ms < 0) return "";
457
+ return formatMediaDuration(Math.round(ms / 1000));
458
+ }
459
+
460
+ /** Escape `|` so step/job names can't break a markdown table row. */
461
+ function escapeCell(text: string): string {
462
+ return text.replaceAll("|", "\\|");
463
+ }
464
+
465
+ /**
466
+ * Strip the per-line ISO-8601 timestamp prefix GitHub prepends to every job log line.
467
+ * Cuts ~28 bytes/line of noise while preserving the message text. Also drops the leading
468
+ * UTF-8 BOM GitHub puts at the start of the log file (otherwise the first line's timestamp
469
+ * survives because `^` no longer sits before a digit).
470
+ */
471
+ export function stripActionsLogTimestamps(logs: string): string {
472
+ return logs.replace(/^\uFEFF/, "").replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z /gm, "");
473
+ }
474
+
475
+ /** Render a job's steps as a markdown table. Empty string when there are no steps. */
476
+ function renderActionsSteps(steps?: GitHubActionsStep[]): string {
477
+ if (!steps || steps.length === 0) return "";
478
+ let md = "| # | Step | Status | Conclusion | Duration |\n";
479
+ md += "|---|------|--------|------------|----------|\n";
480
+ for (const step of steps) {
481
+ const dur = actionDuration(step.started_at, step.completed_at) || "-";
482
+ md += `| ${step.number} | ${escapeCell(step.name)} | ${step.status} | ${step.conclusion ?? "-"} | ${dur} |\n`;
483
+ }
484
+ return `${md}\n`;
485
+ }
486
+
487
+ /** Run-level metadata lines shared by the run and job renderers. */
488
+ function renderActionsRunMeta(run: GitHubActionsRun): string {
489
+ let md = `**Workflow:** ${run.name ?? "(unknown)"}\n`;
490
+ md += `**Run:** #${run.run_number}`;
491
+ if (run.run_attempt && run.run_attempt > 1) md += ` (attempt ${run.run_attempt})`;
492
+ md += ` · ${statusLabel(run.status, run.conclusion)}\n`;
493
+ if (run.head_branch) {
494
+ md += `**Branch:** ${run.head_branch}${run.head_sha ? ` @ ${run.head_sha.slice(0, 7)}` : ""}\n`;
495
+ }
496
+ const actor = run.triggering_actor?.login ?? run.actor?.login;
497
+ md += `**Event:** ${run.event}${actor ? ` · by @${actor}` : ""}\n`;
498
+ const started = run.run_started_at ?? run.created_at;
499
+ const dur = actionDuration(started, run.updated_at);
500
+ md += `Started: ${started}${dur ? ` · Duration: ${dur}` : ""}\n`;
501
+ md += `URL: ${run.html_url}\n`;
502
+ return md;
503
+ }
504
+
505
+ /** Fetch a job's plain-text logs. Returns null when unavailable (no token / expired / private). */
506
+ async function fetchGitHubJobLogs(
507
+ owner: string,
508
+ repo: string,
509
+ jobId: number,
510
+ timeout: number,
511
+ signal?: AbortSignal,
512
+ ): Promise<string | null> {
513
+ const headers: Record<string, string> = {
514
+ Accept: "application/vnd.github+json",
515
+ "X-GitHub-Api-Version": "2022-11-28",
516
+ };
517
+ const token = $env.GITHUB_TOKEN || $env.GH_TOKEN;
518
+ if (token) headers.Authorization = `Bearer ${token}`;
519
+
520
+ // 302 → signed log URL on a different origin; fetch strips Authorization on the cross-origin hop.
521
+ const result = await loadPage(`https://api.github.com/repos/${owner}/${repo}/actions/jobs/${jobId}/logs`, {
522
+ timeout,
523
+ headers,
524
+ signal,
525
+ });
526
+ return result.ok && result.content ? result.content : null;
527
+ }
528
+
529
+ /**
530
+ * Render a workflow run: run metadata plus a per-job breakdown. Steps are listed for any job that
531
+ * did not succeed (the debugging-relevant ones); successful jobs collapse to a single line.
532
+ */
533
+ async function renderGitHubActionsRun(
534
+ gh: GitHubUrl,
535
+ timeout: number,
536
+ signal?: AbortSignal,
537
+ ): Promise<{ content: string; ok: boolean }> {
538
+ const runResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/runs/${gh.runId}`, timeout, signal);
539
+ if (!runResult.ok || !runResult.data) return { content: "", ok: false };
540
+
541
+ const run = runResult.data as GitHubActionsRun;
542
+ let md = `# ${run.display_title || run.name || `Run #${run.run_number}`}\n\n`;
543
+ md += renderActionsRunMeta(run);
544
+ md += `\n---\n\n`;
545
+
546
+ const jobsResult = await fetchGitHubApi(
547
+ `/repos/${gh.owner}/${gh.repo}/actions/runs/${gh.runId}/jobs?per_page=100`,
548
+ timeout,
549
+ signal,
550
+ );
551
+ if (jobsResult.ok && jobsResult.data) {
552
+ const jobs = (jobsResult.data as { jobs?: GitHubActionsJob[] }).jobs ?? [];
553
+ md += `## Jobs (${jobs.length})\n\n`;
554
+ for (const job of jobs) {
555
+ const dur = actionDuration(job.started_at, job.completed_at);
556
+ md += `### ${escapeCell(job.name)} — ${statusLabel(job.status, job.conclusion)}${dur ? ` (${dur})` : ""}\n\n`;
557
+ if (job.conclusion !== "success") {
558
+ md += renderActionsSteps(job.steps);
559
+ }
560
+ }
561
+ }
562
+
563
+ return { content: md, ok: true };
564
+ }
565
+
566
+ /**
567
+ * Render a single workflow job: run context, step table, and the full job logs.
568
+ */
569
+ async function renderGitHubActionsJob(
570
+ gh: GitHubUrl,
571
+ timeout: number,
572
+ signal?: AbortSignal,
573
+ ): Promise<{ content: string; ok: boolean }> {
574
+ const jobResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/jobs/${gh.jobId}`, timeout, signal);
575
+ if (!jobResult.ok || !jobResult.data) return { content: "", ok: false };
576
+
577
+ const job = jobResult.data as GitHubActionsJob;
578
+
579
+ // Best-effort run context for nicer headers; the job render stands on its own without it.
580
+ const runResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/runs/${job.run_id}`, timeout, signal);
581
+ const run = runResult.ok && runResult.data ? (runResult.data as GitHubActionsRun) : null;
582
+
583
+ let md = `# ${escapeCell(job.name)}\n\n`;
584
+ if (run) {
585
+ md += renderActionsRunMeta(run);
586
+ } else if (job.workflow_name) {
587
+ md += `**Workflow:** ${job.workflow_name}\n`;
588
+ if (job.head_branch) md += `**Branch:** ${job.head_branch}\n`;
589
+ }
590
+ const dur = actionDuration(job.started_at, job.completed_at);
591
+ md += `**Job:** ${escapeCell(job.name)} · ${statusLabel(job.status, job.conclusion)}${dur ? ` · ${dur}` : ""}\n`;
592
+ if (job.runner_name) md += `**Runner:** ${job.runner_name}\n`;
593
+ if (job.html_url) md += `URL: ${job.html_url}\n`;
594
+ md += `\n---\n\n`;
595
+
596
+ const steps = renderActionsSteps(job.steps);
597
+ if (steps) md += `## Steps\n\n${steps}`;
598
+
599
+ const logs = await fetchGitHubJobLogs(gh.owner, gh.repo, job.id, timeout, signal);
600
+ md += `## Logs\n\n`;
601
+ md += logs
602
+ ? stripActionsLogTimestamps(logs)
603
+ : "*Logs unavailable — requires a GITHUB_TOKEN/GH_TOKEN with read access, or the run's logs have expired.*\n";
604
+
605
+ return { content: md, ok: true };
606
+ }
607
+
374
608
  /**
375
609
  * Handle GitHub URLs specially
376
610
  */
@@ -445,6 +679,24 @@ export const handleGitHub: SpecialHandler = async (
445
679
  }
446
680
  break;
447
681
  }
682
+
683
+ case "actions-run": {
684
+ notes.push(`Fetched via GitHub API`);
685
+ const result = await renderGitHubActionsRun(gh, timeout, signal);
686
+ if (result.ok) {
687
+ return buildResult(result.content, { url, method: "github-actions-run", fetchedAt, notes });
688
+ }
689
+ break;
690
+ }
691
+
692
+ case "actions-job": {
693
+ notes.push(`Fetched via GitHub API`);
694
+ const result = await renderGitHubActionsJob(gh, timeout, signal);
695
+ if (result.ok) {
696
+ return buildResult(result.content, { url, method: "github-actions-job", fetchedAt, notes });
697
+ }
698
+ break;
699
+ }
448
700
  }
449
701
 
450
702
  // Fall back to null (let normal rendering handle it)
@@ -113,8 +113,9 @@ export const handleYouTube: SpecialHandler = async (
113
113
  const notes: string[] = [];
114
114
  const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
115
115
 
116
- // Prefer Parallel extract when credentials are available
117
- if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
116
+ // Prefer Parallel extract when it sits in the reader chain and creds exist
117
+ const fetchPreference = settings.get("providers.fetch");
118
+ if ((fetchPreference === "auto" || fetchPreference === "parallel") && findParallelApiKey(storage)) {
118
119
  try {
119
120
  const parallelResult = await extractWithParallel(
120
121
  [videoUrl],
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Perplexity Web Search Provider
3
3
  *
4
- * Supports three auth modes:
4
+ * Supports four auth modes:
5
5
  * - Cookies (`PERPLEXITY_COOKIES`) via `www.perplexity.ai/rest/sse/perplexity_ask`
6
6
  * - OAuth/session bearer via `AuthStorage` and `www.perplexity.ai/rest/sse/perplexity_ask`
7
7
  * - API key (`PERPLEXITY_API_KEY`) via `api.perplexity.ai/chat/completions`
8
+ * - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
8
9
  */
9
10
 
10
11
  import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
@@ -32,6 +33,8 @@ const DEFAULT_NUM_SEARCH_RESULTS = 20;
32
33
  const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
33
34
  const OAUTH_API_VERSION = "2.18";
34
35
  const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
36
+ const ANONYMOUS_USER_AGENT =
37
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
35
38
 
36
39
  type PerplexityAuth =
37
40
  | {
@@ -45,6 +48,9 @@ type PerplexityAuth =
45
48
  | {
46
49
  type: "cookies";
47
50
  cookies: string;
51
+ }
52
+ | {
53
+ type: "anonymous";
48
54
  };
49
55
 
50
56
  interface PerplexityOAuthStreamMarkdownBlock {
@@ -149,6 +155,112 @@ function mergeOAuthEventSnapshot(
149
155
 
150
156
  return merged;
151
157
  }
158
+
159
+ function asRecord(value: unknown): Record<string, unknown> | null {
160
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
161
+ return value as Record<string, unknown>;
162
+ }
163
+
164
+ function parseJson(text: string): unknown | null {
165
+ try {
166
+ return JSON.parse(text);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function textFromChunks(value: unknown): string | null {
173
+ if (!Array.isArray(value) || value.length === 0) return null;
174
+ let text = "";
175
+ for (const chunk of value) {
176
+ if (typeof chunk !== "string") return null;
177
+ text += chunk;
178
+ }
179
+ return text.length > 0 ? text : null;
180
+ }
181
+
182
+ function textFromStructuredAnswer(value: unknown): string | null {
183
+ if (!Array.isArray(value)) return null;
184
+ for (const item of value) {
185
+ const record = asRecord(item);
186
+ if (!record) continue;
187
+ const text = record.text;
188
+ if (typeof text === "string" && text.length > 0) return text;
189
+ const chunks = textFromChunks(record.chunks);
190
+ if (chunks) return chunks;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function answerFromTextPayload(payload: Record<string, unknown>): string | null {
196
+ const structured = textFromStructuredAnswer(payload.structured_answer);
197
+ if (structured) return structured;
198
+ const chunks = textFromChunks(payload.chunks);
199
+ if (chunks) return chunks;
200
+ const answer = payload.answer;
201
+ return typeof answer === "string" && answer.length > 0 ? answer : null;
202
+ }
203
+
204
+ function parseOAuthTextPayload(text: string): Record<string, unknown> | null {
205
+ const parsed = parseJson(text);
206
+ const direct = asRecord(parsed);
207
+ if (direct) return direct;
208
+ if (!Array.isArray(parsed)) return null;
209
+
210
+ for (const item of parsed) {
211
+ const step = asRecord(item);
212
+ const content = asRecord(step?.content);
213
+ const answer = content?.answer;
214
+ if (typeof answer !== "string" || answer.length === 0) continue;
215
+ const payload = asRecord(parseJson(answer));
216
+ if (payload) return payload;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function parseOAuthTextAnswer(text: string): string {
222
+ const payload = parseOAuthTextPayload(text);
223
+ if (payload) {
224
+ const answer = answerFromTextPayload(payload);
225
+ if (answer) return answer;
226
+ }
227
+
228
+ const parsed = parseJson(text);
229
+ if (!Array.isArray(parsed)) return text;
230
+ for (const item of parsed) {
231
+ const step = asRecord(item);
232
+ const content = asRecord(step?.content);
233
+ const answer = content?.answer;
234
+ if (typeof answer === "string" && answer.length > 0) return answer;
235
+ }
236
+ return text;
237
+ }
238
+
239
+ function sourcesFromTextPayload(text: string | undefined): SearchSource[] {
240
+ if (!text) return [];
241
+ const payload = parseOAuthTextPayload(text);
242
+ const webResults = payload?.web_results;
243
+ if (!Array.isArray(webResults) || webResults.length === 0) return [];
244
+
245
+ const sources: SearchSource[] = [];
246
+ for (const value of webResults) {
247
+ const result = asRecord(value);
248
+ if (!result) continue;
249
+ const url = result.url;
250
+ if (typeof url !== "string" || url.length === 0) continue;
251
+ const name = result.name ?? result.title;
252
+ const snippet = result.snippet;
253
+ const timestamp = result.timestamp;
254
+ sources.push({
255
+ title: typeof name === "string" && name.length > 0 ? name : url,
256
+ url,
257
+ snippet: typeof snippet === "string" ? snippet : undefined,
258
+ publishedDate: typeof timestamp === "string" ? timestamp : undefined,
259
+ ageSeconds: dateToAgeSeconds(typeof timestamp === "string" ? timestamp : undefined),
260
+ });
261
+ }
262
+ return sources;
263
+ }
152
264
  export interface PerplexitySearchParams {
153
265
  signal?: AbortSignal;
154
266
  query: string;
@@ -216,7 +328,7 @@ async function findPerplexityAuth(
216
328
  authStorage: AuthStorage,
217
329
  sessionId: string | undefined,
218
330
  signal: AbortSignal | undefined,
219
- ): Promise<PerplexityAuth | null> {
331
+ ): Promise<PerplexityAuth> {
220
332
  // 1. PERPLEXITY_COOKIES env var
221
333
  const cookies = $env.PERPLEXITY_COOKIES?.trim();
222
334
  if (cookies) {
@@ -235,7 +347,9 @@ async function findPerplexityAuth(
235
347
  if (apiKey) {
236
348
  return { type: "api_key", token: apiKey };
237
349
  }
238
- return null;
350
+
351
+ // 4. The consumer ask endpoint currently accepts unauthenticated browser-style requests.
352
+ return { type: "anonymous" };
239
353
  }
240
354
 
241
355
  /** Call Perplexity API-key endpoint. */
@@ -284,7 +398,7 @@ function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] {
284
398
  }));
285
399
  }
286
400
 
287
- return (event.sources_list ?? [])
401
+ const sources = (event.sources_list ?? [])
288
402
  .filter(source => typeof source.url === "string" && source.url.length > 0)
289
403
  .map(source => ({
290
404
  title: source.title ?? source.url ?? "",
@@ -293,11 +407,13 @@ function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] {
293
407
  publishedDate: source.date,
294
408
  ageSeconds: dateToAgeSeconds(source.date),
295
409
  }));
410
+ if (sources.length > 0) return sources;
411
+ return sourcesFromTextPayload(event.text);
296
412
  }
297
413
 
298
414
  function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string {
299
415
  if (!event.blocks?.length) {
300
- return typeof event.text === "string" ? event.text : "";
416
+ return typeof event.text === "string" ? parseOAuthTextAnswer(event.text) : "";
301
417
  }
302
418
 
303
419
  const markdownBlock = event.blocks.find(
@@ -324,51 +440,77 @@ function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string {
324
440
  }
325
441
  }
326
442
  if (typeof event.text === "string" && event.text.length > 0) {
327
- return event.text;
443
+ return parseOAuthTextAnswer(event.text);
328
444
  }
329
445
  return "";
330
446
  }
331
447
 
332
- async function callPerplexityOAuth(
333
- auth: { type: "oauth"; token: string } | { type: "cookies"; cookies: string },
448
+ async function callPerplexityAsk(
449
+ auth: { type: "oauth"; token: string } | { type: "cookies"; cookies: string } | { type: "anonymous" },
334
450
  params: PerplexitySearchParams,
335
451
  ): Promise<{ answer: string; sources: SearchSource[]; model?: string; requestId?: string }> {
336
452
  const requestId = crypto.randomUUID();
337
- const effectiveQuery = params.system_prompt ? `${params.system_prompt}\n\n${params.query}` : params.query;
453
+ // The consumer `perplexity_ask` endpoint is itself a research assistant and
454
+ // has no system-message slot. Prepending the API-style system prompt to the
455
+ // query makes the model read it as a meta-instruction and refuse with
456
+ // "I don't have access to web-search tools in this turn", so ask-endpoint
457
+ // searches send the bare query. (The API-key path still uses system_prompt
458
+ // as a proper `system` message.)
459
+ const effectiveQuery = params.query;
460
+
461
+ const headers: Record<string, string> = {
462
+ "Content-Type": "application/json",
463
+ Accept: "text/event-stream",
464
+ Origin: "https://www.perplexity.ai",
465
+ Referer: "https://www.perplexity.ai/",
466
+ "User-Agent": auth.type === "anonymous" ? ANONYMOUS_USER_AGENT : OAUTH_USER_AGENT,
467
+ "X-Request-ID": requestId,
468
+ };
469
+ if (auth.type === "oauth") {
470
+ // The ask endpoint authenticates via the next-auth session cookie, NOT a
471
+ // bearer header — a bearer (even a garbage one) is ignored and the request
472
+ // silently falls back to the anonymous free `turbo` model regardless of
473
+ // `model_preference`. The stored OAuth token IS the Perplexity session JWT
474
+ // (the native app injects the same value as this cookie), so sending it as
475
+ // the cookie is what unlocks the account's Pro model selection.
476
+ headers.Cookie = `__Secure-next-auth.session-token=${auth.token}`;
477
+ } else if (auth.type === "cookies") {
478
+ headers.Cookie = auth.cookies;
479
+ }
480
+ if (auth.type !== "anonymous") {
481
+ headers["X-App-ApiClient"] = "default";
482
+ headers["X-App-ApiVersion"] = OAUTH_API_VERSION;
483
+ headers["X-Perplexity-Request-Reason"] = "submit";
484
+ }
485
+
486
+ const requestParams: Record<string, unknown> = {
487
+ query_str: effectiveQuery,
488
+ search_focus: "internet",
489
+ mode: "copilot",
490
+ model_preference: "experimental",
491
+ sources: ["web"],
492
+ attachments: [],
493
+ frontend_uuid: crypto.randomUUID(),
494
+ frontend_context_uuid: crypto.randomUUID(),
495
+ version: OAUTH_API_VERSION,
496
+ language: "en-US",
497
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
498
+ search_recency_filter: params.search_recency_filter ?? null,
499
+ is_incognito: true,
500
+ use_schematized_api: true,
501
+ skip_search_enabled: true,
502
+ };
503
+ if (auth.type === "anonymous") {
504
+ requestParams.send_back_text_in_streaming_api = true;
505
+ requestParams.source = "default";
506
+ }
338
507
 
339
508
  const response = await fetch(PERPLEXITY_OAUTH_ASK_URL, {
340
509
  method: "POST",
341
- headers: {
342
- ...(auth.type === "cookies" ? { Cookie: auth.cookies } : { Authorization: `Bearer ${auth.token}` }),
343
- "Content-Type": "application/json",
344
- Accept: "text/event-stream",
345
- Origin: "https://www.perplexity.ai",
346
- Referer: "https://www.perplexity.ai/",
347
- "User-Agent": OAUTH_USER_AGENT,
348
- "X-App-ApiClient": "default",
349
- "X-App-ApiVersion": OAUTH_API_VERSION,
350
- "X-Perplexity-Request-Reason": "submit",
351
- "X-Request-ID": requestId,
352
- },
510
+ headers,
353
511
  body: JSON.stringify({
354
512
  query_str: effectiveQuery,
355
- params: {
356
- query_str: effectiveQuery,
357
- search_focus: "internet",
358
- mode: "copilot",
359
- model_preference: "pplx_pro_upgraded",
360
- sources: ["web"],
361
- attachments: [],
362
- frontend_uuid: crypto.randomUUID(),
363
- frontend_context_uuid: crypto.randomUUID(),
364
- version: OAUTH_API_VERSION,
365
- language: "en-US",
366
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
367
- search_recency_filter: params.search_recency_filter ?? null,
368
- is_incognito: true,
369
- use_schematized_api: true,
370
- skip_search_enabled: true,
371
- },
513
+ params: requestParams,
372
514
  }),
373
515
  signal: withHardTimeout(params.signal),
374
516
  });
@@ -379,13 +521,13 @@ async function callPerplexityOAuth(
379
521
  if (classified) throw classified;
380
522
  throw new SearchProviderError(
381
523
  "perplexity",
382
- `Perplexity OAuth API error (${response.status}): ${errorText}`,
524
+ `Perplexity ask API error (${response.status}): ${errorText}`,
383
525
  response.status,
384
526
  );
385
527
  }
386
528
 
387
529
  if (!response.body) {
388
- throw new SearchProviderError("perplexity", "Perplexity OAuth API returned no response body", 500);
530
+ throw new SearchProviderError("perplexity", "Perplexity ask API returned no response body", 500);
389
531
  }
390
532
 
391
533
  let answer = "";
@@ -397,7 +539,7 @@ async function callPerplexityOAuth(
397
539
  for await (const event of readSseJson<PerplexityOAuthStreamEvent>(response.body, params.signal)) {
398
540
  if (event.error_code) {
399
541
  const message = event.error_message ?? event.error_code;
400
- throw new SearchProviderError("perplexity", `Perplexity OAuth stream error: ${message}`, 400);
542
+ throw new SearchProviderError("perplexity", `Perplexity ask stream error: ${message}`, 400);
401
543
  }
402
544
 
403
545
  mergedEvent = mergeOAuthEventSnapshot(mergedEvent, event);
@@ -500,20 +642,17 @@ function applySourceLimit(result: SearchResponse, limit?: number): SearchRespons
500
642
  /** Execute Perplexity web search */
501
643
  export async function searchPerplexity(params: PerplexitySearchParams): Promise<SearchResponse> {
502
644
  const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal);
503
- if (!auth) {
504
- throw new Error("Perplexity auth not found. Set PERPLEXITY_COOKIES, PERPLEXITY_API_KEY, or login via OAuth.");
505
- }
506
645
 
507
- if (auth.type === "oauth" || auth.type === "cookies") {
508
- const oauthResult = await callPerplexityOAuth(auth, params);
646
+ if (auth.type !== "api_key") {
647
+ const askResult = await callPerplexityAsk(auth, params);
509
648
  return applySourceLimit(
510
649
  {
511
650
  provider: "perplexity",
512
- answer: oauthResult.answer || undefined,
513
- sources: oauthResult.sources,
514
- model: oauthResult.model,
515
- requestId: oauthResult.requestId,
516
- authMode: "oauth",
651
+ answer: askResult.answer || undefined,
652
+ sources: askResult.sources,
653
+ model: askResult.model,
654
+ requestId: askResult.requestId,
655
+ authMode: auth.type === "anonymous" ? "anonymous" : "oauth",
517
656
  },
518
657
  params.num_results,
519
658
  );
@@ -562,6 +701,15 @@ export class PerplexityProvider extends SearchProvider {
562
701
  return !!$env.PERPLEXITY_COOKIES?.trim() || authStorage.hasAuth("perplexity") || !!findApiKey();
563
702
  }
564
703
 
704
+ /**
705
+ * Perplexity accepts anonymous browser-style ask requests, but keep auto
706
+ * provider selection credential-gated so a configured provider keeps priority
707
+ * over the anonymous fallback.
708
+ */
709
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
710
+ return true;
711
+ }
712
+
565
713
  search(params: SearchParams): Promise<SearchResponse> {
566
714
  return searchPerplexity({
567
715
  signal: params.signal,