@oh-my-pi/pi-coding-agent 13.2.1 → 13.3.1
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 +43 -2
- package/package.json +7 -7
- package/scripts/generate-docs-index.ts +2 -2
- package/src/cli/args.ts +2 -1
- package/src/cli/config-cli.ts +32 -20
- package/src/config/settings-schema.ts +96 -14
- package/src/config/settings.ts +10 -0
- package/src/discovery/claude.ts +24 -6
- package/src/discovery/helpers.ts +9 -2
- package/src/ipy/runtime.ts +1 -0
- package/src/mcp/config.ts +1 -1
- package/src/modes/components/settings-defs.ts +53 -1
- package/src/modes/components/status-line.ts +7 -5
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/controllers/selector-controller.ts +46 -0
- package/src/modes/interactive-mode.ts +9 -0
- package/src/modes/oauth-manual-input.ts +42 -0
- package/src/modes/types.ts +2 -0
- package/src/patch/hashline.ts +19 -1
- package/src/patch/index.ts +7 -8
- package/src/prompts/system/commit-message-system.md +2 -0
- package/src/prompts/system/subagent-submit-reminder.md +3 -3
- package/src/prompts/system/subagent-system-prompt.md +4 -4
- package/src/prompts/system/system-prompt.md +13 -0
- package/src/prompts/tools/hashline.md +45 -1
- package/src/prompts/tools/task-summary.md +4 -4
- package/src/prompts/tools/task.md +1 -1
- package/src/sdk.ts +8 -0
- package/src/slash-commands/builtin-registry.ts +26 -1
- package/src/system-prompt.ts +4 -0
- package/src/task/index.ts +211 -70
- package/src/task/render.ts +44 -16
- package/src/task/types.ts +6 -1
- package/src/task/worktree.ts +394 -31
- package/src/tools/review.ts +50 -1
- package/src/tools/submit-result.ts +22 -23
- package/src/utils/commit-message-generator.ts +132 -0
- package/src/web/search/providers/exa.ts +41 -4
- package/src/web/search/providers/perplexity.ts +20 -8
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate commit messages from diffs using a smol, fast model.
|
|
3
|
+
* Follows the same pattern as title-generator.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
6
|
+
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import type { ModelRegistry } from "../config/model-registry";
|
|
9
|
+
import { parseModelString } from "../config/model-resolver";
|
|
10
|
+
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
11
|
+
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
|
12
|
+
import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
|
|
13
|
+
|
|
14
|
+
const COMMIT_SYSTEM_PROMPT = renderPromptTemplate(commitSystemPrompt);
|
|
15
|
+
const MAX_DIFF_CHARS = 4000;
|
|
16
|
+
|
|
17
|
+
/** File patterns that should be excluded from commit message generation diffs. */
|
|
18
|
+
const NOISE_SUFFIXES = [".lock", ".lockb", "-lock.json", "-lock.yaml"];
|
|
19
|
+
|
|
20
|
+
/** Strip diff hunks for noisy files that drown out real changes. */
|
|
21
|
+
function filterDiffNoise(diff: string): string {
|
|
22
|
+
const lines = diff.split("\n");
|
|
23
|
+
const filtered: string[] = [];
|
|
24
|
+
let skip = false;
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (line.startsWith("diff --git ")) {
|
|
27
|
+
const bPath = line.split(" b/")[1];
|
|
28
|
+
skip = bPath != null && NOISE_SUFFIXES.some(s => bPath.endsWith(s));
|
|
29
|
+
}
|
|
30
|
+
if (!skip) filtered.push(line);
|
|
31
|
+
}
|
|
32
|
+
return filtered.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getSmolModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
|
|
36
|
+
const availableModels = registry.getAvailable();
|
|
37
|
+
if (availableModels.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
const candidates: Model<Api>[] = [];
|
|
40
|
+
const addCandidate = (model?: Model<Api>): void => {
|
|
41
|
+
if (!model) return;
|
|
42
|
+
if (candidates.some(c => c.provider === model.provider && c.id === model.id)) return;
|
|
43
|
+
candidates.push(model);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (savedSmolModel) {
|
|
47
|
+
const parsed = parseModelString(savedSmolModel);
|
|
48
|
+
if (parsed) {
|
|
49
|
+
const match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
50
|
+
addCandidate(match);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const pattern of MODEL_PRIO.smol) {
|
|
55
|
+
const needle = pattern.toLowerCase();
|
|
56
|
+
addCandidate(availableModels.find(m => m.id.toLowerCase() === needle));
|
|
57
|
+
addCandidate(availableModels.find(m => m.id.toLowerCase().includes(needle)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const model of availableModels) {
|
|
61
|
+
addCandidate(model);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return candidates;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate a commit message from a unified diff.
|
|
69
|
+
* Returns null if generation fails (caller should fall back to generic message).
|
|
70
|
+
*/
|
|
71
|
+
export async function generateCommitMessage(
|
|
72
|
+
diff: string,
|
|
73
|
+
registry: ModelRegistry,
|
|
74
|
+
savedSmolModel?: string,
|
|
75
|
+
sessionId?: string,
|
|
76
|
+
): Promise<string | null> {
|
|
77
|
+
const candidates = getSmolModelCandidates(registry, savedSmolModel);
|
|
78
|
+
if (candidates.length === 0) {
|
|
79
|
+
logger.debug("commit-msg-generator: no smol model found");
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cleanDiff = filterDiffNoise(diff);
|
|
84
|
+
const truncatedDiff =
|
|
85
|
+
cleanDiff.length > MAX_DIFF_CHARS ? `${cleanDiff.slice(0, MAX_DIFF_CHARS)}\n… (truncated)` : cleanDiff;
|
|
86
|
+
if (!truncatedDiff.trim()) {
|
|
87
|
+
logger.debug("commit-msg-generator: diff is empty after noise filtering");
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const userMessage = `<diff>\n${truncatedDiff}\n</diff>`;
|
|
91
|
+
|
|
92
|
+
for (const model of candidates) {
|
|
93
|
+
const apiKey = await registry.getApiKey(model, sessionId);
|
|
94
|
+
if (!apiKey) continue;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await completeSimple(
|
|
98
|
+
model,
|
|
99
|
+
{
|
|
100
|
+
systemPrompt: COMMIT_SYSTEM_PROMPT,
|
|
101
|
+
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
|
102
|
+
},
|
|
103
|
+
{ apiKey, maxTokens: 60 },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (response.stopReason === "error") {
|
|
107
|
+
logger.debug("commit-msg-generator: error", { model: model.id, error: response.errorMessage });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let msg = "";
|
|
112
|
+
for (const content of response.content) {
|
|
113
|
+
if (content.type === "text") msg += content.text;
|
|
114
|
+
}
|
|
115
|
+
msg = msg.trim();
|
|
116
|
+
if (!msg) continue;
|
|
117
|
+
|
|
118
|
+
// Clean up: remove wrapping quotes, backticks, trailing period
|
|
119
|
+
msg = msg.replace(/^[`"']|[`"']$/g, "").replace(/\.$/, "");
|
|
120
|
+
|
|
121
|
+
logger.debug("commit-msg-generator: generated", { model: model.id, msg });
|
|
122
|
+
return msg;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.debug("commit-msg-generator: error", {
|
|
125
|
+
model: model.id,
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* High-quality neural search via Exa Search API.
|
|
5
5
|
* Returns structured search results with optional content extraction.
|
|
6
|
+
* Requests per-result summaries via `contents.summary` and synthesizes
|
|
7
|
+
* them into a combined `answer` string on the SearchResponse.
|
|
6
8
|
*/
|
|
7
9
|
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
8
10
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
@@ -34,6 +36,7 @@ interface ExaSearchResult {
|
|
|
34
36
|
publishedDate?: string | null;
|
|
35
37
|
text?: string | null;
|
|
36
38
|
highlights?: string[] | null;
|
|
39
|
+
summary?: string | null;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
interface ExaSearchResponse {
|
|
@@ -44,18 +47,41 @@ interface ExaSearchResponse {
|
|
|
44
47
|
searchTime?: number;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
|
|
50
|
+
export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
|
|
48
51
|
if (!type) return "auto";
|
|
49
52
|
if (type === "keyword") return "fast";
|
|
50
53
|
return type;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
56
|
+
/** Maximum number of per-result summaries to include in the synthesized answer. */
|
|
57
|
+
const MAX_ANSWER_SUMMARIES = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Synthesize an answer string from per-result summaries returned by Exa.
|
|
61
|
+
* Returns `undefined` when no non-empty summaries are available so callers
|
|
62
|
+
* can leave `SearchResponse.answer` unset (matching other providers).
|
|
63
|
+
*/
|
|
64
|
+
export function synthesizeAnswer(results: ExaSearchResult[]): string | undefined {
|
|
65
|
+
const parts: string[] = [];
|
|
66
|
+
for (const r of results) {
|
|
67
|
+
if (parts.length >= MAX_ANSWER_SUMMARIES) break;
|
|
68
|
+
const summary = r.summary?.trim();
|
|
69
|
+
if (!summary) continue;
|
|
70
|
+
const title = r.title?.trim() || r.url || "Untitled";
|
|
71
|
+
parts.push(`**${title}**: ${summary}`);
|
|
72
|
+
}
|
|
73
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the request body for `callExaSearch`. Exported for testing. */
|
|
77
|
+
export function buildExaRequestBody(params: ExaSearchParams): Record<string, unknown> {
|
|
55
78
|
const body: Record<string, unknown> = {
|
|
56
79
|
query: params.query,
|
|
57
80
|
numResults: params.num_results ?? 10,
|
|
58
81
|
type: normalizeSearchType(params.type),
|
|
82
|
+
contents: {
|
|
83
|
+
summary: { query: params.query },
|
|
84
|
+
},
|
|
59
85
|
};
|
|
60
86
|
|
|
61
87
|
if (params.include_domains?.length) {
|
|
@@ -71,6 +97,13 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
|
|
71
97
|
body.endPublishedDate = params.end_published_date;
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
return body;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Call Exa Search API */
|
|
104
|
+
async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<ExaSearchResponse> {
|
|
105
|
+
const body = buildExaRequestBody(params);
|
|
106
|
+
|
|
74
107
|
const response = await fetch(EXA_API_URL, {
|
|
75
108
|
method: "POST",
|
|
76
109
|
headers: {
|
|
@@ -106,7 +139,7 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
|
|
|
106
139
|
sources.push({
|
|
107
140
|
title: result.title ?? result.url,
|
|
108
141
|
url: result.url,
|
|
109
|
-
snippet: result.text
|
|
142
|
+
snippet: result.summary || result.text || result.highlights?.join(" ") || undefined,
|
|
110
143
|
publishedDate: result.publishedDate ?? undefined,
|
|
111
144
|
ageSeconds: dateToAgeSeconds(result.publishedDate ?? undefined),
|
|
112
145
|
author: result.author ?? undefined,
|
|
@@ -117,8 +150,12 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
|
|
|
117
150
|
// Apply num_results limit if specified
|
|
118
151
|
const limitedSources = params.num_results ? sources.slice(0, params.num_results) : sources;
|
|
119
152
|
|
|
153
|
+
// Synthesize answer only from results that have a URL (same guard as sources loop)
|
|
154
|
+
const answer = response.results ? synthesizeAnswer(response.results.filter(r => !!r.url)) : undefined;
|
|
155
|
+
|
|
120
156
|
return {
|
|
121
157
|
provider: "exa",
|
|
158
|
+
answer,
|
|
122
159
|
sources: limitedSources,
|
|
123
160
|
requestId: response.requestId,
|
|
124
161
|
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Perplexity Web Search Provider
|
|
3
3
|
*
|
|
4
|
-
* Supports
|
|
5
|
-
* -
|
|
4
|
+
* Supports three auth modes:
|
|
5
|
+
* - Cookies (`PERPLEXITY_COOKIES`) via `www.perplexity.ai/rest/sse/perplexity_ask`
|
|
6
6
|
* - OAuth JWT (stored in `agent.db`) via `www.perplexity.ai/rest/sse/perplexity_ask`
|
|
7
|
+
* - API key (`PERPLEXITY_API_KEY`) via `api.perplexity.ai/chat/completions`
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
10
|
-
import { getAgentDbPath, readSseJson } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { $env, getAgentDbPath, readSseJson } from "@oh-my-pi/pi-utils";
|
|
11
12
|
import { AgentStorage } from "../../../session/agent-storage";
|
|
12
13
|
import type {
|
|
13
14
|
PerplexityMessageOutput,
|
|
@@ -46,6 +47,10 @@ type PerplexityAuth =
|
|
|
46
47
|
| {
|
|
47
48
|
type: "oauth";
|
|
48
49
|
token: string;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
type: "cookies";
|
|
53
|
+
cookies: string;
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
interface PerplexityOAuthStreamMarkdownBlock {
|
|
@@ -188,10 +193,17 @@ async function findOAuthToken(): Promise<string | null> {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
async function findPerplexityAuth(): Promise<PerplexityAuth | null> {
|
|
196
|
+
// 1. PERPLEXITY_COOKIES env var
|
|
197
|
+
const cookies = $env.PERPLEXITY_COOKIES?.trim();
|
|
198
|
+
if (cookies) {
|
|
199
|
+
return { type: "cookies", cookies };
|
|
200
|
+
}
|
|
201
|
+
// 2. OAuth token from agent.db
|
|
191
202
|
const oauthToken = await findOAuthToken();
|
|
192
203
|
if (oauthToken) {
|
|
193
204
|
return { type: "oauth", token: oauthToken };
|
|
194
205
|
}
|
|
206
|
+
// 3. PERPLEXITY_API_KEY env var
|
|
195
207
|
const apiKey = findApiKey();
|
|
196
208
|
if (apiKey) {
|
|
197
209
|
return { type: "api_key", token: apiKey };
|
|
@@ -289,7 +301,7 @@ function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string {
|
|
|
289
301
|
}
|
|
290
302
|
|
|
291
303
|
async function callPerplexityOAuth(
|
|
292
|
-
|
|
304
|
+
auth: { type: "oauth"; token: string } | { type: "cookies"; cookies: string },
|
|
293
305
|
params: PerplexitySearchParams,
|
|
294
306
|
): Promise<{ answer: string; sources: SearchSource[]; model?: string; requestId?: string }> {
|
|
295
307
|
const requestId = crypto.randomUUID();
|
|
@@ -298,7 +310,7 @@ async function callPerplexityOAuth(
|
|
|
298
310
|
const response = await fetch(PERPLEXITY_OAUTH_ASK_URL, {
|
|
299
311
|
method: "POST",
|
|
300
312
|
headers: {
|
|
301
|
-
Authorization: `Bearer ${
|
|
313
|
+
...(auth.type === "cookies" ? { Cookie: auth.cookies } : { Authorization: `Bearer ${auth.token}` }),
|
|
302
314
|
"Content-Type": "application/json",
|
|
303
315
|
Accept: "text/event-stream",
|
|
304
316
|
Origin: "https://www.perplexity.ai",
|
|
@@ -455,11 +467,11 @@ function applySourceLimit(result: SearchResponse, limit?: number): SearchRespons
|
|
|
455
467
|
export async function searchPerplexity(params: PerplexitySearchParams): Promise<SearchResponse> {
|
|
456
468
|
const auth = await findPerplexityAuth();
|
|
457
469
|
if (!auth) {
|
|
458
|
-
throw new Error("Perplexity auth not found. Set PERPLEXITY_API_KEY or login via OAuth.");
|
|
470
|
+
throw new Error("Perplexity auth not found. Set PERPLEXITY_COOKIES, PERPLEXITY_API_KEY, or login via OAuth.");
|
|
459
471
|
}
|
|
460
472
|
|
|
461
|
-
if (auth.type === "oauth") {
|
|
462
|
-
const oauthResult = await callPerplexityOAuth(auth
|
|
473
|
+
if (auth.type === "oauth" || auth.type === "cookies") {
|
|
474
|
+
const oauthResult = await callPerplexityOAuth(auth, params);
|
|
463
475
|
return applySourceLimit(
|
|
464
476
|
{
|
|
465
477
|
provider: "perplexity",
|