@mammothb/pi-web 6.0.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.
- package/LICENSE +18 -0
- package/bin/searxng +48 -0
- package/index.ts +85 -0
- package/package.json +37 -0
- package/searxng/core-config/settings.yml +2458 -0
- package/searxng/docker-compose.yml +24 -0
- package/src/config.ts +106 -0
- package/src/lib/headers.ts +28 -0
- package/src/lib/processors.ts +57 -0
- package/src/lib/providers/exa-mcp.ts +130 -0
- package/src/lib/providers/index.ts +29 -0
- package/src/lib/providers/searxng.ts +172 -0
- package/src/lib/searxng-manager.ts +293 -0
- package/src/lib/types.ts +78 -0
- package/src/webfetch.ts +393 -0
- package/src/websearch.ts +148 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { expandTilde } from "@mammothb/pi-shared";
|
|
12
|
+
|
|
13
|
+
const __dirname = new URL(".", import.meta.url).pathname;
|
|
14
|
+
|
|
15
|
+
function getScriptPath(): string {
|
|
16
|
+
// From src/lib/ go up 2 levels to package root, then bin/searxng
|
|
17
|
+
return join(__dirname, "..", "..", "bin", "searxng");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getInstancesDir(): string {
|
|
21
|
+
return join(getAgentDir(), "searxng-instances");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Result of a shutdown health check. */
|
|
25
|
+
export interface ShutdownState {
|
|
26
|
+
/** Number of PID files whose process is dead (unclean shutdown). */
|
|
27
|
+
uncleanCount: number;
|
|
28
|
+
/** Number of PID files whose process is still alive (shutdown in progress). */
|
|
29
|
+
stillRunning: number;
|
|
30
|
+
/**
|
|
31
|
+
* Remove the shutdown PID files that were just inspected.
|
|
32
|
+
* Call after reporting health status so stale files don't
|
|
33
|
+
* trigger false positives on the next startup.
|
|
34
|
+
*/
|
|
35
|
+
cleanup(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether a PID is alive by sending signal 0.
|
|
40
|
+
*/
|
|
41
|
+
export function isProcessAlive(pid: number): boolean {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove lock files belonging to dead PIDs.
|
|
52
|
+
*/
|
|
53
|
+
export function cleanStaleLocks(dir: string): void {
|
|
54
|
+
if (!existsSync(dir)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of readdirSync(dir)) {
|
|
58
|
+
const pid = parseInt(entry.replace(/\.lock$/, ""), 10);
|
|
59
|
+
if (!Number.isNaN(pid) && !isProcessAlive(pid)) {
|
|
60
|
+
rmSync(join(dir, entry), { force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the searxng management script (up/down).
|
|
67
|
+
* Uses the provided scriptPath, or falls back to the built-in `bin/searxng`.
|
|
68
|
+
*
|
|
69
|
+
* Waits for the child process to complete and captures stdout/stderr.
|
|
70
|
+
* Use this when you need the exit code and output (e.g., for the `up` command).
|
|
71
|
+
*/
|
|
72
|
+
export async function runScript(
|
|
73
|
+
command: "up" | "down",
|
|
74
|
+
scriptPath?: string,
|
|
75
|
+
): Promise<void>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run the searxng management script in detached mode.
|
|
79
|
+
*
|
|
80
|
+
* The child process is detached from the parent and will survive parent exit.
|
|
81
|
+
* No output is captured and no exit code is available. Use this for
|
|
82
|
+
* fire-and-forget commands (e.g., `docker compose down` during shutdown).
|
|
83
|
+
*
|
|
84
|
+
* @param opts.shutdownPidDir When set, the command is wrapped in a bash script
|
|
85
|
+
* that creates a `shutdown-<pid>.pid` file before running the command and
|
|
86
|
+
* removes it on exit. Enables cross-session health checks via
|
|
87
|
+
* {@link inspectShutdownState}.
|
|
88
|
+
*/
|
|
89
|
+
export function runScript(
|
|
90
|
+
command: "up" | "down",
|
|
91
|
+
scriptPath: string | undefined,
|
|
92
|
+
opts: { detached: true; shutdownPidDir?: string },
|
|
93
|
+
): void;
|
|
94
|
+
|
|
95
|
+
export function runScript(
|
|
96
|
+
command: "up" | "down",
|
|
97
|
+
scriptPath?: string,
|
|
98
|
+
opts?: { detached?: boolean; shutdownPidDir?: string },
|
|
99
|
+
): Promise<void> | void {
|
|
100
|
+
const script = scriptPath ? expandTilde(scriptPath) : getScriptPath();
|
|
101
|
+
|
|
102
|
+
// Detached mode: fire-and-forget, child survives parent exit
|
|
103
|
+
if (opts?.detached) {
|
|
104
|
+
if (opts.shutdownPidDir) {
|
|
105
|
+
// Bash wrapper that tracks the down command via a PID file.
|
|
106
|
+
// The wrapper creates shutdown-<pid>.pid, runs the script, then
|
|
107
|
+
// removes the file. If the file remains on next startup, the
|
|
108
|
+
// shutdown was unclean.
|
|
109
|
+
const child = spawn(
|
|
110
|
+
"bash",
|
|
111
|
+
[
|
|
112
|
+
"-c",
|
|
113
|
+
`
|
|
114
|
+
PID_FILE="${opts.shutdownPidDir}/shutdown-$$.pid"
|
|
115
|
+
echo $$ > "$PID_FILE"
|
|
116
|
+
"${script}" ${command}
|
|
117
|
+
EXIT=$?
|
|
118
|
+
rm -f "$PID_FILE"
|
|
119
|
+
exit $EXIT
|
|
120
|
+
`,
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
stdio: "ignore",
|
|
124
|
+
detached: true,
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
child.unref();
|
|
128
|
+
child.on("error", (err) => {
|
|
129
|
+
console.error(
|
|
130
|
+
`pi-web: failed to run searxng ${command}: ${err.message}`,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
const child = spawn("bash", [script, command], {
|
|
135
|
+
stdio: "ignore",
|
|
136
|
+
detached: true,
|
|
137
|
+
});
|
|
138
|
+
child.unref();
|
|
139
|
+
child.on("error", (err) => {
|
|
140
|
+
console.error(
|
|
141
|
+
`pi-web: failed to run searxng ${command}: ${err.message}`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Default mode: wait for completion, capture output
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const child = spawn("bash", [script, command], {
|
|
151
|
+
stdio: "pipe",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let stdout = "";
|
|
155
|
+
let stderr = "";
|
|
156
|
+
|
|
157
|
+
child.stdout?.on("data", (d) => {
|
|
158
|
+
stdout += d.toString();
|
|
159
|
+
});
|
|
160
|
+
child.stderr?.on("data", (d) => {
|
|
161
|
+
stderr += d.toString();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.on("close", (code) => {
|
|
165
|
+
if (code === 0) {
|
|
166
|
+
if (stdout.trim()) {
|
|
167
|
+
console.log(stdout.trim());
|
|
168
|
+
}
|
|
169
|
+
resolve();
|
|
170
|
+
} else {
|
|
171
|
+
reject(
|
|
172
|
+
new Error(
|
|
173
|
+
`searxng ${command} failed (exit ${code}): ${stderr || stdout}`,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
child.on("error", reject);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check for leftover shutdown PID files from previous sessions.
|
|
185
|
+
*
|
|
186
|
+
* Returns counts of unclean and still-running shutdowns, plus a
|
|
187
|
+
* `cleanup()` method that removes the inspected PID files. Call
|
|
188
|
+
* `cleanup()` after reporting to prevent false positives on the
|
|
189
|
+
* next startup.
|
|
190
|
+
*/
|
|
191
|
+
export function inspectShutdownState(dir: string): ShutdownState {
|
|
192
|
+
const pids: string[] = [];
|
|
193
|
+
let uncleanCount = 0;
|
|
194
|
+
let stillRunning = 0;
|
|
195
|
+
|
|
196
|
+
if (existsSync(dir)) {
|
|
197
|
+
for (const entry of readdirSync(dir)) {
|
|
198
|
+
const match = entry.match(/^shutdown-(\d+)\.pid$/);
|
|
199
|
+
if (!match) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const pidStr = match[1];
|
|
204
|
+
if (!pidStr) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pids.push(entry);
|
|
209
|
+
const pid = parseInt(pidStr, 10);
|
|
210
|
+
if (isProcessAlive(pid)) {
|
|
211
|
+
stillRunning++;
|
|
212
|
+
} else {
|
|
213
|
+
uncleanCount++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
uncleanCount,
|
|
220
|
+
stillRunning,
|
|
221
|
+
cleanup() {
|
|
222
|
+
for (const entry of pids) {
|
|
223
|
+
rmSync(join(dir, entry), { force: true });
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Register the current pi instance as a searxng user.
|
|
231
|
+
*
|
|
232
|
+
* Creates a PID-based lock file in the instances directory, cleans up stale
|
|
233
|
+
* locks, and starts SearXNG (if it's not already running). Safe to call
|
|
234
|
+
* multiple times — it is idempotent.
|
|
235
|
+
*
|
|
236
|
+
* @param scriptPath Optional path to a custom management script.
|
|
237
|
+
* Must accept "up" and "down" commands. When set, used instead of the
|
|
238
|
+
* built-in `bin/searxng` script.
|
|
239
|
+
*/
|
|
240
|
+
export async function registerInstance(scriptPath?: string): Promise<void> {
|
|
241
|
+
const dir = getInstancesDir();
|
|
242
|
+
mkdirSync(dir, { recursive: true });
|
|
243
|
+
|
|
244
|
+
// Remove locks belonging to dead processes
|
|
245
|
+
cleanStaleLocks(dir);
|
|
246
|
+
|
|
247
|
+
// Create / overwrite our lock file
|
|
248
|
+
const lockFile = join(dir, `${process.pid}.lock`);
|
|
249
|
+
writeFileSync(lockFile, String(process.pid));
|
|
250
|
+
|
|
251
|
+
// Start SearXNG (idempotent — the script checks if already running)
|
|
252
|
+
try {
|
|
253
|
+
await runScript("up", scriptPath);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
console.error(`pi-web: failed to start SearXNG: ${message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Unregister the current pi instance.
|
|
262
|
+
*
|
|
263
|
+
* Removes our PID lock file. If no live instances remain, stops SearXNG.
|
|
264
|
+
*
|
|
265
|
+
* @param scriptPath Optional path to a custom management script.
|
|
266
|
+
* Must match the path passed to registerInstance.
|
|
267
|
+
*/
|
|
268
|
+
export async function unregisterInstance(scriptPath?: string): Promise<void> {
|
|
269
|
+
const dir = getInstancesDir();
|
|
270
|
+
|
|
271
|
+
// Remove our lock file
|
|
272
|
+
const lockFile = join(dir, `${process.pid}.lock`);
|
|
273
|
+
try {
|
|
274
|
+
rmSync(lockFile, { force: true });
|
|
275
|
+
} catch {
|
|
276
|
+
// Ignore — best effort cleanup
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check if any live instances remain
|
|
280
|
+
if (existsSync(dir)) {
|
|
281
|
+
for (const entry of readdirSync(dir)) {
|
|
282
|
+
const pid = parseInt(entry.replace(/\.lock$/, ""), 10);
|
|
283
|
+
if (!Number.isNaN(pid) && isProcessAlive(pid)) {
|
|
284
|
+
return; // Another instance is still running
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// No more live instances — shut down SearXNG.
|
|
290
|
+
// Use detached mode with PID tracking so unclean shutdowns are detected
|
|
291
|
+
// on the next startup via inspectShutdownState().
|
|
292
|
+
runScript("down", scriptPath, { detached: true, shutdownPidDir: dir });
|
|
293
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Static } from "typebox";
|
|
3
|
+
import Type from "typebox";
|
|
4
|
+
|
|
5
|
+
// ── webfetch types ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export const FormatSchema = StringEnum(["text", "markdown", "html"] as const, {
|
|
8
|
+
description:
|
|
9
|
+
"The format to return the content in - text, markdown, or html (default: 'markdown')",
|
|
10
|
+
});
|
|
11
|
+
export type Format = Static<typeof FormatSchema>;
|
|
12
|
+
|
|
13
|
+
export type Header = Record<
|
|
14
|
+
"User-Agent" | "Accept" | "Accept-Language",
|
|
15
|
+
string
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
// ── websearch types ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A search provider implementation.
|
|
22
|
+
*
|
|
23
|
+
* Each provider encapsulates the logic for calling a specific search backend
|
|
24
|
+
* (MCP server, REST API, etc.) and returns the search results as a text string.
|
|
25
|
+
*/
|
|
26
|
+
export interface SearchProvider {
|
|
27
|
+
/** Human-readable provider name (e.g. "exa-mcp", "brave", "tavily"). */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
|
|
30
|
+
/** Usage notes shown in the tool description for the LLM. */
|
|
31
|
+
readonly usageNotes: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Execute a search and return the result text.
|
|
35
|
+
* Returns undefined if the search returned no results.
|
|
36
|
+
*/
|
|
37
|
+
search(args: SearchArgs, signal?: AbortSignal): Promise<string | undefined>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const WebsearchParameters = Type.Object({
|
|
41
|
+
query: Type.String({ description: "Web search query" }),
|
|
42
|
+
numResults: Type.Optional(
|
|
43
|
+
Type.Number({
|
|
44
|
+
description: "Number of search results to return (default: 8)",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
livecrawl: Type.Optional(
|
|
48
|
+
StringEnum(["fallback", "preferred"] as const, {
|
|
49
|
+
description:
|
|
50
|
+
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
type: Type.Optional(
|
|
54
|
+
StringEnum(["auto", "fast", "deep"] as const, {
|
|
55
|
+
description:
|
|
56
|
+
"Search type - 'auto': balanced search, 'fast': quick results, 'deep': comprehensive search (default: 'auto')",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
contextMaxCharacters: Type.Optional(
|
|
60
|
+
Type.Number({
|
|
61
|
+
description:
|
|
62
|
+
"Maximum characters for context string optimized for LLMs (default: 10000)",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type SearchArgs = Static<typeof WebsearchParameters>;
|
|
68
|
+
|
|
69
|
+
export const McpResultPayload = Type.Object({
|
|
70
|
+
result: Type.Object({
|
|
71
|
+
content: Type.Array(
|
|
72
|
+
Type.Object({
|
|
73
|
+
type: Type.String(),
|
|
74
|
+
text: Type.String(),
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
}),
|
|
78
|
+
});
|