@oh-my-pi/pi-coding-agent 16.1.2 → 16.1.4

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 (65) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/cli.js +2990 -2991
  3. package/dist/types/config/model-resolver.d.ts +3 -3
  4. package/dist/types/mnemopi/embed-client.d.ts +70 -0
  5. package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
  6. package/dist/types/mnemopi/embed-worker.d.ts +12 -0
  7. package/dist/types/mnemopi/state.d.ts +9 -1
  8. package/dist/types/modes/components/cache-invalidation-marker.d.ts +23 -10
  9. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  10. package/dist/types/sdk.d.ts +12 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/agent-storage.d.ts +2 -0
  13. package/dist/types/session/auth-broker-config.d.ts +3 -2
  14. package/dist/types/session/history-storage.d.ts +1 -1
  15. package/dist/types/session/tool-choice-queue.d.ts +2 -0
  16. package/dist/types/tools/image-gen.d.ts +2 -2
  17. package/dist/types/tools/index.d.ts +2 -0
  18. package/dist/types/tui/hyperlink.d.ts +3 -2
  19. package/dist/types/utils/image-loading.d.ts +1 -1
  20. package/dist/types/utils/ipc.d.ts +22 -0
  21. package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
  22. package/package.json +12 -12
  23. package/src/cli/bench-cli.ts +33 -2
  24. package/src/cli/dry-balance-cli.ts +4 -2
  25. package/src/cli.ts +8 -0
  26. package/src/commands/token.ts +52 -33
  27. package/src/config/append-only-context-mode.ts +45 -0
  28. package/src/config/model-discovery.ts +3 -0
  29. package/src/config/model-registry.ts +21 -3
  30. package/src/config/model-resolver.ts +31 -8
  31. package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
  32. package/src/extensibility/plugins/manager.ts +82 -22
  33. package/src/lsp/client.ts +24 -0
  34. package/src/mnemopi/backend.ts +49 -3
  35. package/src/mnemopi/embed-client.ts +401 -0
  36. package/src/mnemopi/embed-protocol.ts +35 -0
  37. package/src/mnemopi/embed-worker.ts +113 -0
  38. package/src/mnemopi/state.ts +29 -1
  39. package/src/modes/components/cache-invalidation-marker.ts +31 -15
  40. package/src/modes/components/custom-editor.test.ts +4 -3
  41. package/src/modes/components/custom-editor.ts +1 -1
  42. package/src/modes/components/model-selector.ts +2 -2
  43. package/src/modes/components/status-line/component.ts +64 -18
  44. package/src/modes/components/welcome.ts +1 -1
  45. package/src/modes/controllers/event-controller.ts +8 -0
  46. package/src/modes/controllers/selector-controller.ts +2 -2
  47. package/src/modes/theme/theme.ts +69 -0
  48. package/src/sdk.ts +37 -0
  49. package/src/session/agent-session.ts +13 -0
  50. package/src/session/agent-storage.ts +14 -0
  51. package/src/session/auth-broker-config.ts +2 -1
  52. package/src/session/history-storage.ts +13 -1
  53. package/src/session/tool-choice-queue.ts +6 -0
  54. package/src/stt/asr-client.ts +2 -7
  55. package/src/tiny/title-client.ts +2 -7
  56. package/src/tools/image-gen.ts +4 -8
  57. package/src/tools/index.ts +2 -0
  58. package/src/tools/render-utils.ts +4 -1
  59. package/src/tools/resolve.ts +1 -0
  60. package/src/tts/tts-client.ts +2 -7
  61. package/src/tui/hyperlink.ts +6 -3
  62. package/src/utils/image-loading.ts +12 -2
  63. package/src/utils/ipc.ts +38 -0
  64. package/src/web/search/providers/perplexity-auth.ts +133 -0
  65. package/src/web/search/providers/perplexity.ts +2 -125
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import * as url from "node:url";
9
9
  import { TERMINAL } from "@oh-my-pi/pi-tui";
