@oh-my-pi/pi-coding-agent 13.2.1 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/package.json +7 -7
  3. package/scripts/generate-docs-index.ts +2 -2
  4. package/src/cli/args.ts +2 -1
  5. package/src/config/settings-schema.ts +36 -4
  6. package/src/config/settings.ts +10 -0
  7. package/src/discovery/claude.ts +24 -6
  8. package/src/ipy/runtime.ts +1 -0
  9. package/src/mcp/config.ts +1 -1
  10. package/src/modes/components/settings-defs.ts +17 -1
  11. package/src/modes/components/status-line.ts +7 -5
  12. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  13. package/src/modes/controllers/selector-controller.ts +21 -0
  14. package/src/modes/interactive-mode.ts +9 -0
  15. package/src/modes/oauth-manual-input.ts +42 -0
  16. package/src/modes/types.ts +2 -0
  17. package/src/patch/hashline.ts +19 -1
  18. package/src/prompts/system/commit-message-system.md +2 -0
  19. package/src/prompts/system/subagent-submit-reminder.md +3 -3
  20. package/src/prompts/system/subagent-system-prompt.md +4 -4
  21. package/src/prompts/system/system-prompt.md +13 -0
  22. package/src/prompts/tools/hashline.md +45 -1
  23. package/src/prompts/tools/task-summary.md +4 -4
  24. package/src/prompts/tools/task.md +1 -1
  25. package/src/sdk.ts +3 -0
  26. package/src/slash-commands/builtin-registry.ts +26 -1
  27. package/src/system-prompt.ts +4 -0
  28. package/src/task/index.ts +211 -70
  29. package/src/task/render.ts +24 -8
  30. package/src/task/types.ts +6 -1
  31. package/src/task/worktree.ts +394 -31
  32. package/src/tools/submit-result.ts +22 -23
  33. package/src/utils/commit-message-generator.ts +132 -0
  34. package/src/web/search/providers/exa.ts +41 -4
  35. 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
- /** Call Exa Search API */
54
- async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<ExaSearchResponse> {
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 ?? result.highlights?.join(" ") ?? undefined,
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 two auth modes:
5
- * - API key (`PERPLEXITY_API_KEY`) via `api.perplexity.ai/chat/completions`
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
- oauthToken: string,
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 ${oauthToken}`,
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.token, params);
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",