@oh-my-pi/pi-coding-agent 15.9.0 → 15.9.3

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 (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
@@ -10,8 +10,6 @@ import path from "node:path";
10
10
  import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
11
11
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
- import { glob } from "@oh-my-pi/pi-natives";
14
- import { fuzzyMatch } from "@oh-my-pi/pi-tui";
15
13
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
16
14
  import { normalizeToLF } from "../edit/normalize";
17
15
  import type { FileMentionMessage } from "../session/messages";
@@ -30,27 +28,6 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
30
28
  const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
31
29
  const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
32
30
  const DEFAULT_DIR_LIMIT = 500;
33
- const MIN_FUZZY_QUERY_LENGTH = 5;
34
- const MAX_RESOLUTION_CANDIDATES = 20_000;
35
- const PATH_SEPARATOR_REGEX = /[/._\-\s]+/g;
36
-
37
- type MentionDiscoveryProfile = {
38
- hidden: boolean;
39
- gitignore: boolean;
40
- includeNodeModules: boolean;
41
- maxResults: number;
42
- cache: boolean;
43
- };
44
-
45
- function getMentionCandidateDiscoveryProfile(): MentionDiscoveryProfile {
46
- return {
47
- hidden: true,
48
- gitignore: true,
49
- cache: true,
50
- includeNodeModules: true,
51
- maxResults: MAX_RESOLUTION_CANDIDATES,
52
- };
53
- }
54
31
 
55
32
  // Avoid OOM when users @mention very large files. Above these limits we skip
56
33
  // auto-reading and only include the path in the message.
@@ -70,16 +47,6 @@ function sanitizeMentionPath(rawPath: string): string | null {
70
47
  return cleaned.length > 0 ? cleaned : null;
71
48
  }
72
49
 
73
- type MentionCandidate = {
74
- path: string;
75
- pathLower: string;
76
- normalizedPath: string;
77
- };
78
-
79
- function normalizeMentionQuery(query: string): string {
80
- return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, "");
81
- }
82
-
83
50
  async function pathExists(filePath: string): Promise<boolean> {
84
51
  try {
85
52
  await Bun.file(filePath).stat();
@@ -89,75 +56,13 @@ async function pathExists(filePath: string): Promise<boolean> {
89
56
  }
90
57
  }
91
58
 
92
- async function listMentionCandidates(cwd: string): Promise<MentionCandidate[]> {
93
- let entries: string[];
94
- try {
95
- const discoveryProfile = getMentionCandidateDiscoveryProfile();
96
- const result = await glob({
97
- pattern: "**/*",
98
- path: cwd,
99
- ...discoveryProfile,
100
- });
101
- entries = result.matches.map(match => match.path);
102
- } catch {
103
- return [];
104
- }
105
-
106
- entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
107
- const candidates: MentionCandidate[] = [];
108
- for (const entry of entries) {
109
- const pathLower = entry.toLowerCase();
110
- const normalizedPath = normalizeMentionQuery(entry);
111
- if (normalizedPath.length === 0) {
112
- continue;
113
- }
114
- candidates.push({ path: entry, pathLower, normalizedPath });
115
- }
116
- return candidates;
117
- }
118
-
119
- async function resolveMentionPath(
120
- filePath: string,
121
- cwd: string,
122
- getMentionCandidates: () => Promise<MentionCandidate[]>,
123
- ): Promise<string | null> {
59
+ async function resolveMentionPath(filePath: string, cwd: string): Promise<string | null> {
60
+ // Exact resolution only. The TUI @-selector inserts the real, complete path, so a
61
+ // mention that does not resolve to an existing file or directory is prose, not a file
62
+ // reference. Fuzzy/prefix guessing here previously dragged in unrelated same-named
63
+ // files; that disambiguation belongs to the selector's display, not post-send.
124
64
  const absolutePath = resolveReadPath(filePath, cwd);
125
- if (await pathExists(absolutePath)) {
126
- return filePath;
127
- }
128
-
129
- const queryLower = filePath.toLowerCase();
130
- const candidates = await getMentionCandidates();
131
- const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower));
132
- if (prefixMatches.length === 1) {
133
- return prefixMatches[0]?.path ?? null;
134
- }
135
- if (prefixMatches.length > 1) {
136
- return null;
137
- }
138
-
139
- const normalizedQuery = normalizeMentionQuery(filePath);
140
- if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
141
- return null;
142
- }
143
-
144
- const scored = candidates
145
- .map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) }))
146
- .filter(entry => entry.match.matches)
147
- .sort((a, b) => {
148
- if (a.match.score !== b.match.score) {
149
- return a.match.score - b.match.score;
150
- }
151
- return a.candidate.path.localeCompare(b.candidate.path);
152
- });
153
-
154
- if (scored.length === 0) {
155
- return null;
156
- }
157
-
158
- const best = scored[0];
159
-
160
- return best?.candidate.path ?? null;
65
+ return (await pathExists(absolutePath)) ? filePath : null;
161
66
  }
