@undefineds.co/linx 0.3.4 → 0.3.7
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/README.md +58 -23
- package/dist/generated/version.js +1 -1
- package/dist/generated/version.js.map +1 -1
- package/dist/index.js +334 -162
- package/dist/index.js.map +1 -1
- package/dist/lib/account-session.js +4 -8
- package/dist/lib/account-session.js.map +1 -1
- package/dist/lib/ai-command.js +228 -178
- package/dist/lib/ai-command.js.map +1 -1
- package/dist/lib/auto-mode/archive.js +38 -7
- package/dist/lib/auto-mode/archive.js.map +1 -1
- package/dist/lib/auto-mode/auth.js.map +1 -1
- package/dist/lib/auto-mode/display.js +71 -45
- package/dist/lib/auto-mode/display.js.map +1 -1
- package/dist/lib/auto-mode/format.js +9 -7
- package/dist/lib/auto-mode/format.js.map +1 -1
- package/dist/lib/auto-mode/hooks/claude.js +12 -2
- package/dist/lib/auto-mode/hooks/claude.js.map +1 -1
- package/dist/lib/auto-mode/hooks/codex.js +17 -7
- package/dist/lib/auto-mode/hooks/codex.js.map +1 -1
- package/dist/lib/auto-mode/hooks/index.js +28 -8
- package/dist/lib/auto-mode/hooks/index.js.map +1 -1
- package/dist/lib/auto-mode/pod-ai.js +20 -37
- package/dist/lib/auto-mode/pod-ai.js.map +1 -1
- package/dist/lib/auto-mode/pod-approval.js +124 -195
- package/dist/lib/auto-mode/pod-approval.js.map +1 -1
- package/dist/lib/auto-mode/pod-persistence.js +169 -90
- package/dist/lib/auto-mode/pod-persistence.js.map +1 -1
- package/dist/lib/auto-mode/runner.js +683 -81
- package/dist/lib/auto-mode/runner.js.map +1 -1
- package/dist/lib/auto-mode/secretary.js +186 -41
- package/dist/lib/auto-mode/secretary.js.map +1 -1
- package/dist/lib/auto-mode-command.js +32 -32
- package/dist/lib/auto-mode-command.js.map +1 -1
- package/dist/lib/chat-api.js +242 -50
- package/dist/lib/chat-api.js.map +1 -1
- package/dist/lib/codex-plugin/bridge.js +164 -17
- package/dist/lib/codex-plugin/bridge.js.map +1 -1
- package/dist/lib/codex-plugin/codex-native-proxy.js +370 -34
- package/dist/lib/codex-plugin/codex-native-proxy.js.map +1 -1
- package/dist/lib/credentials-store.js +33 -42
- package/dist/lib/credentials-store.js.map +1 -1
- package/dist/lib/linx-cloud-errors.js +61 -0
- package/dist/lib/linx-cloud-errors.js.map +1 -0
- package/dist/lib/linx-tui-contract.js +8 -5
- package/dist/lib/linx-tui-contract.js.map +1 -1
- package/dist/lib/login-command.js +9 -2
- package/dist/lib/login-command.js.map +1 -1
- package/dist/lib/models.js +3 -20
- package/dist/lib/models.js.map +1 -1
- package/dist/lib/oidc-auth.js +143 -17
- package/dist/lib/oidc-auth.js.map +1 -1
- package/dist/lib/oidc-session-storage.js +2 -6
- package/dist/lib/oidc-session-storage.js.map +1 -1
- package/dist/lib/pi-adapter/auto-input-controller.js +988 -0
- package/dist/lib/pi-adapter/auto-input-controller.js.map +1 -0
- package/dist/lib/pi-adapter/backend-command.js +2 -0
- package/dist/lib/pi-adapter/backend-command.js.map +1 -0
- package/dist/lib/pi-adapter/backend-credentials.js +80 -0
- package/dist/lib/pi-adapter/backend-credentials.js.map +1 -0
- package/dist/lib/pi-adapter/branding.js +246 -108
- package/dist/lib/pi-adapter/branding.js.map +1 -1
- package/dist/lib/pi-adapter/control-state.js +72 -0
- package/dist/lib/pi-adapter/control-state.js.map +1 -0
- package/dist/lib/pi-adapter/interactive.js +2634 -30
- package/dist/lib/pi-adapter/interactive.js.map +1 -1
- package/dist/lib/pi-adapter/pod-approval.js +382 -210
- package/dist/lib/pi-adapter/pod-approval.js.map +1 -1
- package/dist/lib/pi-adapter/pod-mirror-mapping.js +71 -17
- package/dist/lib/pi-adapter/pod-mirror-mapping.js.map +1 -1
- package/dist/lib/pi-adapter/pod-mirror.js +531 -64
- package/dist/lib/pi-adapter/pod-mirror.js.map +1 -1
- package/dist/lib/pi-adapter/pod-native.js +81 -85
- package/dist/lib/pi-adapter/pod-native.js.map +1 -1
- package/dist/lib/pi-adapter/pod-status-output.js +54 -0
- package/dist/lib/pi-adapter/pod-status-output.js.map +1 -0
- package/dist/lib/pi-adapter/runtime.js +458 -228
- package/dist/lib/pi-adapter/runtime.js.map +1 -1
- package/dist/lib/pi-adapter/session-control.js +509 -0
- package/dist/lib/pi-adapter/session-control.js.map +1 -0
- package/dist/lib/pi-adapter/session.js +35 -22
- package/dist/lib/pi-adapter/session.js.map +1 -1
- package/dist/lib/pi-adapter/stream.js +89 -32
- package/dist/lib/pi-adapter/stream.js.map +1 -1
- package/dist/lib/pi-adapter/sync-recovery.js +89 -0
- package/dist/lib/pi-adapter/sync-recovery.js.map +1 -0
- package/dist/lib/pi-adapter/web-fetch.js +13 -14
- package/dist/lib/pi-adapter/web-fetch.js.map +1 -1
- package/dist/lib/pod-chat-store.js +254 -78
- package/dist/lib/pod-chat-store.js.map +1 -1
- package/dist/lib/pod-data-session.js +156 -35
- package/dist/lib/pod-data-session.js.map +1 -1
- package/dist/lib/solid-auth-store.js +27 -0
- package/dist/lib/solid-auth-store.js.map +1 -0
- package/dist/lib/solid-auth.js +2 -4
- package/dist/lib/solid-auth.js.map +1 -1
- package/dist/lib/solid-client-credentials-login.js +100 -0
- package/dist/lib/solid-client-credentials-login.js.map +1 -0
- package/dist/lib/solid-local-store.js +31 -0
- package/dist/lib/solid-local-store.js.map +1 -0
- package/dist/lib/symphony/archive.js +328 -18
- package/dist/lib/symphony/archive.js.map +1 -1
- package/dist/lib/symphony/pod-projection.js +2222 -0
- package/dist/lib/symphony/pod-projection.js.map +1 -0
- package/dist/lib/symphony-command.js +602 -178
- package/dist/lib/symphony-command.js.map +1 -1
- package/dist/lib/sync-checkpoint-store.js +74 -0
- package/dist/lib/sync-checkpoint-store.js.map +1 -0
- package/dist/skills/symphony/SKILL.md +665 -0
- package/package.json +15 -9
- package/vendor/agent-runtime/dist/agent-runtime.d.ts +137 -0
- package/vendor/agent-runtime/dist/agent-runtime.js +211 -0
- package/vendor/agent-runtime/dist/auto-mode.d.ts +78 -13
- package/vendor/agent-runtime/dist/auto-mode.js +288 -31
- package/vendor/agent-runtime/dist/control-plane.d.ts +28 -0
- package/vendor/agent-runtime/dist/control-plane.js +79 -0
- package/vendor/agent-runtime/dist/file-sync.d.ts +157 -0
- package/vendor/agent-runtime/dist/file-sync.js +314 -0
- package/vendor/agent-runtime/dist/index.d.ts +7 -0
- package/vendor/agent-runtime/dist/index.js +7 -0
- package/vendor/agent-runtime/dist/reconciler.d.ts +117 -0
- package/vendor/agent-runtime/dist/reconciler.js +361 -0
- package/vendor/agent-runtime/dist/symphony.d.ts +128 -8
- package/vendor/agent-runtime/dist/symphony.js +362 -57
- package/vendor/agent-runtime/dist/sync.d.ts +271 -0
- package/vendor/agent-runtime/dist/sync.js +550 -0
- package/vendor/agent-runtime/dist/thread-reconciler-controller.d.ts +58 -0
- package/vendor/agent-runtime/dist/thread-reconciler-controller.js +137 -0
- package/vendor/agent-runtime/dist/turn-controller.js +2 -2
- package/vendor/agent-runtime/dist/wake-scheduler.d.ts +67 -0
- package/vendor/agent-runtime/dist/wake-scheduler.js +194 -0
- package/vendor/agent-runtime/package.json +8 -1
- package/vendor/pi-web-access/CHANGELOG.md +387 -0
- package/vendor/pi-web-access/LICENSE +21 -0
- package/vendor/pi-web-access/README.md +352 -0
- package/vendor/pi-web-access/activity.ts +101 -0
- package/vendor/pi-web-access/banner.png +0 -0
- package/vendor/pi-web-access/chrome-cookies.ts +322 -0
- package/vendor/pi-web-access/code-search.ts +107 -0
- package/vendor/pi-web-access/curator-page.ts +3359 -0
- package/vendor/pi-web-access/curator-server.ts +605 -0
- package/vendor/pi-web-access/exa.ts +520 -0
- package/vendor/pi-web-access/extract.ts +641 -0
- package/vendor/pi-web-access/gemini-api.ts +112 -0
- package/vendor/pi-web-access/gemini-search.ts +361 -0
- package/vendor/pi-web-access/gemini-url-context.ts +126 -0
- package/vendor/pi-web-access/gemini-web-config.ts +52 -0
- package/vendor/pi-web-access/gemini-web.ts +396 -0
- package/vendor/pi-web-access/github-api.ts +196 -0
- package/vendor/pi-web-access/github-extract.ts +634 -0
- package/vendor/pi-web-access/index.ts +2346 -0
- package/vendor/pi-web-access/package.json +45 -0
- package/vendor/pi-web-access/pdf-extract.ts +192 -0
- package/vendor/pi-web-access/perplexity.ts +195 -0
- package/vendor/pi-web-access/pi-web-fetch-demo.mp4 +0 -0
- package/vendor/pi-web-access/rsc-extract.ts +338 -0
- package/vendor/pi-web-access/skills/librarian/SKILL.md +195 -0
- package/vendor/pi-web-access/storage.ts +72 -0
- package/vendor/pi-web-access/summary-review.ts +276 -0
- package/vendor/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +41 -0
- package/vendor/pi-web-access/test/pdf-extract.test.mjs +95 -0
- package/vendor/pi-web-access/utils.ts +44 -0
- package/vendor/pi-web-access/video-extract.ts +378 -0
- package/vendor/pi-web-access/youtube-extract.ts +310 -0
- package/dist/lib/pi-adapter/auth.js +0 -68
- package/dist/lib/pi-adapter/auth.js.map +0 -1
- package/dist/lib/pi-adapter/pod-tools.js +0 -140
- package/dist/lib/pi-adapter/pod-tools.js.map +0 -1
- package/dist/skills/drizzle-solid/SKILL.md +0 -340
- package/dist/skills/pod-storage/SKILL.md +0 -100
- package/dist/skills/solid-modeling/SKILL.md +0 -274
- package/dist/skills/xpod-componentsjs/SKILL.md +0 -284
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const API_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
6
|
+
const CONFIG_PATH = join(process.env.LINX_HOME?.trim() || join(process.env.SOLID_HOME?.trim() || join(homedir(), ".solid"), "apps", "linx"), "pi-web-access.json");
|
|
7
|
+
export const DEFAULT_MODEL = "gemini-3-flash-preview";
|
|
8
|
+
|
|
9
|
+
interface GeminiApiConfig {
|
|
10
|
+
geminiApiKey?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cachedConfig: GeminiApiConfig | null = null;
|
|
14
|
+
|
|
15
|
+
function loadConfig(): GeminiApiConfig {
|
|
16
|
+
if (cachedConfig) return cachedConfig;
|
|
17
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
18
|
+
cachedConfig = {};
|
|
19
|
+
return cachedConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
23
|
+
try {
|
|
24
|
+
cachedConfig = JSON.parse(raw) as GeminiApiConfig;
|
|
25
|
+
return cachedConfig;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
33
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
34
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeApiKey(value: unknown): string | null {
|
|
38
|
+
if (typeof value !== "string") return null;
|
|
39
|
+
const normalized = value.trim();
|
|
40
|
+
return normalized.length > 0 ? normalized : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getApiKey(): string | null {
|
|
44
|
+
return normalizeApiKey(process.env.GEMINI_API_KEY) ?? normalizeApiKey(loadConfig().geminiApiKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isGeminiApiAvailable(): boolean {
|
|
48
|
+
return getApiKey() !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GeminiApiOptions {
|
|
52
|
+
model?: string;
|
|
53
|
+
mimeType?: string;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function queryGeminiApiWithVideo(
|
|
59
|
+
prompt: string,
|
|
60
|
+
videoUri: string,
|
|
61
|
+
options: GeminiApiOptions = {},
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const apiKey = getApiKey();
|
|
64
|
+
if (!apiKey) throw new Error("GEMINI_API_KEY not configured");
|
|
65
|
+
|
|
66
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
67
|
+
const signal = withTimeout(options.signal, options.timeoutMs ?? 120000);
|
|
68
|
+
const url = `${API_BASE}/models/${model}:generateContent?key=${apiKey}`;
|
|
69
|
+
|
|
70
|
+
const fileData: Record<string, string> = { fileUri: videoUri };
|
|
71
|
+
if (options.mimeType) fileData.mimeType = options.mimeType;
|
|
72
|
+
|
|
73
|
+
const body = {
|
|
74
|
+
contents: [
|
|
75
|
+
{
|
|
76
|
+
parts: [
|
|
77
|
+
{ fileData },
|
|
78
|
+
{ text: prompt },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
signal,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const errorText = await res.text();
|
|
93
|
+
throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const data = (await res.json()) as GenerateContentResponse;
|
|
97
|
+
const text = data.candidates?.[0]?.content?.parts
|
|
98
|
+
?.map((p) => p.text)
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join("\n");
|
|
101
|
+
|
|
102
|
+
if (!text) throw new Error("Gemini API returned empty response");
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface GenerateContentResponse {
|
|
107
|
+
candidates?: Array<{
|
|
108
|
+
content?: {
|
|
109
|
+
parts?: Array<{ text?: string }>;
|
|
110
|
+
};
|
|
111
|
+
}>;
|
|
112
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { activityMonitor } from "./activity.js";
|
|
5
|
+
import { getApiKey, API_BASE, DEFAULT_MODEL } from "./gemini-api.js";
|
|
6
|
+
import { isGeminiWebAvailable, queryWithCookies } from "./gemini-web.js";
|
|
7
|
+
import { isPerplexityAvailable, searchWithPerplexity, type SearchResult, type SearchResponse, type SearchOptions } from "./perplexity.js";
|
|
8
|
+
import { hasExaApiKey, isExaAvailable, searchWithExa } from "./exa.js";
|
|
9
|
+
|
|
10
|
+
export type SearchProvider = "auto" | "perplexity" | "gemini" | "exa";
|
|
11
|
+
export type ResolvedSearchProvider = Exclude<SearchProvider, "auto">;
|
|
12
|
+
|
|
13
|
+
export interface AttributedSearchResponse extends SearchResponse {
|
|
14
|
+
provider: ResolvedSearchProvider;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CONFIG_PATH = join(process.env.LINX_HOME?.trim() || join(process.env.SOLID_HOME?.trim() || join(homedir(), ".solid"), "apps", "linx"), "pi-web-access.json");
|
|
18
|
+
|
|
19
|
+
let cachedSearchConfig: { searchProvider: SearchProvider; searchModel?: string } | null = null;
|
|
20
|
+
|
|
21
|
+
function getSearchConfig(): { searchProvider: SearchProvider; searchModel?: string } {
|
|
22
|
+
if (cachedSearchConfig) return cachedSearchConfig;
|
|
23
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
24
|
+
cachedSearchConfig = { searchProvider: "auto", searchModel: undefined };
|
|
25
|
+
return cachedSearchConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rawText = readFileSync(CONFIG_PATH, "utf-8");
|
|
29
|
+
let raw: {
|
|
30
|
+
searchProvider?: SearchProvider;
|
|
31
|
+
provider?: SearchProvider;
|
|
32
|
+
searchModel?: unknown;
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
raw = JSON.parse(rawText) as {
|
|
36
|
+
searchProvider?: SearchProvider;
|
|
37
|
+
provider?: SearchProvider;
|
|
38
|
+
searchModel?: unknown;
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cachedSearchConfig = {
|
|
46
|
+
searchProvider: normalizeSearchProvider(raw.searchProvider ?? raw.provider),
|
|
47
|
+
searchModel: normalizeSearchModel(raw.searchModel),
|
|
48
|
+
};
|
|
49
|
+
return cachedSearchConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeSearchModel(value: unknown): string | undefined {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const normalized = value.trim();
|
|
55
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSearchProvider(value: unknown): SearchProvider {
|
|
59
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
60
|
+
return normalized === "auto" || normalized === "perplexity" || normalized === "gemini" || normalized === "exa"
|
|
61
|
+
? normalized
|
|
62
|
+
: "auto";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FullSearchOptions extends SearchOptions {
|
|
66
|
+
provider?: SearchProvider;
|
|
67
|
+
includeContent?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorMessage(err: unknown): string {
|
|
71
|
+
return err instanceof Error ? err.message : String(err);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAbortError(err: unknown): boolean {
|
|
75
|
+
return errorMessage(err).toLowerCase().includes("abort");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function searchWithGemini(
|
|
79
|
+
query: string,
|
|
80
|
+
options: SearchOptions,
|
|
81
|
+
strictErrors: boolean,
|
|
82
|
+
): Promise<SearchResponse | null> {
|
|
83
|
+
const errors: string[] = [];
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const apiResult = await searchWithGeminiApi(query, options);
|
|
87
|
+
if (apiResult) return apiResult;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (isAbortError(err)) throw err;
|
|
90
|
+
errors.push(`Gemini API: ${errorMessage(err)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const webResult = await searchWithGeminiWeb(query, options);
|
|
95
|
+
if (webResult) return webResult;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (isAbortError(err)) throw err;
|
|
98
|
+
errors.push(`Gemini Web: ${errorMessage(err)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (strictErrors && errors.length > 0) {
|
|
102
|
+
throw new Error(`Gemini search failed:\n - ${errors.join("\n - ")}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function search(query: string, options: FullSearchOptions = {}): Promise<AttributedSearchResponse> {
|
|
109
|
+
const config = getSearchConfig();
|
|
110
|
+
const provider = options.provider ?? config.searchProvider;
|
|
111
|
+
|
|
112
|
+
if (provider === "perplexity") {
|
|
113
|
+
const result = await searchWithPerplexity(query, options);
|
|
114
|
+
return { ...result, provider: "perplexity" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (provider === "gemini") {
|
|
118
|
+
const result = await searchWithGemini(query, options, true);
|
|
119
|
+
if (result) return { ...result, provider: "gemini" };
|
|
120
|
+
throw new Error(
|
|
121
|
+
"Gemini search unavailable. Either:\n" +
|
|
122
|
+
" 1. Set GEMINI_API_KEY in $LINX_HOME/pi-web-access.json\n" +
|
|
123
|
+
" 2. Sign into gemini.google.com in a supported Chromium-based browser"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (provider === "exa") {
|
|
128
|
+
const exaApiKeyConfigured = hasExaApiKey();
|
|
129
|
+
try {
|
|
130
|
+
const result = await searchWithExa(query, options);
|
|
131
|
+
if (result && "exhausted" in result) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Exa monthly free tier exhausted (1,000 requests). Resets next month.\n" +
|
|
134
|
+
" Use provider: 'perplexity' or 'gemini', or upgrade at exa.ai/pricing"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (result && "answer" in result) return { ...result, provider: "exa" };
|
|
138
|
+
if (exaApiKeyConfigured) {
|
|
139
|
+
throw new Error("Exa search returned no results.");
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
if (message.toLowerCase().includes("abort")) throw err;
|
|
144
|
+
if (exaApiKeyConfigured) throw err;
|
|
145
|
+
// No API key: allow provider fallback.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const fallbackErrors: string[] = [];
|
|
150
|
+
|
|
151
|
+
if (provider !== "exa" && isExaAvailable()) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await searchWithExa(query, options);
|
|
154
|
+
if (result && "answer" in result) return { ...result, provider: "exa" };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (isAbortError(err)) throw err;
|
|
157
|
+
fallbackErrors.push(`Exa: ${errorMessage(err)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isPerplexityAvailable()) {
|
|
162
|
+
try {
|
|
163
|
+
const result = await searchWithPerplexity(query, options);
|
|
164
|
+
return { ...result, provider: "perplexity" };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isAbortError(err)) throw err;
|
|
167
|
+
fallbackErrors.push(`Perplexity: ${errorMessage(err)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const geminiResult = await searchWithGemini(query, options, false);
|
|
173
|
+
if (geminiResult) return { ...geminiResult, provider: "gemini" };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (isAbortError(err)) throw err;
|
|
176
|
+
fallbackErrors.push(`Gemini: ${errorMessage(err)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (fallbackErrors.length > 0) {
|
|
180
|
+
throw new Error(`Auto provider search failed:\n - ${fallbackErrors.join("\n - ")}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error(
|
|
184
|
+
"No search provider available. Either:\n" +
|
|
185
|
+
" 1. Set perplexityApiKey in $LINX_HOME/pi-web-access.json\n" +
|
|
186
|
+
" 2. Set EXA_API_KEY (or exaApiKey) in $LINX_HOME/pi-web-access.json\n" +
|
|
187
|
+
" 3. Set GEMINI_API_KEY in $LINX_HOME/pi-web-access.json\n" +
|
|
188
|
+
" 4. Sign into gemini.google.com in a supported Chromium-based browser"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function searchWithGeminiApi(query: string, options: SearchOptions = {}): Promise<SearchResponse | null> {
|
|
193
|
+
const apiKey = getApiKey();
|
|
194
|
+
if (!apiKey) return null;
|
|
195
|
+
|
|
196
|
+
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const model = getSearchConfig().searchModel ?? DEFAULT_MODEL;
|
|
200
|
+
const body = {
|
|
201
|
+
contents: [{ parts: [{ text: query }] }],
|
|
202
|
+
tools: [{ google_search: {} }],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const res = await fetch(`${API_BASE}/models/${model}:generateContent?key=${apiKey}`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify(body),
|
|
209
|
+
signal: AbortSignal.any([
|
|
210
|
+
AbortSignal.timeout(60000),
|
|
211
|
+
...(options.signal ? [options.signal] : []),
|
|
212
|
+
]),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
const errorText = await res.text();
|
|
217
|
+
throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const data = await res.json() as GeminiSearchResponse;
|
|
221
|
+
activityMonitor.logComplete(activityId, res.status);
|
|
222
|
+
|
|
223
|
+
const answer = data.candidates?.[0]?.content?.parts
|
|
224
|
+
?.map(p => p.text).filter(Boolean).join("\n") ?? "";
|
|
225
|
+
|
|
226
|
+
const metadata = data.candidates?.[0]?.groundingMetadata;
|
|
227
|
+
const results = await resolveGroundingChunks(metadata?.groundingChunks, options.signal);
|
|
228
|
+
|
|
229
|
+
if (!answer && results.length === 0) return null;
|
|
230
|
+
return { answer, results };
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
if (message.toLowerCase().includes("abort")) {
|
|
234
|
+
activityMonitor.logComplete(activityId, 0);
|
|
235
|
+
} else {
|
|
236
|
+
activityMonitor.logError(activityId, message);
|
|
237
|
+
}
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function searchWithGeminiWeb(query: string, options: SearchOptions = {}): Promise<SearchResponse | null> {
|
|
243
|
+
const cookies = await isGeminiWebAvailable();
|
|
244
|
+
if (!cookies) return null;
|
|
245
|
+
|
|
246
|
+
const prompt = buildSearchPrompt(query, options);
|
|
247
|
+
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const text = await queryWithCookies(prompt, cookies, {
|
|
251
|
+
model: "gemini-3-flash-preview",
|
|
252
|
+
signal: options.signal,
|
|
253
|
+
timeoutMs: 60000,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
activityMonitor.logComplete(activityId, 200);
|
|
257
|
+
|
|
258
|
+
const results = extractSourceUrls(text);
|
|
259
|
+
return { answer: text, results };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
+
if (message.toLowerCase().includes("abort")) {
|
|
263
|
+
activityMonitor.logComplete(activityId, 0);
|
|
264
|
+
} else {
|
|
265
|
+
activityMonitor.logError(activityId, message);
|
|
266
|
+
}
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildSearchPrompt(query: string, options: SearchOptions): string {
|
|
272
|
+
let prompt = `Search the web and answer the following question. Include source URLs for your claims.\nFormat your response as:\n1. A direct answer to the question\n2. Cited sources as markdown links\n\nQuestion: ${query}`;
|
|
273
|
+
|
|
274
|
+
if (options.recencyFilter) {
|
|
275
|
+
const labels: Record<string, string> = {
|
|
276
|
+
day: "past 24 hours",
|
|
277
|
+
week: "past week",
|
|
278
|
+
month: "past month",
|
|
279
|
+
year: "past year",
|
|
280
|
+
};
|
|
281
|
+
prompt += `\n\nOnly include results from the ${labels[options.recencyFilter]}.`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (options.domainFilter?.length) {
|
|
285
|
+
const includes = options.domainFilter.filter(d => !d.startsWith("-"));
|
|
286
|
+
const excludes = options.domainFilter.filter(d => d.startsWith("-")).map(d => d.slice(1));
|
|
287
|
+
if (includes.length) prompt += `\n\nOnly cite sources from: ${includes.join(", ")}`;
|
|
288
|
+
if (excludes.length) prompt += `\n\nDo not cite sources from: ${excludes.join(", ")}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return prompt;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function extractSourceUrls(markdown: string): SearchResult[] {
|
|
295
|
+
const results: SearchResult[] = [];
|
|
296
|
+
const seen = new Set<string>();
|
|
297
|
+
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
|
|
298
|
+
for (const match of markdown.matchAll(linkRegex)) {
|
|
299
|
+
const url = match[2];
|
|
300
|
+
if (seen.has(url)) continue;
|
|
301
|
+
seen.add(url);
|
|
302
|
+
results.push({ title: match[1], url, snippet: "" });
|
|
303
|
+
}
|
|
304
|
+
return results;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function resolveGroundingChunks(
|
|
308
|
+
chunks: GroundingChunk[] | undefined,
|
|
309
|
+
signal?: AbortSignal,
|
|
310
|
+
): Promise<SearchResult[]> {
|
|
311
|
+
if (!chunks?.length) return [];
|
|
312
|
+
|
|
313
|
+
const results: SearchResult[] = [];
|
|
314
|
+
for (const chunk of chunks) {
|
|
315
|
+
if (!chunk.web) continue;
|
|
316
|
+
const title = chunk.web.title || "";
|
|
317
|
+
let url = chunk.web.uri || "";
|
|
318
|
+
|
|
319
|
+
if (url.includes("vertexaisearch.cloud.google.com/grounding-api-redirect")) {
|
|
320
|
+
const resolved = await resolveRedirect(url, signal);
|
|
321
|
+
if (resolved) url = resolved;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (url) results.push({ title, url, snippet: "" });
|
|
325
|
+
}
|
|
326
|
+
return results;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function resolveRedirect(proxyUrl: string, signal?: AbortSignal): Promise<string | null> {
|
|
330
|
+
try {
|
|
331
|
+
const res = await fetch(proxyUrl, {
|
|
332
|
+
method: "HEAD",
|
|
333
|
+
redirect: "manual",
|
|
334
|
+
signal: AbortSignal.any([
|
|
335
|
+
AbortSignal.timeout(5000),
|
|
336
|
+
...(signal ? [signal] : []),
|
|
337
|
+
]),
|
|
338
|
+
});
|
|
339
|
+
return res.headers.get("location") || null;
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface GeminiSearchResponse {
|
|
346
|
+
candidates?: Array<{
|
|
347
|
+
content?: { parts?: Array<{ text?: string }> };
|
|
348
|
+
groundingMetadata?: {
|
|
349
|
+
webSearchQueries?: string[];
|
|
350
|
+
groundingChunks?: GroundingChunk[];
|
|
351
|
+
groundingSupports?: Array<{
|
|
352
|
+
segment?: { startIndex?: number; endIndex?: number; text?: string };
|
|
353
|
+
groundingChunkIndices?: number[];
|
|
354
|
+
}>;
|
|
355
|
+
};
|
|
356
|
+
}>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface GroundingChunk {
|
|
360
|
+
web?: { uri?: string; title?: string };
|
|
361
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { activityMonitor } from "./activity.js";
|
|
2
|
+
import { getApiKey, API_BASE, DEFAULT_MODEL } from "./gemini-api.js";
|
|
3
|
+
import { isGeminiWebAvailable, queryWithCookies } from "./gemini-web.js";
|
|
4
|
+
import { extractHeadingTitle, type ExtractedContent } from "./extract.js";
|
|
5
|
+
|
|
6
|
+
const EXTRACTION_PROMPT = `Extract the complete readable content from this URL as clean markdown.
|
|
7
|
+
Include the page title, all text content, code blocks, and tables.
|
|
8
|
+
Do not summarize — extract the full content.
|
|
9
|
+
|
|
10
|
+
URL: `;
|
|
11
|
+
|
|
12
|
+
function shouldRethrow(err: unknown): boolean {
|
|
13
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
14
|
+
return message.startsWith("Failed to parse ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function extractWithUrlContext(
|
|
18
|
+
url: string,
|
|
19
|
+
signal?: AbortSignal,
|
|
20
|
+
): Promise<ExtractedContent | null> {
|
|
21
|
+
const apiKey = getApiKey();
|
|
22
|
+
if (!apiKey) return null;
|
|
23
|
+
|
|
24
|
+
const activityId = activityMonitor.logStart({ type: "api", query: `url_context: ${url}` });
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const model = DEFAULT_MODEL;
|
|
28
|
+
const body = {
|
|
29
|
+
contents: [{ parts: [{ text: EXTRACTION_PROMPT + url }] }],
|
|
30
|
+
tools: [{ url_context: {} }],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const res = await fetch(`${API_BASE}/models/${model}:generateContent?key=${apiKey}`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: AbortSignal.any([
|
|
38
|
+
AbortSignal.timeout(60000),
|
|
39
|
+
...(signal ? [signal] : []),
|
|
40
|
+
]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
activityMonitor.logComplete(activityId, res.status);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await res.json() as UrlContextResponse;
|
|
49
|
+
activityMonitor.logComplete(activityId, res.status);
|
|
50
|
+
|
|
51
|
+
const metadata = data.candidates?.[0]?.url_context_metadata;
|
|
52
|
+
if (metadata?.url_metadata?.length) {
|
|
53
|
+
const status = metadata.url_metadata[0].url_retrieval_status;
|
|
54
|
+
if (status === "URL_RETRIEVAL_STATUS_UNSAFE" || status === "URL_RETRIEVAL_STATUS_ERROR") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = data.candidates?.[0]?.content?.parts
|
|
60
|
+
?.map(p => p.text).filter(Boolean).join("\n") ?? "";
|
|
61
|
+
|
|
62
|
+
if (!content || content.length < 50) return null;
|
|
63
|
+
|
|
64
|
+
const title = extractTitleFromContent(content, url);
|
|
65
|
+
return { url, title, content, error: null };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (shouldRethrow(err)) throw err;
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
if (message.toLowerCase().includes("abort")) {
|
|
70
|
+
activityMonitor.logComplete(activityId, 0);
|
|
71
|
+
} else {
|
|
72
|
+
activityMonitor.logError(activityId, message);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function extractWithGeminiWeb(
|
|
79
|
+
url: string,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<ExtractedContent | null> {
|
|
82
|
+
const cookies = await isGeminiWebAvailable();
|
|
83
|
+
if (!cookies) return null;
|
|
84
|
+
|
|
85
|
+
const activityId = activityMonitor.logStart({ type: "api", query: `gemini_web: ${url}` });
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const text = await queryWithCookies(EXTRACTION_PROMPT + url, cookies, {
|
|
89
|
+
model: "gemini-3-flash-preview",
|
|
90
|
+
signal,
|
|
91
|
+
timeoutMs: 60000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
activityMonitor.logComplete(activityId, 200);
|
|
95
|
+
|
|
96
|
+
if (!text || text.length < 50) return null;
|
|
97
|
+
|
|
98
|
+
const title = extractTitleFromContent(text, url);
|
|
99
|
+
return { url, title, content: text, error: null };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (shouldRethrow(err)) throw err;
|
|
102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
+
if (message.toLowerCase().includes("abort")) {
|
|
104
|
+
activityMonitor.logComplete(activityId, 0);
|
|
105
|
+
} else {
|
|
106
|
+
activityMonitor.logError(activityId, message);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractTitleFromContent(text: string, url: string): string {
|
|
113
|
+
return extractHeadingTitle(text) ?? (new URL(url).pathname.split("/").pop() || url);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface UrlContextResponse {
|
|
117
|
+
candidates?: Array<{
|
|
118
|
+
content?: { parts?: Array<{ text?: string }> };
|
|
119
|
+
url_context_metadata?: {
|
|
120
|
+
url_metadata?: Array<{
|
|
121
|
+
retrieved_url?: string;
|
|
122
|
+
url_retrieval_status?: string;
|
|
123
|
+
}>;
|
|
124
|
+
};
|
|
125
|
+
}>;
|
|
126
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = join(process.env.LINX_HOME?.trim() || join(process.env.SOLID_HOME?.trim() || join(homedir(), ".solid"), "apps", "linx"), "pi-web-access.json");
|
|
6
|
+
|
|
7
|
+
interface GeminiWebConfig {
|
|
8
|
+
chromeProfile?: string;
|
|
9
|
+
allowBrowserCookies?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let cachedConfig: GeminiWebConfig | null = null;
|
|
13
|
+
|
|
14
|
+
export function normalizeChromeProfile(value: unknown): string | undefined {
|
|
15
|
+
if (typeof value !== "string") return undefined;
|
|
16
|
+
const normalized = value.trim();
|
|
17
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadConfig(): GeminiWebConfig {
|
|
21
|
+
if (cachedConfig) return cachedConfig;
|
|
22
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
23
|
+
cachedConfig = {};
|
|
24
|
+
return cachedConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rawText = readFileSync(CONFIG_PATH, "utf-8");
|
|
28
|
+
let raw: { chromeProfile?: unknown; allowBrowserCookies?: unknown };
|
|
29
|
+
try {
|
|
30
|
+
raw = JSON.parse(rawText) as { chromeProfile?: unknown; allowBrowserCookies?: unknown };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
cachedConfig = {
|
|
37
|
+
chromeProfile: normalizeChromeProfile(raw.chromeProfile),
|
|
38
|
+
allowBrowserCookies: raw.allowBrowserCookies === true,
|
|
39
|
+
};
|
|
40
|
+
return cachedConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getChromeProfileFromConfig(): string | undefined {
|
|
44
|
+
return loadConfig().chromeProfile;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isBrowserCookieAccessAllowed(): boolean {
|
|
48
|
+
if (process.env.PI_ALLOW_BROWSER_COOKIES === "1" || process.env.FEYNMAN_ALLOW_BROWSER_COOKIES === "1") {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return loadConfig().allowBrowserCookies === true;
|
|
52
|
+
}
|