@oh-my-pi/pi-coding-agent 13.7.0 → 13.7.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.
@@ -0,0 +1,161 @@
1
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
+ import { findCredential } from "./search/providers/utils";
3
+
4
+ const KAGI_SUMMARIZE_URL = "https://kagi.com/api/v0/summarize";
5
+ const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
6
+
7
+ interface KagiSummarizeResponse {
8
+ data?: {
9
+ output?: string;
10
+ };
11
+ error?: Array<{
12
+ msg?: string;
13
+ }>;
14
+ }
15
+
16
+ interface KagiSearchResultObject {
17
+ t: 0;
18
+ url: string;
19
+ title: string;
20
+ snippet?: string;
21
+ published?: string;
22
+ }
23
+
24
+ interface KagiRelatedSearchesObject {
25
+ t: 1;
26
+ list: string[];
27
+ }
28
+
29
+ type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject;
30
+
31
+ interface KagiSearchResponse {
32
+ meta: {
33
+ id: string;
34
+ };
35
+ data: KagiSearchObject[];
36
+ error?: Array<{
37
+ code: number;
38
+ msg: string;
39
+ }>;
40
+ }
41
+
42
+ export class KagiApiError extends Error {
43
+ readonly statusCode?: number;
44
+
45
+ constructor(message: string, statusCode?: number) {
46
+ super(message);
47
+ this.name = "KagiApiError";
48
+ this.statusCode = statusCode;
49
+ }
50
+ }
51
+
52
+ export interface KagiSummarizeOptions {
53
+ engine?: string;
54
+ summaryType?: string;
55
+ targetLanguage?: string;
56
+ cache?: boolean;
57
+ signal?: AbortSignal;
58
+ }
59
+
60
+ export interface KagiSearchOptions {
61
+ limit?: number;
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ export interface KagiSearchSource {
66
+ title: string;
67
+ url: string;
68
+ snippet?: string;
69
+ publishedDate?: string;
70
+ }
71
+
72
+ export interface KagiSearchResult {
73
+ requestId: string;
74
+ sources: KagiSearchSource[];
75
+ relatedQuestions: string[];
76
+ }
77
+
78
+ export async function findKagiApiKey(): Promise<string | null> {
79
+ return findCredential(getEnvApiKey("kagi"), "kagi");
80
+ }
81
+
82
+ function getAuthHeaders(apiKey: string): Record<string, string> {
83
+ return {
84
+ Authorization: `Bot ${apiKey}`,
85
+ Accept: "application/json",
86
+ };
87
+ }
88
+
89
+ export async function summarizeUrlWithKagi(url: string, options: KagiSummarizeOptions = {}): Promise<string | null> {
90
+ const apiKey = await findKagiApiKey();
91
+ if (!apiKey) return null;
92
+
93
+ const requestUrl = new URL(KAGI_SUMMARIZE_URL);
94
+ requestUrl.searchParams.set("url", url);
95
+ requestUrl.searchParams.set("summary_type", options.summaryType ?? "summary");
96
+ if (options.engine) requestUrl.searchParams.set("engine", options.engine);
97
+ if (options.targetLanguage) requestUrl.searchParams.set("target_language", options.targetLanguage);
98
+ if (options.cache !== undefined) requestUrl.searchParams.set("cache", String(options.cache));
99
+
100
+ const response = await fetch(requestUrl, {
101
+ headers: getAuthHeaders(apiKey),
102
+ signal: options.signal,
103
+ });
104
+ if (!response.ok) return null;
105
+
106
+ const payload = (await response.json()) as KagiSummarizeResponse;
107
+ if (payload.error && payload.error.length > 0) return null;
108
+
109
+ const output = payload.data?.output?.trim();
110
+ return output && output.length > 0 ? output : null;
111
+ }
112
+
113
+ export async function searchWithKagi(query: string, options: KagiSearchOptions = {}): Promise<KagiSearchResult> {
114
+ const apiKey = await findKagiApiKey();
115
+ if (!apiKey) {
116
+ throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
117
+ }
118
+
119
+ const requestUrl = new URL(KAGI_SEARCH_URL);
120
+ requestUrl.searchParams.set("q", query);
121
+ if (options.limit !== undefined) {
122
+ requestUrl.searchParams.set("limit", String(options.limit));
123
+ }
124
+
125
+ const response = await fetch(requestUrl, {
126
+ headers: getAuthHeaders(apiKey),
127
+ signal: options.signal,
128
+ });
129
+ if (!response.ok) {
130
+ const errorText = await response.text();
131
+ throw new KagiApiError(`Kagi API error (${response.status}): ${errorText}`, response.status);
132
+ }
133
+
134
+ const payload = (await response.json()) as KagiSearchResponse;
135
+ if (payload.error && payload.error.length > 0) {
136
+ const firstError = payload.error[0];
137
+ throw new KagiApiError(`Kagi API error: ${firstError.msg}`, firstError.code);
138
+ }
139
+
140
+ const sources: KagiSearchSource[] = [];
141
+ const relatedQuestions: string[] = [];
142
+
143
+ for (const item of payload.data) {
144
+ if (item.t === 0) {
145
+ sources.push({
146
+ title: item.title,
147
+ url: item.url,
148
+ snippet: item.snippet,
149
+ publishedDate: item.published ?? undefined,
150
+ });
151
+ } else if (item.t === 1) {
152
+ relatedQuestions.push(...item.list);
153
+ }
154
+ }
155
+
156
+ return {
157
+ requestId: payload.meta.id,
158
+ sources,
159
+ relatedQuestions,
160
+ };
161
+ }
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { ptree, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import { throwIfAborted } from "../../tools/tool-errors";
6
6
  import { ensureTool } from "../../utils/tools-manager";
7
+ import { summarizeUrlWithKagi } from "../kagi";
7
8
  import type { RenderResult, SpecialHandler } from "./types";
8
9
  import { buildResult, formatMediaDuration, formatNumber } from "./types";
9
10
 
@@ -104,8 +105,28 @@ export const handleYouTube: SpecialHandler = async (
104
105
  const yt = parseYouTubeUrl(url);
105
106
  if (!yt) return null;
106
107
 
107
- // Ensure yt-dlp is available (auto-download if missing)
108
108
  const signal = ptree.combineSignals(userSignal, timeout * 1000);
109
+ const fetchedAt = new Date().toISOString();
110
+ const notes: string[] = [];
111
+ const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
112
+
113
+ // Prefer Kagi Universal Summarizer when credentials are available
114
+ try {
115
+ const kagiSummary = await summarizeUrlWithKagi(videoUrl, { signal });
116
+ if (kagiSummary && kagiSummary.length > 100) {
117
+ return buildResult(kagiSummary, {
118
+ url,
119
+ finalUrl: videoUrl,
120
+ method: "kagi",
121
+ fetchedAt,
122
+ notes: ["Used Kagi Universal Summarizer for YouTube"],
123
+ });
124
+ }
125
+ } catch {
126
+ throwIfAborted(signal);
127
+ }
128
+
129
+ // Ensure yt-dlp is available (auto-download if missing)
109
130
  const ytdlp = await ensureTool("yt-dlp", { signal, silent: true });
110
131
  if (!ytdlp) {
111
132
  return {
@@ -120,9 +141,6 @@ export const handleYouTube: SpecialHandler = async (
120
141
  };
121
142
  }
122
143
 
123
- const fetchedAt = new Date().toISOString();
124
- const notes: string[] = [];
125
- const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
126
144
  const execOptions = {
127
145
  mode: "group" as const,
128
146
  signal,
@@ -126,23 +126,21 @@ function formatCount(label: string, count: number): string {
126
126
  function formatForLLM(response: SearchResponse): string {
127
127
  const parts: string[] = [];
128
128
 
129
- parts.push("## Answer");
130
- parts.push(response.answer ? response.answer : "No answer text returned.");
131
-
132
- if (response.sources.length > 0) {
133
- parts.push("\n## Sources");
134
- parts.push(formatCount("source", response.sources.length));
135
- for (const [i, src] of response.sources.entries()) {
136
- const age = formatAge(src.ageSeconds) || src.publishedDate;
137
- const agePart = age ? ` (${age})` : "";
138
- parts.push(`[${i + 1}] ${src.title}${agePart}\n ${src.url}`);
139
- if (src.snippet) {
140
- parts.push(` ${truncateText(src.snippet, 240)}`);
141
- }
129
+ if (response.answer) {
130
+ parts.push(response.answer);
131
+ if (response.sources.length > 0) {
132
+ parts.push("\n## Sources");
133
+ parts.push(formatCount("source", response.sources.length));
134
+ }
135
+ }
136
+
137
+ for (const [i, src] of response.sources.entries()) {
138
+ const age = formatAge(src.ageSeconds) || src.publishedDate;
139
+ const agePart = age ? ` (${age})` : "";
140
+ parts.push(`[${i + 1}] ${src.title}${agePart}\n ${src.url}`);
141
+ if (src.snippet) {
142
+ parts.push(` ${truncateText(src.snippet, 240)}`);
142
143
  }
143
- } else {
144
- parts.push("\n## Sources");
145
- parts.push("0 sources");
146
144
  }
147
145
 
148
146
  if (response.citations && response.citations.length > 0) {
@@ -163,29 +161,8 @@ function formatForLLM(response: SearchResponse): string {
163
161
  for (const q of response.relatedQuestions) {
164
162
  parts.push(`- ${q}`);
165
163
  }
166
- } else {
167
- parts.push("\n## Related");
168
- parts.push("0 questions");
169
164
  }
170
165
 
171
- parts.push("\n## Meta");
172
- parts.push(`Provider: ${response.provider}`);
173
- if (response.model) {
174
- parts.push(`Model: ${response.model}`);
175
- }
176
- if (response.usage) {
177
- const usageParts: string[] = [];
178
- if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`);
179
- if (response.usage.outputTokens !== undefined) usageParts.push(`out ${response.usage.outputTokens}`);
180
- if (response.usage.totalTokens !== undefined) usageParts.push(`total ${response.usage.totalTokens}`);
181
- if (response.usage.searchRequests !== undefined) usageParts.push(`search ${response.usage.searchRequests}`);
182
- if (usageParts.length > 0) {
183
- parts.push(`Usage: ${usageParts.join(" | ")}`);
184
- }
185
- }
186
- if (response.requestId) {
187
- parts.push(`Request: ${truncateText(response.requestId, 64)}`);
188
- }
189
166
  if (response.searchQueries && response.searchQueries.length > 0) {
190
167
  parts.push(`Search queries: ${response.searchQueries.length}`);
191
168
  for (const query of response.searchQueries.slice(0, 3)) {
@@ -368,7 +345,7 @@ async function executeExaTool(
368
345
  toolName: string,
369
346
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: ExaRenderDetails }> {
370
347
  try {
371
- const apiKey = await findExaKey();
348
+ const apiKey = findExaKey();
372
349
  const response = await callExaTool(mcpToolName, params, apiKey);
373
350
 
374
351
  if (isSearchResponse(response)) {
@@ -581,7 +558,7 @@ export async function getSearchTools(options: SearchToolsOptions = {}): Promise<
581
558
  tools.push(webSearchDeepTool, webSearchCodeContextTool);
582
559
 
583
560
  // Advanced/add-on tools remain key-gated to avoid exposing known unauthenticated failures
584
- const exaKey = await findExaKey();
561
+ const exaKey = findExaKey();
585
562
  if (exaKey) {
586
563
  tools.push(webSearchCrawlTool);
587
564
 
@@ -1,91 +1,18 @@
1
1
  /**
2
2
  * Kagi Web Search Provider
3
3
  *
4
- * Calls Kagi's Search API (v0) and maps results into the unified
5
- * SearchResponse shape used by the web search tool.
4
+ * Thin wrapper that adapts shared Kagi API utilities to SearchResponse shape.
6
5
  */
7
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
- import type { SearchResponse, SearchSource } from "../../../web/search/types";
6
+ import type { SearchResponse } from "../../../web/search/types";
9
7
  import { SearchProviderError } from "../../../web/search/types";
8
+ import { findKagiApiKey, KagiApiError, searchWithKagi } from "../../kagi";
10
9
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
10
  import type { SearchParams } from "./base";
12
11
  import { SearchProvider } from "./base";
13
12
 
14
- const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
15
13
  const DEFAULT_NUM_RESULTS = 10;
16
14
  const MAX_NUM_RESULTS = 40;
17
15
 
18
- interface KagiSearchResult {
19
- t: 0;
20
- url: string;
21
- title: string;
22
- snippet?: string;
23
- published?: string;
24
- thumbnail?: {
25
- url: string;
26
- width?: number | null;
27
- height?: number | null;
28
- };
29
- }
30
-
31
- interface KagiRelatedSearches {
32
- t: 1;
33
- list: string[];
34
- }
35
-
36
- type KagiSearchObject = KagiSearchResult | KagiRelatedSearches;
37
-
38
- interface KagiMeta {
39
- id: string;
40
- node: string;
41
- ms: number;
42
- api_balance?: number;
43
- }
44
-
45
- interface KagiSearchResponse {
46
- meta: KagiMeta;
47
- data: KagiSearchObject[];
48
- error?: Array<{ code: number; msg: string; ref?: unknown }>;
49
- }
50
-
51
- /** Find KAGI_API_KEY from environment or .env files. */
52
- export function findApiKey(): string | null {
53
- return getEnvApiKey("kagi") ?? null;
54
- }
55
-
56
- async function callKagiSearch(
57
- apiKey: string,
58
- query: string,
59
- limit: number,
60
- signal?: AbortSignal,
61
- ): Promise<KagiSearchResponse> {
62
- const url = new URL(KAGI_SEARCH_URL);
63
- url.searchParams.set("q", query);
64
- url.searchParams.set("limit", String(limit));
65
-
66
- const response = await fetch(url, {
67
- headers: {
68
- Authorization: `Bot ${apiKey}`,
69
- Accept: "application/json",
70
- },
71
- signal,
72
- });
73
-
74
- if (!response.ok) {
75
- const errorText = await response.text();
76
- throw new SearchProviderError("kagi", `Kagi API error (${response.status}): ${errorText}`, response.status);
77
- }
78
-
79
- const data = (await response.json()) as KagiSearchResponse;
80
-
81
- if (data.error && data.error.length > 0) {
82
- const firstError = data.error[0];
83
- throw new SearchProviderError("kagi", `Kagi API error: ${firstError.msg}`, firstError.code);
84
- }
85
-
86
- return data;
87
- }
88
-
89
16
  /** Execute Kagi web search. */
90
17
  export async function searchKagi(params: {
91
18
  query: string;
@@ -93,36 +20,31 @@ export async function searchKagi(params: {
93
20
  signal?: AbortSignal;
94
21
  }): Promise<SearchResponse> {
95
22
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
96
- const apiKey = findApiKey();
97
- if (!apiKey) {
98
- throw new Error("KAGI_API_KEY not found. Set it in environment or .env file.");
99
- }
100
-
101
- const data = await callKagiSearch(apiKey, params.query, numResults, params.signal);
102
23
 
103
- const sources: SearchSource[] = [];
104
- const relatedQuestions: string[] = [];
24
+ try {
25
+ const result = await searchWithKagi(params.query, {
26
+ limit: numResults,
27
+ signal: params.signal,
28
+ });
105
29
 
106
- for (const item of data.data) {
107
- if (item.t === 0) {
108
- sources.push({
109
- title: item.title,
110
- url: item.url,
111
- snippet: item.snippet,
112
- publishedDate: item.published ?? undefined,
113
- ageSeconds: dateToAgeSeconds(item.published),
114
- });
115
- } else if (item.t === 1) {
116
- relatedQuestions.push(...item.list);
30
+ return {
31
+ provider: "kagi",
32
+ sources: result.sources.slice(0, numResults).map(source => ({
33
+ title: source.title,
34
+ url: source.url,
35
+ snippet: source.snippet,
36
+ publishedDate: source.publishedDate,
37
+ ageSeconds: dateToAgeSeconds(source.publishedDate),
38
+ })),
39
+ relatedQuestions: result.relatedQuestions.length > 0 ? result.relatedQuestions : undefined,
40
+ requestId: result.requestId,
41
+ };
42
+ } catch (err) {
43
+ if (err instanceof KagiApiError) {
44
+ throw new SearchProviderError("kagi", err.message, err.statusCode);
117
45
  }
46
+ throw err;
118
47
  }
119
-
120
- return {
121
- provider: "kagi",
122
- sources: sources.slice(0, numResults),
123
- relatedQuestions: relatedQuestions.length > 0 ? relatedQuestions : undefined,
124
- requestId: data.meta.id,
125
- };
126
48
  }
127
49
 
128
50
  /** Search provider for Kagi web search. */
@@ -130,9 +52,9 @@ export class KagiProvider extends SearchProvider {
130
52
  readonly id = "kagi";
131
53
  readonly label = "Kagi";
132
54
 
133
- isAvailable() {
55
+ async isAvailable() {
134
56
  try {
135
- return !!findApiKey();
57
+ return !!(await findKagiApiKey());
136
58
  } catch {
137
59
  return false;
138
60
  }