@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.
- package/CHANGELOG.md +67 -0
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/cli/update-cli.d.ts +15 -1
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/append-only-context-mode.d.ts +8 -0
- package/dist/types/config/model-registry.d.ts +5 -0
- package/dist/types/config/models-config-schema.d.ts +18 -0
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/exa/mcp-client.d.ts +2 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/json-rpc.d.ts +6 -1
- package/dist/types/mcp/manager.d.ts +1 -1
- package/dist/types/mcp/tool-bridge.d.ts +4 -0
- package/dist/types/mnemopi/state.d.ts +2 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +40 -8
- package/dist/types/modes/components/session-selector.d.ts +8 -3
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +13 -1
- package/dist/types/session/auth-storage.d.ts +2 -2
- package/dist/types/session/history-storage.d.ts +3 -4
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/slash-commands/types.d.ts +17 -4
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tiny/text.d.ts +17 -0
- package/dist/types/tools/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- package/dist/types/web/search/providers/base.d.ts +14 -0
- package/dist/types/web/search/providers/exa.d.ts +9 -0
- package/dist/types/web/search/providers/perplexity.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +2 -1
- package/package.json +9 -9
- package/src/cli/dry-balance-cli.ts +823 -0
- package/src/cli/session-picker.ts +1 -0
- package/src/cli/update-cli.ts +54 -2
- package/src/cli-commands.ts +1 -0
- package/src/commands/completions.ts +1 -1
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/append-only-context-mode.ts +37 -0
- package/src/config/model-registry.ts +6 -0
- package/src/config/models-config-schema.ts +3 -0
- package/src/config/settings-schema.ts +2 -2
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/exa/mcp-client.ts +11 -5
- package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/main.ts +4 -2
- package/src/mcp/json-rpc.ts +8 -0
- package/src/mcp/manager.ts +40 -21
- package/src/mcp/render.ts +3 -0
- package/src/mcp/tool-bridge.ts +10 -2
- package/src/mcp/transports/http.ts +33 -16
- package/src/mnemopi/state.ts +4 -4
- package/src/modes/acp/acp-agent.ts +168 -3
- package/src/modes/components/agent-dashboard.ts +103 -31
- package/src/modes/components/extensions/extension-dashboard.ts +56 -10
- package/src/modes/components/history-search.ts +128 -14
- package/src/modes/components/plugin-settings.ts +270 -36
- package/src/modes/components/session-selector.ts +45 -14
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/tips.txt +5 -1
- package/src/modes/components/transcript-container.ts +35 -6
- package/src/modes/components/tree-selector.ts +29 -2
- package/src/modes/controllers/command-controller.ts +4 -3
- package/src/modes/controllers/input-controller.ts +18 -7
- package/src/modes/controllers/selector-controller.ts +30 -19
- package/src/modes/interactive-mode.ts +38 -3
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/modes/utils/keybinding-matchers.ts +10 -0
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/prompts/steering/user-interjection.md +10 -0
- package/src/prompts/system/agent-creation-architect.md +1 -26
- package/src/prompts/system/system-prompt.md +143 -145
- package/src/prompts/system/title-system.md +3 -2
- package/src/prompts/tools/browser.md +29 -29
- package/src/prompts/tools/render-mermaid.md +2 -2
- package/src/sdk.ts +87 -30
- package/src/session/agent-session.ts +96 -14
- package/src/session/auth-storage.ts +4 -0
- package/src/session/history-storage.ts +11 -18
- package/src/session/messages.ts +80 -0
- package/src/session/session-manager.ts +7 -1
- package/src/slash-commands/types.ts +27 -10
- package/src/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -0
- package/src/tiny/text.ts +112 -1
- package/src/tools/bash.ts +3 -4
- package/src/tools/index.ts +16 -0
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-recall.ts +1 -1
- package/src/tools/memory-reflect.ts +3 -3
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/search.ts +18 -1
- package/src/tools/ssh.ts +26 -10
- package/src/tools/write.ts +14 -2
- package/src/tui/status-line.ts +15 -4
- package/src/utils/file-mentions.ts +7 -107
- package/src/utils/title-generator.ts +66 -38
- package/src/web/search/index.ts +3 -1
- package/src/web/search/provider.ts +1 -1
- package/src/web/search/providers/base.ts +17 -0
- package/src/web/search/providers/exa.ts +111 -7
- package/src/web/search/providers/perplexity.ts +8 -4
- 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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
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: [
|
|
221
|
-
messages: [{ role: "user", content:
|
|
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
|
|
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.
|
|
236
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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.
|
|
255
|
-
|
|
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;
|
package/src/web/search/index.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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;
|
|
295
|
+
// Settings may be unavailable before CLI initialization; assume not disabled.
|
|
192
296
|
}
|
|
193
|
-
return
|
|
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 =
|
|
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
|
|
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
|
|
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: "
|
|
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) {
|
package/src/web/search/types.ts
CHANGED
|
@@ -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: "
|
|
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
|
}
|