10
- import { settings } from "../config/settings";
10
+ import { isSettingsInitialized, settings } from "../config/settings";
11
11
  import {
12
12
  LocalProtocolHandler,
13
13
  memoryRootsFromRegistry,
@@ -45,8 +45,10 @@ function buildFileUri(filePath: string, opts?: { line?: number; col?: number }):
45
45
  * - `"off"`: never
46
46
  * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
47
47
  * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
48
+ * Before settings initialization, returns false so early render paths stay plain text.
48
49
  */
49
50
  export function isHyperlinkEnabled(): boolean {
51
+ if (!isSettingsInitialized()) return false;
50
52
  const mode = settings.get("tui.hyperlinks");
51
53
  if (mode === "off") return false;
52
54
  if (mode === "always") return true;
@@ -104,10 +106,11 @@ export function urlHyperlink(url: string, displayText: string): string {
104
106
  * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
105
107
  * bypassing terminal capability auto-detection. Used for auth prompts where
106
108
  * an inert "click" label blocks login on terminals whose capabilities are
107
- * not advertised. Still returns plain text when the user has explicitly
108
- * opted out via `tui.hyperlinks=off`.
109
+ * not advertised. Still returns plain text before settings initialization or
110
+ * when the user has explicitly opted out via `tui.hyperlinks=off`.
109
111
  */
110
112
  export function urlHyperlinkAlways(url: string, displayText: string): string {
113
+ if (!isSettingsInitialized()) return displayText;
111
114
  if (settings.get("tui.hyperlinks") === "off") return displayText;
112
115
  const normalized = url.match(/^www\./i) ? `https://${url}` : url;
113
116
  try {
@@ -13,9 +13,19 @@ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
13
13
  * with an opaque HTTP 400. Detect those models so the resize pipeline encodes
14
14
  * to PNG/JPEG instead — the automatic equivalent of `OMP_NO_WEBP=1`.
15
15
  */
16
- export function modelLacksWebpSupport(model: Pick<Model, "provider" | "api"> | undefined): boolean {
16
+ export function modelLacksWebpSupport(
17
+ model: Pick<Model, "provider" | "api" | "imageInputDecoder"> | undefined,
18
+ ): boolean {
17
19
  if (!model) return false;
18
- return model.provider === "ollama" || model.provider === "ollama-cloud" || model.api === "ollama-chat";
20
+ return (
21
+ model.imageInputDecoder === "stb" ||
22
+ model.provider === "ollama" ||
23
+ model.provider === "ollama-cloud" ||
24
+ model.provider === "llama.cpp" ||
25
+ model.provider === "lm-studio" ||
26
+ model.provider === "local-server" ||
27
+ model.api === "ollama-chat"
28
+ );
19
29
  }
20
30
 
21
31
  /**
@@ -0,0 +1,38 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+
3
+ /**
4
+ * Narrow a value to a thenable so a rejection handler can be attached.
5
+ *
6
+ * Mirrors the local helper in `mcp/transports/stdio.ts` (kept separate because
7
+ * that copy serves the FileSink stdin-write path and is battle-tested there).
8
+ * This shared copy is the home for the IPC `send()` sites.
9
+ */
10
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
11
+ return (
12
+ value != null &&
13
+ (typeof value === "object" || typeof value === "function") &&
14
+ typeof (value as { then?: unknown }).then === "function"
15
+ );
16
+ }
17
+
18
+ /**
19
+ * Send a message to a Bun subprocess over IPC, neutralizing both the
20
+ * synchronous throw ("cannot be used after the process has exited") and any
21
+ * asynchronous rejection (EPIPE from a pipe that broke between exit being
22
+ * observed and the next `send()`). The dead worker is detected separately via
23
+ * `onExit`/`onError` and respawned or disabled by the owning client; an
24
+ * un-awaited EPIPE rejection must not escape as a fatal unhandled rejection
25
+ * that takes down the whole session. See issue #2997.
26
+ *
27
+ * `label` prefixes the debug log on synchronous failure (e.g. "tts").
28
+ */
29
+ export function safeSend(proc: { send(message: unknown): unknown }, message: unknown, label: string): void {
30
+ try {
31
+ const result = proc.send(message);
32
+ if (isThenable(result)) result.then(undefined, () => {});
33
+ } catch (error) {
34
+ logger.debug(`${label}: send to subprocess failed`, {
35
+ error: error instanceof Error ? error.message : String(error),
36
+ });
37
+ }
38
+ }
@@ -0,0 +1,133 @@
1
+ import type { AuthStorage, OAuthAccess } from "@oh-my-pi/pi-ai";
2
+ import { $env } from "@oh-my-pi/pi-utils";
3
+
4
+ export const PERPLEXITY_CHAT_BASE_URL = "https://api.perplexity.ai";
5
+ export const PERPLEXITY_RESPONSES_BASE_URL = "https://api.perplexity.ai/v1";
6
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
7
+ export const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
8
+
9
+ export interface ApiConfig {
10
+ type: "api_key";
11
+ apiKey: string;
12
+ provider: "perplexity" | "openrouter";
13
+ chatBaseUrl: string;
14
+ responsesBaseUrl: string;
15
+ modelPrefix: string;
16
+ useResponses: boolean;
17
+ }
18
+
19
+ export type PerplexityAuth =
20
+ | ApiConfig
21
+ | {
22
+ type: "oauth";
23
+ access: OAuthAccess;
24
+ }
25
+ | {
26
+ type: "cookies";
27
+ cookies: string;
28
+ }
29
+ | {
30
+ type: "anonymous";
31
+ };
32
+
33
+ export interface PerplexityAuthOptions {
34
+ signal?: AbortSignal;
35
+ forceRefresh?: boolean;
36
+ }
37
+
38
+ /** Detect API-key endpoints to try in priority order (Perplexity direct, then OpenRouter). */
39
+ export async function getApiConfigs(
40
+ authStorage: AuthStorage,
41
+ sessionId: string | undefined,
42
+ options?: PerplexityAuthOptions,
43
+ ): Promise<ApiConfig[]> {
44
+ const useResponses = $env.PI_PERPLEXITY_RESPONSES === "1";
45
+ const configs: ApiConfig[] = [];
46
+
47
+ const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, options);
48
+ if (perplexityKey) {
49
+ configs.push({
50
+ type: "api_key",
51
+ apiKey: perplexityKey,
52
+ provider: "perplexity",
53
+ chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
54
+ responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
55
+ modelPrefix: "",
56
+ useResponses,
57
+ });
58
+ }
59
+
60
+ const openrouterKey = await authStorage.getApiKey("openrouter", sessionId, options);
61
+ if (openrouterKey) {
62
+ configs.push({
63
+ type: "api_key",
64
+ apiKey: openrouterKey,
65
+ provider: "openrouter",
66
+ chatBaseUrl: OPENROUTER_BASE_URL,
67
+ responsesBaseUrl: OPENROUTER_BASE_URL,
68
+ modelPrefix: "perplexity/",
69
+ useResponses,
70
+ });
71
+ }
72
+
73
+ return configs;
74
+ }
75
+
76
+ /**
77
+ * Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
78
+ * token has no `exp` (which is the common case — Perplexity sessions are
79
+ * server-side and effectively non-expiring from the client's POV).
80
+ */
81
+ export function jwtExpiryMs(token: string): number | undefined {
82
+ const parts = token.split(".");
83
+ if (parts.length !== 3) return undefined;
84
+ const payload = parts[1];
85
+ if (!payload) return undefined;
86
+ try {
87
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
88
+ if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
89
+ return decoded.exp * 1000;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /** Collect all available auth methods to try in priority order */
96
+ export async function getAvailableAuthMethods(
97
+ authStorage: AuthStorage,
98
+ sessionId: string | undefined,
99
+ options?: PerplexityAuthOptions,
100
+ ): Promise<PerplexityAuth[]> {
101
+ const methods: PerplexityAuth[] = [];
102
+
103
+ // 1. Cookies take precedence over OAuth as noted in comments/docs
104
+ const cookies = $env.PERPLEXITY_COOKIES?.trim();
105
+ if (cookies) {
106
+ methods.push({ type: "cookies", cookies });
107
+ }
108
+
109
+ // 2. Perplexity OAuth (session bearer)
110
+ try {
111
+ const access = await authStorage.getOAuthAccess("perplexity", sessionId, options);
112
+ const token = access?.accessToken;
113
+ if (access && token) {
114
+ const jwtExpiry = jwtExpiryMs(token);
115
+ if (jwtExpiry === undefined || jwtExpiry > Date.now() + OAUTH_EXPIRY_BUFFER_MS) {
116
+ methods.push({ type: "oauth", access });
117
+ }
118
+ }
119
+ } catch {
120
+ // ignored
121
+ }
122
+
123
+ // 3. API key configs (direct, then openrouter)
124
+ const apiConfigs = await getApiConfigs(authStorage, sessionId, options);
125
+ methods.push(...apiConfigs);
126
+
127
+ // 4. Fallback to Perplexity free (anonymous)
128
+ if (methods.length === 0) {
129
+ methods.push({ type: "anonymous" });
130
+ }
131
+
132
+ return methods;
133
+ }
@@ -14,7 +14,6 @@ import {
14
14
  type AuthStorage,
15
15
  type Context,
16
16
  type FetchImpl,
17
- type OAuthAccess,
18
17
  type Usage,
19
18
  withOAuthAccess,
20
19
  } from "@oh-my-pi/pi-ai";
@@ -34,36 +33,19 @@ import { SearchProviderError } from "../../../web/search/types";
34
33
  import { dateToAgeSeconds } from "../utils";
35
34
  import type { SearchParams } from "./base";
36
35
  import { SearchProvider } from "./base";
36
+ import { type ApiConfig, getAvailableAuthMethods } from "./perplexity-auth";
37
37
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
38
38
 
39
- const PERPLEXITY_CHAT_BASE_URL = "https://api.perplexity.ai";
40
- const PERPLEXITY_RESPONSES_BASE_URL = "https://api.perplexity.ai/v1";
41
- const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
42
39
  const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
43
40
 
44
41
  const DEFAULT_MAX_TOKENS = 8192;
45
42
  const DEFAULT_TEMPERATURE = 0.2;
46
43
  const DEFAULT_NUM_SEARCH_RESULTS = 20;
47
- const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
48
44
  const OAUTH_API_VERSION = "2.18";
49
45
  const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
50
46
  const ANONYMOUS_USER_AGENT =
51
47
  "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";
52
48
 
53
- type PerplexityAuth =
54
- | ApiConfig
55
- | {
56
- type: "oauth";
57
- access: OAuthAccess;
58
- }
59
- | {
60
- type: "cookies";
61
- cookies: string;
62
- }
63
- | {
64
- type: "anonymous";
65
- };
66
-
67
49
  interface PerplexityOAuthStreamMarkdownBlock {
68
50
  answer?: string;
69
51
  chunks?: string[];
@@ -289,111 +271,6 @@ export interface PerplexitySearchParams {
289
271
  fetch?: FetchImpl;
290
272
  }
291
273
 
292
- interface ApiConfig {
293
- type: "api_key";
294
- apiKey: string;
295
- provider: "perplexity" | "openrouter";
296
- chatBaseUrl: string;
297
- responsesBaseUrl: string;
298
- modelPrefix: string;
299
- useResponses: boolean;
300
- }
301
-
302
- /** Detect API-key endpoints to try in priority order (Perplexity direct, then OpenRouter). */
303
- async function getApiConfigs(
304
- authStorage: AuthStorage,
305
- sessionId: string | undefined,
306
- signal: AbortSignal | undefined,
307
- ): Promise<ApiConfig[]> {
308
- const useResponses = $env.PI_PERPLEXITY_RESPONSES === "1";
309
- const configs: ApiConfig[] = [];
310
-
311
- const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, { signal });
312
- if (perplexityKey) {
313
- configs.push({
314
- type: "api_key",
315
- apiKey: perplexityKey,
316
- provider: "perplexity",
317
- chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
318
- responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
319
- modelPrefix: "",
320
- useResponses,
321
- });
322
- }
323
-
324
- const openrouterKey = await authStorage.getApiKey("openrouter", sessionId, { signal });
325
- if (openrouterKey) {
326
- configs.push({
327
- type: "api_key",
328
- apiKey: openrouterKey,
329
- provider: "openrouter",
330
- chatBaseUrl: OPENROUTER_BASE_URL,
331
- responsesBaseUrl: OPENROUTER_BASE_URL,
332
- modelPrefix: "perplexity/",
333
- useResponses,
334
- });
335
- }
336
-
337
- return configs;
338
- }
339
-
340
- /**
341
- * Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
342
- * token has no `exp` (which is the common case — Perplexity sessions are
343
- * server-side and effectively non-expiring from the client's POV).
344
- */
345
- function jwtExpiryMs(token: string): number | undefined {
346
- const parts = token.split(".");
347
- if (parts.length !== 3) return undefined;
348
- const payload = parts[1];
349
- if (!payload) return undefined;
350
- try {
351
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
352
- if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
353
- return decoded.exp * 1000;
354
- } catch {
355
- return undefined;
356
- }
357
- }
358
-
359
- /** Collect all available auth methods to try in priority order */
360
- async function getAvailableAuthMethods(
361
- authStorage: AuthStorage,
362
- sessionId: string | undefined,
363
- signal: AbortSignal | undefined,
364
- ): Promise<PerplexityAuth[]> {
365
- const methods: PerplexityAuth[] = [];
366
-
367
- // 1. Perplexity OAuth & Cookies (same priority - highest)
368
- try {
369
- const access = await authStorage.getOAuthAccess("perplexity", sessionId, { signal });
370
- const token = access?.accessToken;
371
- if (access && token) {
372
- const jwtExpiry = jwtExpiryMs(token);
373
- if (jwtExpiry === undefined || jwtExpiry > Date.now() + OAUTH_EXPIRY_BUFFER_MS) {
374
- methods.push({ type: "oauth", access });
375
- }
376
- }
377
- } catch {
378
- // ignored
379
- }
380
-
381
- const cookies = $env.PERPLEXITY_COOKIES?.trim();
382
- if (cookies) {
383
- methods.push({ type: "cookies", cookies });
384
- }
385
-
386
- const apiConfigs = await getApiConfigs(authStorage, sessionId, signal);
387
- methods.push(...apiConfigs);
388
-
389
- // 5. Fallback to Perplexity free (anonymous)
390
- if (methods.length === 0) {
391
- methods.push({ type: "anonymous" });
392
- }
393
-
394
- return methods;
395
- }
396
-
397
274
  interface PerplexityApiStreamMetadata {
398
275
  id?: string;
399
276
  model?: string;
@@ -904,7 +781,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
904
781
  request.search_recency_filter = params.search_recency_filter;
905
782
  }
906
783
 
907
- const authMethods = await getAvailableAuthMethods(params.authStorage, params.sessionId, params.signal);
784
+ const authMethods = await getAvailableAuthMethods(params.authStorage, params.sessionId, { signal: params.signal });
908
785
  let lastError: unknown;
909
786
 
910
787
  for (const auth of authMethods) {