@nghyane/arcane 0.1.15 → 0.1.17
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 +21 -0
- package/package.json +7 -15
- package/src/config/keybindings.ts +9 -7
- package/src/config/settings-schema.ts +19 -46
- package/src/config/settings.ts +0 -1
- package/src/exa/mcp-client.ts +57 -2
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/internal-urls/index.ts +2 -4
- package/src/internal-urls/router.ts +2 -2
- package/src/internal-urls/types.ts +2 -2
- package/src/mcp/oauth-flow.ts +1 -1
- package/src/modes/controllers/command-controller.ts +26 -64
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/patch/hashline.ts +42 -0
- package/src/prompts/system/system-prompt.md +14 -10
- package/src/prompts/thread-extract.md +16 -0
- package/src/prompts/tools/render-mermaid.md +9 -0
- package/src/sdk.ts +1 -19
- package/src/session/agent-session.ts +4 -3
- package/src/session/retry-utils.ts +1 -1
- package/src/session/session-index.ts +329 -0
- package/src/slash-commands/builtin-registry.ts +0 -16
- package/src/task/index.ts +1 -1
- package/src/tools/ask.ts +9 -6
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/create-tools.ts +26 -0
- package/src/tools/find-thread.ts +120 -0
- package/src/tools/index.ts +5 -0
- package/src/tools/read-thread.ts +409 -0
- package/src/tools/read.ts +2 -2
- package/src/tools/render-mermaid.ts +68 -0
- package/src/tools/save-memory.ts +182 -0
- package/src/web/search/index.ts +2 -0
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/anthropic.ts +1 -0
- package/src/web/search/providers/gemini.ts +122 -37
- package/src/web/search/providers/kagi.ts +163 -0
- package/src/web/search/types.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +0 -133
- package/src/memories/index.ts +0 -1099
- package/src/memories/storage.ts +0 -563
- package/src/prompts/memories/consolidation.md +0 -30
- package/src/prompts/memories/read_path.md +0 -11
- package/src/prompts/memories/stage_one_input.md +0 -6
- package/src/prompts/memories/stage_one_system.md +0 -21
|
@@ -5,6 +5,7 @@ import { CodexProvider } from "./providers/codex";
|
|
|
5
5
|
import { ExaProvider } from "./providers/exa";
|
|
6
6
|
import { GeminiProvider } from "./providers/gemini";
|
|
7
7
|
import { JinaProvider } from "./providers/jina";
|
|
8
|
+
import { KagiProvider } from "./providers/kagi";
|
|
8
9
|
import { KimiProvider } from "./providers/kimi";
|
|
9
10
|
import { PerplexityProvider } from "./providers/perplexity";
|
|
10
11
|
import { SyntheticProvider } from "./providers/synthetic";
|
|
@@ -18,6 +19,7 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
|
|
|
18
19
|
exa: new ExaProvider(),
|
|
19
20
|
brave: new BraveProvider(),
|
|
20
21
|
jina: new JinaProvider(),
|
|
22
|
+
kagi: new KagiProvider(),
|
|
21
23
|
perplexity: new PerplexityProvider(),
|
|
22
24
|
kimi: new KimiProvider(),
|
|
23
25
|
zai: new ZaiProvider(),
|
|
@@ -32,6 +34,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
|
|
|
32
34
|
"exa",
|
|
33
35
|
"brave",
|
|
34
36
|
"jina",
|
|
37
|
+
"kagi",
|
|
35
38
|
"kimi",
|
|
36
39
|
"anthropic",
|
|
37
40
|
"gemini",
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
|
|
6
6
|
* Returns synthesized answers with citations and source metadata from grounding chunks.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
|
10
|
+
extractRetryDelay,
|
|
11
|
+
getAntigravityHeaders,
|
|
12
|
+
getGeminiCliHeaders,
|
|
13
|
+
refreshGoogleCloudToken,
|
|
14
|
+
} from "@nghyane/arcane-ai";
|
|
9
15
|
import { getAgentDbPath } from "@nghyane/arcane-utils/dirs";
|
|
10
16
|
import { AgentStorage } from "../../../session/agent-storage";
|
|
11
17
|
import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
|
|
@@ -14,10 +20,32 @@ import type { SearchParams } from "./base";
|
|
|
14
20
|
import { SearchProvider } from "./base";
|
|
15
21
|
|
|
16
22
|
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
17
|
-
const
|
|
23
|
+
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
24
|
+
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
25
|
+
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
|
|
18
26
|
const DEFAULT_MODEL = "gemini-2.5-flash";
|
|
27
|
+
const MAX_RETRIES = 3;
|
|
28
|
+
const BASE_DELAY_MS = 1000;
|
|
29
|
+
const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
interface GeminiToolParams {
|
|
32
|
+
google_search?: Record<string, unknown>;
|
|
33
|
+
code_execution?: Record<string, unknown>;
|
|
34
|
+
url_context?: Record<string, unknown>;
|
|
35
|
+
}
|
|
19
36
|
|
|
20
|
-
export
|
|
37
|
+
export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
|
|
38
|
+
const tools: Array<Record<string, Record<string, unknown>>> = [{ googleSearch: params.google_search ?? {} }];
|
|
39
|
+
if (params.code_execution !== undefined) {
|
|
40
|
+
tools.push({ codeExecution: params.code_execution });
|
|
41
|
+
}
|
|
42
|
+
if (params.url_context !== undefined) {
|
|
43
|
+
tools.push({ urlContext: params.url_context });
|
|
44
|
+
}
|
|
45
|
+
return tools;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GeminiSearchParams extends GeminiToolParams {
|
|
21
49
|
query: string;
|
|
22
50
|
system_prompt?: string;
|
|
23
51
|
num_results?: number;
|
|
@@ -55,8 +83,8 @@ export async function findGeminiAuth(): Promise<GeminiAuth | null> {
|
|
|
55
83
|
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
|
|
56
84
|
const now = Date.now();
|
|
57
85
|
|
|
58
|
-
// Try providers in order:
|
|
59
|
-
const providers = ["google-
|
|
86
|
+
// Try providers in order: gemini-cli first (deterministic), then antigravity
|
|
87
|
+
const providers = ["google-gemini-cli", "google-antigravity"] as const;
|
|
60
88
|
|
|
61
89
|
try {
|
|
62
90
|
const storage = await AgentStorage.open(getAgentDbPath());
|
|
@@ -180,6 +208,7 @@ async function callGeminiSearch(
|
|
|
180
208
|
systemPrompt?: string,
|
|
181
209
|
maxOutputTokens?: number,
|
|
182
210
|
temperature?: number,
|
|
211
|
+
toolParams: GeminiToolParams = {},
|
|
183
212
|
): Promise<{
|
|
184
213
|
answer: string;
|
|
185
214
|
sources: SearchSource[];
|
|
@@ -188,10 +217,20 @@ async function callGeminiSearch(
|
|
|
188
217
|
model: string;
|
|
189
218
|
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
190
219
|
}> {
|
|
191
|
-
const
|
|
192
|
-
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
|
220
|
+
const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
|
|
193
221
|
const headers = auth.isAntigravity ? getAntigravityHeaders() : getGeminiCliHeaders();
|
|
194
222
|
|
|
223
|
+
const normalizedSystemPrompt = systemPrompt?.toWellFormed();
|
|
224
|
+
const systemInstructionParts: Array<{ text: string }> = [
|
|
225
|
+
...(auth.isAntigravity
|
|
226
|
+
? [
|
|
227
|
+
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
|
228
|
+
{ text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
|
|
229
|
+
]
|
|
230
|
+
: []),
|
|
231
|
+
...(normalizedSystemPrompt ? [{ text: normalizedSystemPrompt }] : []),
|
|
232
|
+
];
|
|
233
|
+
|
|
195
234
|
const requestBody: Record<string, unknown> = {
|
|
196
235
|
project: auth.projectId,
|
|
197
236
|
model: DEFAULT_MODEL,
|
|
@@ -202,11 +241,11 @@ async function callGeminiSearch(
|
|
|
202
241
|
parts: [{ text: query }],
|
|
203
242
|
},
|
|
204
243
|
],
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
...(systemPrompt && {
|
|
244
|
+
tools: buildGeminiRequestTools(toolParams),
|
|
245
|
+
...(systemInstructionParts.length > 0 && {
|
|
208
246
|
systemInstruction: {
|
|
209
|
-
|
|
247
|
+
...(auth.isAntigravity ? { role: "user" } : {}),
|
|
248
|
+
parts: systemInstructionParts,
|
|
210
249
|
},
|
|
211
250
|
}),
|
|
212
251
|
},
|
|
@@ -225,31 +264,83 @@ async function callGeminiSearch(
|
|
|
225
264
|
(requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
|
|
226
265
|
}
|
|
227
266
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
267
|
+
// Retry loop with endpoint fallback and rate limit budgeting
|
|
268
|
+
let lastError: Error | undefined;
|
|
269
|
+
let totalDelayMs = 0;
|
|
270
|
+
|
|
271
|
+
for (const endpoint of endpoints) {
|
|
272
|
+
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
|
273
|
+
|
|
274
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await fetch(url, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: {
|
|
279
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
280
|
+
"Content-Type": "application/json",
|
|
281
|
+
Accept: "text/event-stream",
|
|
282
|
+
...headers,
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify(requestBody),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (response.ok) {
|
|
288
|
+
return await parseGeminiSSEResponse(response);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const errorText = await response.text();
|
|
292
|
+
|
|
293
|
+
// Non-retryable status codes
|
|
294
|
+
if (response.status >= 400 && response.status < 429) {
|
|
295
|
+
throw new SearchProviderError(
|
|
296
|
+
"gemini",
|
|
297
|
+
`Gemini Cloud Code API error (${response.status}): ${errorText}`,
|
|
298
|
+
response.status,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Rate limit or server error — retry with backoff
|
|
303
|
+
const serverDelay = extractRetryDelay(errorText, response);
|
|
304
|
+
const delay = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
|
|
305
|
+
totalDelayMs += delay;
|
|
306
|
+
|
|
307
|
+
if (totalDelayMs > RATE_LIMIT_BUDGET_MS) {
|
|
308
|
+
throw new SearchProviderError(
|
|
309
|
+
"gemini",
|
|
310
|
+
`Rate limit budget exhausted after ${Math.round(totalDelayMs / 1000)}s of delays`,
|
|
311
|
+
429,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
lastError = new SearchProviderError(
|
|
316
|
+
"gemini",
|
|
317
|
+
`Gemini Cloud Code API error (${response.status}): ${errorText}`,
|
|
318
|
+
response.status,
|
|
319
|
+
);
|
|
320
|
+
await Bun.sleep(delay);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err instanceof SearchProviderError) throw err;
|
|
323
|
+
lastError = err as Error;
|
|
324
|
+
break; // Network error — try next endpoint
|
|
325
|
+
}
|
|
326
|
+
}
|
|
246
327
|
}
|
|
247
328
|
|
|
329
|
+
throw lastError ?? new SearchProviderError("gemini", "All Gemini endpoints failed", 500);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function parseGeminiSSEResponse(response: Response): Promise<{
|
|
333
|
+
answer: string;
|
|
334
|
+
sources: SearchSource[];
|
|
335
|
+
citations: SearchCitation[];
|
|
336
|
+
searchQueries: string[];
|
|
337
|
+
model: string;
|
|
338
|
+
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
339
|
+
}> {
|
|
248
340
|
if (!response.body) {
|
|
249
341
|
throw new SearchProviderError("gemini", "Gemini API returned no response body", 500);
|
|
250
342
|
}
|
|
251
343
|
|
|
252
|
-
// Parse SSE stream
|
|
253
344
|
const answerParts: string[] = [];
|
|
254
345
|
const sources: SearchSource[] = [];
|
|
255
346
|
const citations: SearchCitation[] = [];
|
|
@@ -289,7 +380,6 @@ async function callGeminiSearch(
|
|
|
289
380
|
|
|
290
381
|
const candidate = responseData.candidates?.[0];
|
|
291
382
|
|
|
292
|
-
// Extract text content
|
|
293
383
|
if (candidate?.content?.parts) {
|
|
294
384
|
for (const part of candidate.content.parts) {
|
|
295
385
|
if (part.text) {
|
|
@@ -298,10 +388,8 @@ async function callGeminiSearch(
|
|
|
298
388
|
}
|
|
299
389
|
}
|
|
300
390
|
|
|
301
|
-
// Extract grounding metadata
|
|
302
391
|
const groundingMetadata = candidate?.groundingMetadata;
|
|
303
392
|
if (groundingMetadata) {
|
|
304
|
-
// Extract sources from grounding chunks
|
|
305
393
|
if (groundingMetadata.groundingChunks) {
|
|
306
394
|
for (const grChunk of groundingMetadata.groundingChunks) {
|
|
307
395
|
if (grChunk.web?.uri) {
|
|
@@ -317,7 +405,6 @@ async function callGeminiSearch(
|
|
|
317
405
|
}
|
|
318
406
|
}
|
|
319
407
|
|
|
320
|
-
// Extract citations from grounding supports
|
|
321
408
|
if (groundingMetadata.groundingSupports && groundingMetadata.groundingChunks) {
|
|
322
409
|
for (const support of groundingMetadata.groundingSupports) {
|
|
323
410
|
const citedText = support.segment?.text;
|
|
@@ -336,7 +423,6 @@ async function callGeminiSearch(
|
|
|
336
423
|
}
|
|
337
424
|
}
|
|
338
425
|
|
|
339
|
-
// Extract search queries
|
|
340
426
|
if (groundingMetadata.webSearchQueries) {
|
|
341
427
|
for (const q of groundingMetadata.webSearchQueries) {
|
|
342
428
|
if (!searchQueries.includes(q)) {
|
|
@@ -346,7 +432,6 @@ async function callGeminiSearch(
|
|
|
346
432
|
}
|
|
347
433
|
}
|
|
348
434
|
|
|
349
|
-
// Extract usage metadata
|
|
350
435
|
if (responseData.usageMetadata) {
|
|
351
436
|
usage = {
|
|
352
437
|
inputTokens: responseData.usageMetadata.promptTokenCount ?? 0,
|
|
@@ -355,7 +440,6 @@ async function callGeminiSearch(
|
|
|
355
440
|
};
|
|
356
441
|
}
|
|
357
442
|
|
|
358
|
-
// Extract model version
|
|
359
443
|
if (responseData.modelVersion) {
|
|
360
444
|
model = responseData.modelVersion;
|
|
361
445
|
}
|
|
@@ -396,6 +480,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
|
|
|
396
480
|
params.system_prompt,
|
|
397
481
|
params.max_output_tokens,
|
|
398
482
|
params.temperature,
|
|
483
|
+
params,
|
|
399
484
|
);
|
|
400
485
|
|
|
401
486
|
let sources = result.sources;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kagi Web Search Provider
|
|
3
|
+
*
|
|
4
|
+
* Calls Kagi's Search API (v0) and maps results into the unified
|
|
5
|
+
* SearchResponse shape used by the web search tool.
|
|
6
|
+
*/
|
|
7
|
+
import { getEnvApiKey } from "@nghyane/arcane-ai";
|
|
8
|
+
import type { SearchResponse, SearchSource } from "../types";
|
|
9
|
+
import { SearchProviderError } from "../types";
|
|
10
|
+
import type { SearchParams } from "./base";
|
|
11
|
+
import { SearchProvider } from "./base";
|
|
12
|
+
|
|
13
|
+
const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
|
|
14
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
15
|
+
const MAX_NUM_RESULTS = 40;
|
|
16
|
+
|
|
17
|
+
interface KagiSearchResult {
|
|
18
|
+
t: 0;
|
|
19
|
+
url: string;
|
|
20
|
+
title: string;
|
|
21
|
+
snippet?: string;
|
|
22
|
+
published?: string;
|
|
23
|
+
thumbnail?: {
|
|
24
|
+
url: string;
|
|
25
|
+
width?: number | null;
|
|
26
|
+
height?: number | null;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface KagiRelatedSearches {
|
|
31
|
+
t: 1;
|
|
32
|
+
list: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type KagiSearchObject = KagiSearchResult | KagiRelatedSearches;
|
|
36
|
+
|
|
37
|
+
interface KagiMeta {
|
|
38
|
+
id: string;
|
|
39
|
+
node: string;
|
|
40
|
+
ms: number;
|
|
41
|
+
api_balance?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface KagiSearchResponse {
|
|
45
|
+
meta: KagiMeta;
|
|
46
|
+
data: KagiSearchObject[];
|
|
47
|
+
error?: Array<{ code: number; msg: string; ref?: unknown }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clampNumResults(value: number | undefined): number {
|
|
51
|
+
if (!value || Number.isNaN(value)) return DEFAULT_NUM_RESULTS;
|
|
52
|
+
return Math.min(MAX_NUM_RESULTS, Math.max(1, value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined {
|
|
56
|
+
if (!dateStr) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
const date = new Date(dateStr);
|
|
59
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
60
|
+
return Math.floor((Date.now() - date.getTime()) / 1000);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Find KAGI_API_KEY from environment or .env files. */
|
|
67
|
+
export function findApiKey(): string | null {
|
|
68
|
+
return getEnvApiKey("kagi") ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function callKagiSearch(
|
|
72
|
+
apiKey: string,
|
|
73
|
+
query: string,
|
|
74
|
+
limit: number,
|
|
75
|
+
signal?: AbortSignal,
|
|
76
|
+
): Promise<KagiSearchResponse> {
|
|
77
|
+
const url = new URL(KAGI_SEARCH_URL);
|
|
78
|
+
url.searchParams.set("q", query);
|
|
79
|
+
url.searchParams.set("limit", String(limit));
|
|
80
|
+
|
|
81
|
+
const response = await fetch(url, {
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bot ${apiKey}`,
|
|
84
|
+
Accept: "application/json",
|
|
85
|
+
},
|
|
86
|
+
signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorText = await response.text();
|
|
91
|
+
throw new SearchProviderError("kagi", `Kagi API error (${response.status}): ${errorText}`, response.status);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data = (await response.json()) as KagiSearchResponse;
|
|
95
|
+
|
|
96
|
+
if (data.error && data.error.length > 0) {
|
|
97
|
+
const firstError = data.error[0];
|
|
98
|
+
throw new SearchProviderError("kagi", `Kagi API error: ${firstError.msg}`, firstError.code);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Execute Kagi web search. */
|
|
105
|
+
export async function searchKagi(params: {
|
|
106
|
+
query: string;
|
|
107
|
+
num_results?: number;
|
|
108
|
+
signal?: AbortSignal;
|
|
109
|
+
}): Promise<SearchResponse> {
|
|
110
|
+
const numResults = clampNumResults(params.num_results);
|
|
111
|
+
const apiKey = findApiKey();
|
|
112
|
+
if (!apiKey) {
|
|
113
|
+
throw new Error("KAGI_API_KEY not found. Set it in environment or .env file.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const data = await callKagiSearch(apiKey, params.query, numResults, params.signal);
|
|
117
|
+
|
|
118
|
+
const sources: SearchSource[] = [];
|
|
119
|
+
const relatedQuestions: string[] = [];
|
|
120
|
+
|
|
121
|
+
for (const item of data.data) {
|
|
122
|
+
if (item.t === 0) {
|
|
123
|
+
sources.push({
|
|
124
|
+
title: item.title,
|
|
125
|
+
url: item.url,
|
|
126
|
+
snippet: item.snippet,
|
|
127
|
+
publishedDate: item.published ?? undefined,
|
|
128
|
+
ageSeconds: dateToAgeSeconds(item.published),
|
|
129
|
+
});
|
|
130
|
+
} else if (item.t === 1) {
|
|
131
|
+
relatedQuestions.push(...item.list);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
provider: "kagi",
|
|
137
|
+
sources: sources.slice(0, numResults),
|
|
138
|
+
relatedQuestions: relatedQuestions.length > 0 ? relatedQuestions : undefined,
|
|
139
|
+
requestId: data.meta.id,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Search provider for Kagi web search. */
|
|
144
|
+
export class KagiProvider extends SearchProvider {
|
|
145
|
+
readonly id = "kagi";
|
|
146
|
+
readonly label = "Kagi";
|
|
147
|
+
|
|
148
|
+
isAvailable() {
|
|
149
|
+
try {
|
|
150
|
+
return !!findApiKey();
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
157
|
+
return searchKagi({
|
|
158
|
+
query: params.query,
|
|
159
|
+
num_results: params.numSearchResults ?? params.limit,
|
|
160
|
+
signal: params.signal,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/web/search/types.ts
CHANGED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { isEnoent } from "@nghyane/arcane-utils";
|
|
4
|
-
import { validateRelativePath } from "./skill-protocol";
|
|
5
|
-
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_MEMORY_FILE = "memory_summary.md";
|
|
8
|
-
const MEMORY_NAMESPACE = "root";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Options for the memory:// URL protocol.
|
|
12
|
-
*/
|
|
13
|
-
export interface MemoryProtocolOptions {
|
|
14
|
-
/**
|
|
15
|
-
* Returns the absolute path to the current project's memory root.
|
|
16
|
-
*/
|
|
17
|
-
getMemoryRoot: () => string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function ensureWithinRoot(targetPath: string, rootPath: string): void {
|
|
21
|
-
if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
|
|
22
|
-
throw new Error("memory:// URL escapes memory root");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function toMemoryValidationError(error: unknown): Error {
|
|
27
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
-
return new Error(message.replace("skill://", "memory://"));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Resolve a memory:// URL to an absolute filesystem path under memory root.
|
|
33
|
-
*/
|
|
34
|
-
export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): string {
|
|
35
|
-
const namespace = url.rawHost || url.hostname;
|
|
36
|
-
if (!namespace) {
|
|
37
|
-
throw new Error("memory:// URL requires a namespace: memory://root");
|
|
38
|
-
}
|
|
39
|
-
if (namespace !== MEMORY_NAMESPACE) {
|
|
40
|
-
throw new Error(`Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const rawPathname = url.rawPathname ?? url.pathname;
|
|
44
|
-
const hasPath = rawPathname && rawPathname !== "/" && rawPathname !== "";
|
|
45
|
-
if (!hasPath) {
|
|
46
|
-
return path.resolve(memoryRoot, DEFAULT_MEMORY_FILE);
|
|
47
|
-
}
|
|
48
|
-
let relativePath: string;
|
|
49
|
-
try {
|
|
50
|
-
relativePath = decodeURIComponent(rawPathname.slice(1));
|
|
51
|
-
} catch {
|
|
52
|
-
throw new Error(`Invalid URL encoding in memory:// path: ${url.href}`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
validateRelativePath(relativePath);
|
|
57
|
-
} catch (error) {
|
|
58
|
-
throw toMemoryValidationError(error);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return path.resolve(memoryRoot, relativePath);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Protocol handler for memory:// URLs.
|
|
66
|
-
*
|
|
67
|
-
* URL forms:
|
|
68
|
-
* - memory://root - Reads memory_summary.md
|
|
69
|
-
* - memory://root/<path> - Reads a relative file under memory root
|
|
70
|
-
*/
|
|
71
|
-
export class MemoryProtocolHandler implements ProtocolHandler {
|
|
72
|
-
readonly scheme = "memory";
|
|
73
|
-
|
|
74
|
-
constructor(private readonly options: MemoryProtocolOptions) {}
|
|
75
|
-
|
|
76
|
-
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
77
|
-
const memoryRoot = path.resolve(this.options.getMemoryRoot());
|
|
78
|
-
let resolvedRoot: string;
|
|
79
|
-
try {
|
|
80
|
-
resolvedRoot = await fs.realpath(memoryRoot);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (isEnoent(error)) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
"Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
throw error;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
|
|
91
|
-
ensureWithinRoot(targetPath, resolvedRoot);
|
|
92
|
-
|
|
93
|
-
const parentDir = path.dirname(targetPath);
|
|
94
|
-
try {
|
|
95
|
-
const realParent = await fs.realpath(parentDir);
|
|
96
|
-
ensureWithinRoot(realParent, resolvedRoot);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
if (!isEnoent(error)) {
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let realTargetPath: string;
|
|
104
|
-
try {
|
|
105
|
-
realTargetPath = await fs.realpath(targetPath);
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if (isEnoent(error)) {
|
|
108
|
-
throw new Error(`Memory file not found: ${url.href}`);
|
|
109
|
-
}
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
114
|
-
|
|
115
|
-
const stat = await fs.stat(realTargetPath);
|
|
116
|
-
if (!stat.isFile()) {
|
|
117
|
-
throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const content = await Bun.file(realTargetPath).text();
|
|
121
|
-
const ext = path.extname(realTargetPath).toLowerCase();
|
|
122
|
-
const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
url: url.href,
|
|
126
|
-
content,
|
|
127
|
-
contentType,
|
|
128
|
-
size: Buffer.byteLength(content, "utf-8"),
|
|
129
|
-
sourcePath: realTargetPath,
|
|
130
|
-
notes: [],
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
}
|