162
67
 
163
68
  function buildTextOutput(textContent: string): { output: string; lineCount: number } {
@@ -285,14 +190,9 @@ export async function generateFileMentionMessages(
285
190
  const autoResizeImages = options?.autoResizeImages ?? true;
286
191
 
287
192
  const files: FileMentionMessage["files"] = [];
288
- let mentionCandidatesPromise: Promise<MentionCandidate[]> | null = null;
289
- const getMentionCandidates = (): Promise<MentionCandidate[]> => {
290
- mentionCandidatesPromise ??= listMentionCandidates(cwd);
291
- return mentionCandidatesPromise;
292
- };
293
193
 
294
194
  for (const filePath of filePaths) {
295
- const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates);
195
+ const resolvedPath = await resolveMentionPath(filePath, cwd);
296
196
  if (!resolvedPath) {
297
197
  continue;
298
198
  }
@@ -10,7 +10,7 @@ import { resolveRoleSelection } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
12
12
  import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
13
- import { formatTitleUserMessage, normalizeGeneratedTitle } from "../tiny/text";
13
+ import { formatTitleUserMessage, isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
14
14
  import { tinyTitleClient } from "../tiny/title-client";
15
15
 
16
16
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
@@ -31,7 +31,8 @@ const setTitleTool: Tool = {
31
31
  properties: {
32
32
  title: {
33
33
  type: "string",
34
- description: "A concise 3-6 word title for the session.",
34
+ description:
35
+ 'A concise 3-6 word title for the session, or exactly "none" when the message carries no concrete task yet (greeting, small talk, vague).',
35
36
  },
36
37
  },
37
38
  required: ["title"],
@@ -144,6 +145,15 @@ export async function generateSessionTitle(
144
145
  currentModel?: Model<Api>,
145
146
  metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
146
147
  ): Promise<string | null> {
148
+ // Defer titling for greetings / acknowledgements / empty input. The default
149
+ // tiny title model can't reliably decline trivial input, so this happens
150
+ // deterministically before any model is invoked; the caller retries on the
151
+ // next user message while the session stays unnamed.
152
+ if (isLowSignalTitleInput(firstMessage)) {
153
+ logger.debug("title-generator: skipped low-signal input", { sessionId, reason: "low-signal" });
154
+ return null;
155
+ }
156
+
147
157
  const tinyModel = settings.get("providers.tinyModel");
148
158
  if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
149
159
  return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
@@ -152,7 +162,14 @@ export async function generateSessionTitle(
152
162
  const onlineAbortController = new AbortController();
153
163
  const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
154
164
  title => title || null,
155
- () => null,
165
+ err => {
166
+ logger.warn("title-generator: local model error", {
167
+ sessionId,
168
+ model: tinyModel,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ });
171
+ return null;
172
+ },
156
173
  );
157
174
  const startOnline = (): Promise<string | null> =>
158
175
  generateTitleOnline(
@@ -181,49 +198,48 @@ export async function generateTitleOnline(
181
198
  ): Promise<string | null> {
182
199
  const model = getTitleModel(registry, settings, currentModel);
183
200
  if (!model) {
184
- logger.debug("title-generator: no title model found");
201
+ logger.warn("title-generator: no title model found", { sessionId, reason: "no-title-model" });
185
202
  return null;
186
203
  }
187
204
 
188
205
  const userMessage = formatTitleUserMessage(firstMessage);
189
-
190
- const apiKey = await registry.getApiKey(model, sessionId);
191
- if (!apiKey) {
192
- logger.debug("title-generator: no API key for smol model", {
193
- provider: model.provider,
194
- id: model.id,
195
- });
196
- return null;
197
- }
198
- // Resolve metadata after getApiKey so the session-sticky credential for this
199
- // request is already recorded; metadataResolver can then return the correct
200
- // account_uuid rather than the snapshot-at-call-site value.
201
- const metadata = metadataResolver?.(model.provider);
202
-
203
- // Title generation is a 3-6 word task, but some reasoning backends ignore
204
- // disableReasoning. Keep the normal cheap budget for non-reasoning models
205
- // while reserving enough output room for reasoning models to still emit
206
- // the forced tool call after any unavoidable thinking tokens.
207
- const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
208
- const request = {
209
- model: `${model.provider}/${model.id}`,
210
- systemPrompt: TITLE_SYSTEM_PROMPT,
211
- userMessage,
212
- maxTokens,
206
+ const modelName = `${model.provider}/${model.id}`;
207
+ const modelContext = {
208
+ sessionId,
209
+ provider: model.provider,
210
+ id: model.id,
211
+ model: modelName,
213
212
  };
214
- logger.debug("title-generator: request", request);
213
+ logger.debug("title-generator: start", modelContext);
215
214
 
216
215
  try {
216
+ const apiKey = await registry.getApiKey(model, sessionId);
217
+ if (!apiKey) {
218
+ logger.warn("title-generator: no API key", { ...modelContext, reason: "missing-api-key" });
219
+ return null;
220
+ }
221
+ // Resolve metadata after getApiKey so the session-sticky credential for this
222
+ // request is already recorded; metadataResolver can then return the correct
223
+ // account_uuid rather than the snapshot-at-call-site value.
224
+ const metadata = metadataResolver?.(model.provider);
225
+
226
+ // Title generation is a 3-6 word task, but some reasoning backends ignore
227
+ // disableReasoning. Keep the normal cheap budget for non-reasoning models
228
+ // while reserving enough output room for reasoning models to still emit
229
+ // the forced tool call after any unavoidable thinking tokens.
230
+ const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
231
+ logger.debug("title-generator: request", { ...modelContext, maxTokens });
232
+
217
233
  const response = await completeSimple(
218
234
  model,
219
235
  {
220
- systemPrompt: [request.systemPrompt],
221
- messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
236
+ systemPrompt: [TITLE_SYSTEM_PROMPT],
237
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
222
238
  tools: [setTitleTool],
223
239
  },
224
240
  {
225
241
  apiKey,
226
- maxTokens: request.maxTokens,
242
+ maxTokens,
227
243
  disableReasoning: true,
228
244
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
229
245
  metadata,
@@ -232,8 +248,9 @@ export async function generateTitleOnline(
232
248
  );
233
249
 
234
250
  if (response.stopReason === "error") {
235
- logger.debug("title-generator: response error", {
236
- model: request.model,
251
+ logger.warn("title-generator: response error", {
252
+ ...modelContext,
253
+ reason: "provider-response-error",
237
254
  stopReason: response.stopReason,
238
255
  errorMessage: response.errorMessage,
239
256
  });
@@ -242,8 +259,18 @@ export async function generateTitleOnline(
242
259
 
243
260
  const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
244
261
 
245
- logger.debug("title-generator: response", {
246
- model: request.model,
262
+ if (!title) {
263
+ logger.debug("title-generator: no title returned", {
264
+ ...modelContext,
265
+ reason: "model-returned-none",
266
+ usage: response.usage,
267
+ stopReason: response.stopReason,
268
+ });
269
+ return null;
270
+ }
271
+
272
+ logger.debug("title-generator: success", {
273
+ ...modelContext,
247
274
  title,
248
275
  usage: response.usage,
249
276
  stopReason: response.stopReason,
@@ -251,8 +278,9 @@ export async function generateTitleOnline(
251
278
 
252
279
  return title;
253
280
  } catch (err) {
254
- logger.debug("title-generator: error", {
255
- model: request.model,
281
+ logger.warn("title-generator: error", {
282
+ ...modelContext,
283
+ reason: "exception",
256
284
  error: err instanceof Error ? err.message : String(err),
257
285
  });
258
286
  return null;
@@ -131,7 +131,9 @@ async function executeSearch(
131
131
  const providers =
132
132
  params.provider && params.provider !== "auto"
133
133
  ? await getSearchProvider(params.provider).then(async provider =>
134
- (await provider.isAvailable(authStorage)) ? [provider] : resolveProviderChain(authStorage, "auto"),
134
+ (await provider.isExplicitlyAvailable(authStorage))
135
+ ? [provider]
136
+ : resolveProviderChain(authStorage, "auto"),
135
137
  )
136
138
  : await resolveProviderChain(authStorage);
137
139
  if (providers.length === 0) {
@@ -140,7 +140,7 @@ export async function resolveProviderChain(
140
140
 
141
141
  if (preferredProvider !== "auto") {
142
142
  const provider = await getSearchProvider(preferredProvider);
143
- if (await provider.isAvailable(authStorage)) {
143
+ if (await provider.isExplicitlyAvailable(authStorage)) {
144
144
  providers.push(provider);
145
145
  }
146
146
  }
@@ -61,9 +61,26 @@ export abstract class SearchProvider {
61
61
  * Indicates whether this provider has the credentials/config it needs to
62
62
  * service a request right now. Implementations consult the passed
63
63
  * {@link AuthStorage} — never a sibling store.
64
+ *
65
+ * Drives auto-chain admission: providers that return `false` are skipped
66
+ * when {@link resolveProviderChain} walks the order. Explicit selection
67
+ * uses {@link isExplicitlyAvailable} instead.
64
68
  */
65
69
  abstract isAvailable(authStorage: AuthStorage): Promise<boolean> | boolean;
66
70
 
71
+ /**
72
+ * Returns `true` when this provider should run when the user explicitly
73
+ * selects it, even if {@link isAvailable} would reject it for the auto
74
+ * chain. Providers that ship an unauthenticated fallback (e.g. Exa's
75
+ * public MCP) override this so explicit selection still routes through
76
+ * the fallback rather than silently falling back to another provider.
77
+ *
78
+ * Defaults to mirroring {@link isAvailable}.
79
+ */
80
+ isExplicitlyAvailable(authStorage: AuthStorage): Promise<boolean> | boolean {
81
+ return this.isAvailable(authStorage);
82
+ }
83
+
67
84
  /**
68
85
  * Execute a search. Credentials MUST be resolved through `params.authStorage`.
69
86
  */
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
10
10
  import { settings } from "../../../config/settings";
11
+ import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
11
12
 
12
13
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
13
14
  import { SearchProviderError } from "../../../web/search/types";
@@ -56,6 +57,71 @@ interface ExaSearchResponse {
56
57
  costDollars?: { total: number };
57
58
  searchTime?: number;
58
59
  }
60
+ function asRecord(value: unknown): Record<string, unknown> | null {
61
+ if (typeof value !== "object" || value === null) return null;
62
+ return value as Record<string, unknown>;
63
+ }
64
+
65
+ function parseOptionalField(section: string, label: string): string | null | undefined {
66
+ const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
67
+ const match = section.match(regex);
68
+ if (!match) return undefined;
69
+ const value = match[1].trim();
70
+ return value.length > 0 ? value : null;
71
+ }
72
+
73
+ function parseTextField(section: string): string | null | undefined {
74
+ const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/);
75
+ if (!match) return undefined;
76
+ const value = match[1].trim();
77
+ return value.length > 0 ? value : null;
78
+ }
79
+
80
+ function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null {
81
+ const root = asRecord(payload);
82
+ if (!root) return null;
83
+
84
+ const content = root.content;
85
+ if (!Array.isArray(content)) return null;
86
+
87
+ const textBlocks = content
88
+ .map(item => {
89
+ const part = asRecord(item);
90
+ const text = typeof part?.text === "string" ? part.text : "";
91
+ return text.replace(/\r\n?/g, "\n").trim();
92
+ })
93
+ .filter(text => text.length > 0);
94
+
95
+ if (textBlocks.length === 0) return null;
96
+
97
+ const sections = textBlocks
98
+ .join("\n\n")
99
+ .split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/)
100
+ .map(section => section.trim())
101
+ .filter(section => section.startsWith("Title:"));
102
+
103
+ const results: ExaSearchResult[] = [];
104
+ for (const section of sections) {
105
+ const title = parseOptionalField(section, "Title");
106
+ const url = parseOptionalField(section, "URL");
107
+ const author = parseOptionalField(section, "Author");
108
+ const publishedDate = parseOptionalField(section, "Published Date");
109
+ const text = parseTextField(section);
110
+
111
+ if (!title && !url && !text) continue;
112
+
113
+ results.push({
114
+ title: title ?? undefined,
115
+ url: url ?? undefined,
116
+ author: author ?? undefined,
117
+ publishedDate: publishedDate ?? undefined,
118
+ text: text ?? undefined,
119
+ });
120
+ }
121
+
122
+ if (results.length === 0) return null;
123
+ return { results };
124
+ }
59
125
 
60
126
  export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
61
127
  if (!type) return "auto";
@@ -133,6 +199,32 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
133
199
 
134
200
  return response.json() as Promise<ExaSearchResponse>;
135
201
  }
202
+ function buildExaMcpArgs(params: ExaSearchParams): Record<string, unknown> {
203
+ const args: Record<string, unknown> = { query: params.query };
204
+ if (params.num_results !== undefined) args.num_results = params.num_results;
205
+ if (params.type !== undefined) args.type = params.type;
206
+ if (params.include_domains !== undefined) args.include_domains = params.include_domains;
207
+ if (params.exclude_domains !== undefined) args.exclude_domains = params.exclude_domains;
208
+ if (params.start_published_date !== undefined) args.start_published_date = params.start_published_date;
209
+ if (params.end_published_date !== undefined) args.end_published_date = params.end_published_date;
210
+ return args;
211
+ }
212
+
213
+ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
214
+ const response = await callExaTool("web_search_exa", buildExaMcpArgs(params), findApiKey(), {
215
+ signal: withHardTimeout(params.signal),
216
+ });
217
+ if (isSearchResponse(response)) {
218
+ return response as ExaSearchResponse;
219
+ }
220
+
221
+ const parsed = parseExaMcpTextPayload(response);
222
+ if (parsed) {
223
+ return parsed;
224
+ }
225
+
226
+ throw new Error("Exa MCP search returned unexpected response shape.");
227
+ }
136
228
 
137
229
  /** Execute Exa web search */
138
230
  export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
@@ -140,11 +232,7 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
140
232
  ? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
141
233
  : undefined;
142
234
  const apiKey = storedKey ?? getEnvApiKey("exa");
143
- if (!apiKey) {
144
- throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.");
145
- }
146
-
147
- const response = await callExaSearch(apiKey, params);
235
+ const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
148
236
 
149
237
  // Convert to unified SearchResponse
150
238
  const sources: SearchSource[] = [];
@@ -183,14 +271,30 @@ export class ExaProvider extends SearchProvider {
183
271
  readonly label = "Exa";
184
272
 
185
273
  isAvailable(authStorage: AuthStorage): boolean {
274
+ if (!this.#settingsAllowSearch()) return false;
275
+ return !!getEnvApiKey("exa") || authStorage.hasAuth("exa");
276
+ }
277
+
278
+ /**
279
+ * Exa ships an unauthenticated public MCP fallback, so an explicit
280
+ * selection (programmatic or via `providers.webSearch: exa`) routes
281
+ * through MCP even when no credential is configured. The auto chain
282
+ * still uses {@link isAvailable} so an unrelated configured provider
283
+ * keeps priority over the public fallback.
284
+ */
285
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
286
+ return this.#settingsAllowSearch();
287
+ }
288
+
289
+ #settingsAllowSearch(): boolean {
186
290
  try {
187
291
  if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) {
188
292
  return false;
189
293
  }
190
294
  } catch {
191
- // Settings may be unavailable before CLI initialization; credential availability is still authoritative.
295
+ // Settings may be unavailable before CLI initialization; assume not disabled.
192
296
  }
193
- return authStorage.hasAuth("exa");
297
+ return true;
194
298
  }
195
299
 
196
300
  search(params: SearchParams): Promise<SearchResponse> {
@@ -28,7 +28,7 @@ const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_
28
28
 
29
29
  const DEFAULT_MAX_TOKENS = 8192;
30
30
  const DEFAULT_TEMPERATURE = 0.2;
31
- const DEFAULT_NUM_SEARCH_RESULTS = 10;
31
+ const DEFAULT_NUM_SEARCH_RESULTS = 20;
32
32
  const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
33
33
  const OAUTH_API_VERSION = "2.18";
34
34
  const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
@@ -155,11 +155,11 @@ export interface PerplexitySearchParams {
155
155
  system_prompt?: string;
156
156
  search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
157
157
  num_results?: number;
158
- /** Maximum output tokens. Defaults to 4096. */
158
+ /** Maximum output tokens. Defaults to 8192. */
159
159
  max_tokens?: number;
160
160
  /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */
161
161
  temperature?: number;
162
- /** Number of search results to retrieve. Defaults to 10. */
162
+ /** Number of search results to retrieve. Defaults to 20. */
163
163
  num_search_results?: number;
164
164
  authStorage: AuthStorage;
165
165
  sessionId?: string;
@@ -470,11 +470,14 @@ function parseResponse(response: PerplexityResponse): SearchResponse {
470
470
  }
471
471
  }
472
472
 
473
+ const relatedQuestions = (response.related_questions ?? []).filter(q => q.trim().length > 0);
474
+
473
475
  return {
474
476
  provider: "perplexity",
475
477
  answer: answer || undefined,
476
478
  sources,
477
479
  citations: citations.length > 0 ? citations : undefined,
480
+ relatedQuestions: relatedQuestions.length > 0 ? relatedQuestions : undefined,
478
481
  usage: response.usage
479
482
  ? {
480
483
  inputTokens: response.usage.prompt_tokens,
@@ -532,11 +535,12 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
532
535
  num_search_results: params.num_search_results ?? DEFAULT_NUM_SEARCH_RESULTS,
533
536
  web_search_options: {
534
537
  search_type: "pro",
535
- search_context_size: "medium",
538
+ search_context_size: "high",
536
539
  },
537
540
  enable_search_classifier: true,
538
541
  reasoning_effort: "medium",
539
542
  language_preference: "en",
543
+ return_related_questions: true,
540
544
  };
541
545
 
542
546
  if (params.search_recency_filter) {
@@ -53,7 +53,7 @@ export const SEARCH_PROVIDER_OPTIONS = [
53
53
  description: "OpenAI's native web_search (uses ChatGPT OAuth via /login openai-codex)",
54
54
  },
55
55
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
56
- { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
56
+ { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
57
57
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
58
58
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
59
59
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
@@ -472,6 +472,7 @@ export interface PerplexityResponse {
472
472
  choices: PerplexityChoice[];
473
473
  citations?: string[] | null;
474
474
  search_results?: PerplexitySearchResult[] | null;
475
+ related_questions?: string[] | null;
475
476
  type?: PerplexityCompletionResponseType | null;
476
477
  status?: PerplexityCompletionResponseStatus | null;
477
478
  }