@lincoln504/pi-research 0.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 +21 -0
- package/README.md +131 -0
- package/config/searxng/default-settings.yml +189 -0
- package/config/searxng/limiter.toml +34 -0
- package/config/tooling/eslint.config.js +73 -0
- package/config/tooling/vitest.config.integration.ts +17 -0
- package/config/tooling/vitest.config.ts +40 -0
- package/config/tooling/vitest.config.unit.ts +12 -0
- package/docs/ARCHITECTURE.md +109 -0
- package/docs/assets/README-banner.jpg +0 -0
- package/package.json +92 -0
- package/scripts/setup.js +174 -0
- package/src/config.ts +206 -0
- package/src/constants.ts +148 -0
- package/src/healthcheck/index.ts +309 -0
- package/src/index.ts +71 -0
- package/src/infrastructure/searxng-lifecycle.ts +577 -0
- package/src/infrastructure/searxng-manager.ts +1173 -0
- package/src/infrastructure/state-manager.ts +918 -0
- package/src/logger.ts +246 -0
- package/src/orchestration/deep-research-orchestrator.ts +848 -0
- package/src/orchestration/deep-research-reducer.ts +129 -0
- package/src/orchestration/deep-research-types.ts +54 -0
- package/src/orchestration/id-utils.ts +63 -0
- package/src/orchestration/researcher.ts +104 -0
- package/src/orchestration/session-context.ts +43 -0
- package/src/prompts/research-tool-usage.md +45 -0
- package/src/prompts/researcher.md +166 -0
- package/src/prompts/system-coordinator.md +24 -0
- package/src/prompts/system-lead-evaluator.md +225 -0
- package/src/security/cisa-kev.ts +242 -0
- package/src/security/github-advisories.ts +607 -0
- package/src/security/index.ts +406 -0
- package/src/security/nvd.ts +532 -0
- package/src/security/osv.ts +487 -0
- package/src/security/types.ts +138 -0
- package/src/stackexchange/cache.ts +111 -0
- package/src/stackexchange/index.ts +352 -0
- package/src/stackexchange/output/compact.ts +64 -0
- package/src/stackexchange/output/index.ts +7 -0
- package/src/stackexchange/output/json.ts +21 -0
- package/src/stackexchange/output/table.ts +123 -0
- package/src/stackexchange/queries.ts +168 -0
- package/src/stackexchange/rest-client.ts +127 -0
- package/src/stackexchange/types.ts +129 -0
- package/src/tool.ts +430 -0
- package/src/tools/grep.ts +335 -0
- package/src/tools/index.ts +77 -0
- package/src/tools/scrape.ts +451 -0
- package/src/tools/search.ts +188 -0
- package/src/tools/security.ts +292 -0
- package/src/tools/stackexchange.ts +101 -0
- package/src/tui/research-panel.ts +576 -0
- package/src/tui/searxng-status.ts +80 -0
- package/src/types/extension-context.ts +60 -0
- package/src/types/llm.ts +111 -0
- package/src/utils/inject-date.ts +29 -0
- package/src/utils/input-validation.ts +103 -0
- package/src/utils/json-utils.ts +262 -0
- package/src/utils/make-resource-loader.ts +43 -0
- package/src/utils/research-error.ts +205 -0
- package/src/utils/research-export.ts +199 -0
- package/src/utils/searxng-config.ts +94 -0
- package/src/utils/session-state.ts +295 -0
- package/src/utils/shared-links.ts +123 -0
- package/src/utils/shutdown-manager.ts +54 -0
- package/src/utils/text-utils.ts +83 -0
- package/src/utils/tool-usage-tracker.ts +135 -0
- package/src/web-research/dependencies.ts +20 -0
- package/src/web-research/retry-utils.ts +232 -0
- package/src/web-research/scrapers.ts +935 -0
- package/src/web-research/search.ts +235 -0
- package/src/web-research/types.ts +47 -0
- package/src/web-research/utils.ts +158 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Error Handling
|
|
3
|
+
*
|
|
4
|
+
* Custom error class and error codes for research operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { logger } from '../logger.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Research error codes
|
|
11
|
+
*/
|
|
12
|
+
export enum ResearchErrorCode {
|
|
13
|
+
// General errors
|
|
14
|
+
UNKNOWN = 'UNKNOWN',
|
|
15
|
+
INVALID_INPUT = 'INVALID_INPUT',
|
|
16
|
+
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
|
|
17
|
+
|
|
18
|
+
// Configuration errors
|
|
19
|
+
CONFIG_INVALID = 'CONFIG_INVALID',
|
|
20
|
+
CONFIG_MISSING = 'CONFIG_MISSING',
|
|
21
|
+
|
|
22
|
+
// Network/API errors
|
|
23
|
+
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
24
|
+
API_ERROR = 'API_ERROR',
|
|
25
|
+
API_RATE_LIMITED = 'API_RATE_LIMITED',
|
|
26
|
+
API_TIMEOUT = 'API_TIMEOUT',
|
|
27
|
+
API_UNAVAILABLE = 'API_UNAVAILABLE',
|
|
28
|
+
|
|
29
|
+
// SearXNG errors
|
|
30
|
+
SEARXNG_START_FAILED = 'SEARXNG_START_FAILED',
|
|
31
|
+
SEARXNG_NOT_RUNNING = 'SEARXNG_NOT_RUNNING',
|
|
32
|
+
SEARXNG_QUERY_FAILED = 'SEARXNG_QUERY_FAILED',
|
|
33
|
+
|
|
34
|
+
// Orchestrator errors
|
|
35
|
+
ORCHESTRATION_FAILED = 'ORCHESTRATION_FAILED',
|
|
36
|
+
PLANNING_FAILED = 'PLANNING_FAILED',
|
|
37
|
+
RESEARCH_FAILED = 'RESEARCH_FAILED',
|
|
38
|
+
SYNTHESIS_FAILED = 'SYNTHESIS_FAILED',
|
|
39
|
+
|
|
40
|
+
// Researcher errors
|
|
41
|
+
RESEARCHER_FAILED = 'RESEARCHER_FAILED',
|
|
42
|
+
RESEARCHER_TIMEOUT = 'RESEARCHER_TIMEOUT',
|
|
43
|
+
|
|
44
|
+
// Scraping errors
|
|
45
|
+
SCRAPE_FAILED = 'SCRAPE_FAILED',
|
|
46
|
+
SCRAPE_TIMEOUT = 'SCRAPE_TIMEOUT',
|
|
47
|
+
SCRAPE_RATE_LIMITED = 'SCRAPE_RATE_LIMITED',
|
|
48
|
+
|
|
49
|
+
// Export errors
|
|
50
|
+
EXPORT_FAILED = 'EXPORT_FAILED',
|
|
51
|
+
|
|
52
|
+
// Validation errors
|
|
53
|
+
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
|
54
|
+
QUERY_TOO_SHORT = 'QUERY_TOO_SHORT',
|
|
55
|
+
QUERY_TOO_LONG = 'QUERY_TOO_LONG',
|
|
56
|
+
QUERY_INVALID = 'QUERY_INVALID',
|
|
57
|
+
|
|
58
|
+
// State errors
|
|
59
|
+
STATE_INVALID = 'STATE_INVALID',
|
|
60
|
+
STATE_TRANSITION_FAILED = 'STATE_TRANSITION_FAILED',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Research error class
|
|
65
|
+
*
|
|
66
|
+
* Provides structured error information for research operations.
|
|
67
|
+
*/
|
|
68
|
+
export class ResearchError extends Error {
|
|
69
|
+
public readonly code: ResearchErrorCode;
|
|
70
|
+
public readonly details?: unknown;
|
|
71
|
+
public readonly correlationId: string;
|
|
72
|
+
public readonly timestamp: number;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
code: ResearchErrorCode,
|
|
76
|
+
message: string,
|
|
77
|
+
details?: unknown,
|
|
78
|
+
) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'ResearchError';
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.details = details;
|
|
83
|
+
this.correlationId = generateCorrelationId();
|
|
84
|
+
this.timestamp = Date.now();
|
|
85
|
+
|
|
86
|
+
// Ensure proper prototype chain
|
|
87
|
+
Object.setPrototypeOf(this, ResearchError.prototype);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Convert error to a serializable object
|
|
92
|
+
*/
|
|
93
|
+
toJSON(): {
|
|
94
|
+
name: string;
|
|
95
|
+
code: ResearchErrorCode;
|
|
96
|
+
message: string;
|
|
97
|
+
details?: unknown;
|
|
98
|
+
correlationId: string;
|
|
99
|
+
timestamp: number;
|
|
100
|
+
} {
|
|
101
|
+
return {
|
|
102
|
+
name: this.name,
|
|
103
|
+
code: this.code,
|
|
104
|
+
message: this.message,
|
|
105
|
+
details: this.details,
|
|
106
|
+
correlationId: this.correlationId,
|
|
107
|
+
timestamp: this.timestamp,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Log the error with correlation ID
|
|
113
|
+
*/
|
|
114
|
+
log(context?: string): void {
|
|
115
|
+
const prefix = context ? `[${context}] ` : '';
|
|
116
|
+
logger.error(`${prefix}ResearchError: ${this.code}`, {
|
|
117
|
+
message: this.message,
|
|
118
|
+
details: this.details,
|
|
119
|
+
correlationId: this.correlationId,
|
|
120
|
+
timestamp: this.timestamp,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate a correlation ID for error tracking
|
|
127
|
+
*/
|
|
128
|
+
function generateCorrelationId(): string {
|
|
129
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a ResearchError from an unknown error
|
|
134
|
+
*
|
|
135
|
+
* @param error - The error to convert
|
|
136
|
+
* @param defaultCode - Default error code if type cannot be determined
|
|
137
|
+
* @returns A ResearchError instance
|
|
138
|
+
*/
|
|
139
|
+
export function createResearchError(
|
|
140
|
+
error: unknown,
|
|
141
|
+
defaultCode: ResearchErrorCode = ResearchErrorCode.UNKNOWN,
|
|
142
|
+
): ResearchError {
|
|
143
|
+
if (error instanceof ResearchError) {
|
|
144
|
+
return error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (error instanceof Error) {
|
|
148
|
+
// Try to determine error code from error message
|
|
149
|
+
const message = error.message.toLowerCase();
|
|
150
|
+
|
|
151
|
+
if (message.includes('timeout')) {
|
|
152
|
+
return new ResearchError(ResearchErrorCode.API_TIMEOUT, error.message, error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (message.includes('rate limit') || message.includes('too many requests')) {
|
|
156
|
+
return new ResearchError(ResearchErrorCode.API_RATE_LIMITED, error.message, error);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (message.includes('network') || message.includes('econnrefused')) {
|
|
160
|
+
return new ResearchError(ResearchErrorCode.NETWORK_ERROR, error.message, error);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (message.includes('404') || message.includes('not found')) {
|
|
164
|
+
return new ResearchError(ResearchErrorCode.API_UNAVAILABLE, error.message, error);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Generic error
|
|
168
|
+
return new ResearchError(defaultCode, error.message, error);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Unknown error type
|
|
172
|
+
return new ResearchError(
|
|
173
|
+
defaultCode,
|
|
174
|
+
typeof error === 'string' ? error : 'Unknown error',
|
|
175
|
+
error,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if an error is transient (can be retried)
|
|
181
|
+
*
|
|
182
|
+
* @param error - The error to check
|
|
183
|
+
* @returns True if the error is transient
|
|
184
|
+
*/
|
|
185
|
+
export function isTransientError(error: ResearchError): boolean {
|
|
186
|
+
const transientCodes = [
|
|
187
|
+
ResearchErrorCode.NETWORK_ERROR,
|
|
188
|
+
ResearchErrorCode.API_RATE_LIMITED,
|
|
189
|
+
ResearchErrorCode.API_TIMEOUT,
|
|
190
|
+
ResearchErrorCode.API_UNAVAILABLE,
|
|
191
|
+
ResearchErrorCode.SEARXNG_START_FAILED,
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
return transientCodes.includes(error.code);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if an error is fatal (cannot be retried)
|
|
199
|
+
*
|
|
200
|
+
* @param error - The error to check
|
|
201
|
+
* @returns True if the error is fatal
|
|
202
|
+
*/
|
|
203
|
+
export function isFatalError(error: ResearchError): boolean {
|
|
204
|
+
return !isTransientError(error);
|
|
205
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Export Utility
|
|
3
|
+
*
|
|
4
|
+
* Resolves a smart output directory for research reports:
|
|
5
|
+
*
|
|
6
|
+
* 1. cwd is the user's home directory or a system directory → os.tmpdir()
|
|
7
|
+
* 2. cwd has a recognised first-level subdirectory → that subdir
|
|
8
|
+
* (research, docs, doc, ref, references, notes)
|
|
9
|
+
* 3. Otherwise → cwd
|
|
10
|
+
*
|
|
11
|
+
* Cross-platform: works on Linux, macOS, and Windows.
|
|
12
|
+
* Subdir detection uses case-insensitive readdir matching so it works correctly
|
|
13
|
+
* on case-insensitive filesystems (macOS APFS, Windows NTFS).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import { homedir, tmpdir, platform } from 'node:os';
|
|
18
|
+
import { join, resolve, normalize, sep } from 'node:path';
|
|
19
|
+
import { logger } from '../logger.ts';
|
|
20
|
+
import { MAX_QUERY_LENGTH, MAX_EXPORT_RETRIES } from '../constants.ts';
|
|
21
|
+
|
|
22
|
+
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
23
|
+
const DIGITS = '0123456789';
|
|
24
|
+
|
|
25
|
+
// Subdirectory names to probe, in priority order. Matched case-insensitively.
|
|
26
|
+
const PREFERRED_SUBDIRS = [
|
|
27
|
+
'research',
|
|
28
|
+
'docs',
|
|
29
|
+
'doc',
|
|
30
|
+
'ref',
|
|
31
|
+
'references',
|
|
32
|
+
'notes',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Unix/macOS paths that are never appropriate output destinations.
|
|
36
|
+
const UNIX_SYSTEM_PREFIXES = [
|
|
37
|
+
'/',
|
|
38
|
+
'/bin', '/sbin', '/usr', '/lib', '/lib32', '/lib64',
|
|
39
|
+
'/etc', '/var', '/opt', '/root', '/proc', '/sys',
|
|
40
|
+
'/dev', '/run', '/boot', '/srv',
|
|
41
|
+
'/Applications', '/Library', '/System', // macOS
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Windows paths (lowercased for comparison) that are never appropriate.
|
|
45
|
+
const WIN_SYSTEM_PREFIXES_LC = [
|
|
46
|
+
'c:\\windows',
|
|
47
|
+
'c:\\program files',
|
|
48
|
+
'c:\\program files (x86)',
|
|
49
|
+
'c:\\programdata',
|
|
50
|
+
'c:\\recovery',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns true when `dir` is the user's home directory or a system directory
|
|
55
|
+
* where writing research files would be unexpected or inappropriate.
|
|
56
|
+
*/
|
|
57
|
+
function isSafeToWriteDirectly(dir: string): boolean {
|
|
58
|
+
const normalized = normalize(resolve(dir));
|
|
59
|
+
|
|
60
|
+
// Reject the home directory itself
|
|
61
|
+
const home = normalize(resolve(homedir()));
|
|
62
|
+
if (normalized === home) return false;
|
|
63
|
+
|
|
64
|
+
if (platform() === 'win32') {
|
|
65
|
+
// Reject bare drive roots (e.g. C:\) and known system paths
|
|
66
|
+
if (/^[a-zA-Z]:\\?$/.test(normalized)) return false;
|
|
67
|
+
const lc = normalized.toLowerCase();
|
|
68
|
+
for (const prefix of WIN_SYSTEM_PREFIXES_LC) {
|
|
69
|
+
if (lc === prefix || lc.startsWith(prefix + sep) || lc.startsWith(prefix + '/')) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
// Unix / macOS
|
|
75
|
+
for (const prefix of UNIX_SYSTEM_PREFIXES) {
|
|
76
|
+
// Exact match or prefix + separator
|
|
77
|
+
if (normalized === prefix || normalized.startsWith(prefix + sep)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Probe `cwd` for a recognised research-output subdirectory.
|
|
88
|
+
* Uses readdir + case-insensitive comparison so it works on any filesystem.
|
|
89
|
+
* Returns the first match in PREFERRED_SUBDIRS priority order, or null.
|
|
90
|
+
*/
|
|
91
|
+
async function findPreferredSubdir(cwd: string): Promise<string | null> {
|
|
92
|
+
let entries: string[];
|
|
93
|
+
try {
|
|
94
|
+
entries = await fs.readdir(cwd);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const target of PREFERRED_SUBDIRS) {
|
|
100
|
+
const match = entries.find(e => e.toLowerCase() === target);
|
|
101
|
+
if (match === undefined) continue;
|
|
102
|
+
const candidate = join(cwd, match);
|
|
103
|
+
try {
|
|
104
|
+
const stat = await fs.stat(candidate);
|
|
105
|
+
if (stat.isDirectory()) return candidate;
|
|
106
|
+
} catch {
|
|
107
|
+
// Not a directory or not accessible — keep looking
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the directory where the report should be saved.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveExportDir(cwd: string): Promise<string> {
|
|
117
|
+
if (!isSafeToWriteDirectly(cwd)) {
|
|
118
|
+
logger.log(`[export] cwd "${cwd}" is home/system — using tmpdir`);
|
|
119
|
+
return tmpdir();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const subdir = await findPreferredSubdir(cwd);
|
|
123
|
+
if (subdir !== null) {
|
|
124
|
+
logger.log(`[export] Using preferred subdir: ${subdir}`);
|
|
125
|
+
return subdir;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cwd;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sanitize query for use in a filename.
|
|
133
|
+
*/
|
|
134
|
+
function sanitizeQuery(query: string): string {
|
|
135
|
+
return query
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.trim()
|
|
138
|
+
.slice(0, MAX_QUERY_LENGTH)
|
|
139
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
140
|
+
.replace(/\s+/g, '-')
|
|
141
|
+
.replace(/-+/g, '-')
|
|
142
|
+
.replace(/^-|-$/g, '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate a random hash for collision avoidance.
|
|
147
|
+
* Format: letter + number (e.g., a3, b7, x9)
|
|
148
|
+
*/
|
|
149
|
+
function generateHash(): string {
|
|
150
|
+
const letter = LETTERS[Math.floor(Math.random() * LETTERS.length)]!;
|
|
151
|
+
const digit = DIGITS[Math.floor(Math.random() * DIGITS.length)]!;
|
|
152
|
+
return letter + digit;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Export a research report to the resolved output directory.
|
|
157
|
+
*
|
|
158
|
+
* @param query - The research query (used to derive the filename)
|
|
159
|
+
* @param result - The report content
|
|
160
|
+
* @param _mode - 'quick' | 'deep' (reserved for future use)
|
|
161
|
+
* @param cwd - The working directory of the pi session
|
|
162
|
+
* @returns The saved file path, or null on failure
|
|
163
|
+
*/
|
|
164
|
+
export async function exportResearchReport(
|
|
165
|
+
query: string,
|
|
166
|
+
result: string,
|
|
167
|
+
_mode: 'quick' | 'deep',
|
|
168
|
+
cwd?: string,
|
|
169
|
+
): Promise<string | null> {
|
|
170
|
+
const sanitizedQuery = sanitizeQuery(query);
|
|
171
|
+
const baseFilename = `pi-research-${sanitizedQuery}`;
|
|
172
|
+
const targetDir = await resolveExportDir(cwd ?? tmpdir());
|
|
173
|
+
|
|
174
|
+
for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
|
|
175
|
+
const hash = generateHash();
|
|
176
|
+
const filename = `${baseFilename}-${hash}.md`;
|
|
177
|
+
const filepath = join(targetDir, filename);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await fs.writeFile(filepath, result, { flag: 'wx' });
|
|
181
|
+
logger.log(`[export] Research report saved to: ${filepath}`);
|
|
182
|
+
return filepath;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue;
|
|
185
|
+
logger.error(`[export] Failed to save research report:`, error);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
logger.error(`[export] Failed to save research report after ${MAX_EXPORT_RETRIES} attempts (hash collision)`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Append the saved-path footer to a research result string.
|
|
196
|
+
*/
|
|
197
|
+
export function appendExportMessage(result: string, filepath: string): string {
|
|
198
|
+
return result + `\n\n---\n\nResearch report saved to: ${filepath}`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearXNG Configuration Loader
|
|
3
|
+
*
|
|
4
|
+
* Reads the SearXNG config YAML and extracts active search engines.
|
|
5
|
+
* Ensures healthcheck and config stay synchronized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { load } from 'js-yaml';
|
|
12
|
+
import { logger } from '../logger.ts';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
|
|
17
|
+
function parseYamlEngines(content: string): Array<{ name: string; disabled?: boolean }> {
|
|
18
|
+
const parsed = load(content) as { engines?: Array<{ name?: string; disabled?: boolean }> } | undefined;
|
|
19
|
+
const engines = parsed?.engines;
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(engines)) {
|
|
22
|
+
logger.warn('[searxng-config] No engines section found in config');
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return engines
|
|
27
|
+
.filter((engine): engine is { name: string; disabled?: boolean } => typeof engine?.name === 'string')
|
|
28
|
+
.map((engine) => ({ name: engine.name.trim(), disabled: engine.disabled === true }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load SearXNG configuration and extract active general search engines
|
|
33
|
+
* Filters out:
|
|
34
|
+
* - Disabled engines
|
|
35
|
+
* - Category-specific engines (images, news, videos)
|
|
36
|
+
* - Category-restricted engines (stackoverflow, arxiv, semantic scholar, etc.)
|
|
37
|
+
* - Encyclopedic engines (wikipedia)
|
|
38
|
+
*/
|
|
39
|
+
export function getActiveSearxngEngines(): string[] {
|
|
40
|
+
try {
|
|
41
|
+
const configPath = join(__dirname, '../../config/searxng/default-settings.yml');
|
|
42
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
43
|
+
const allEngines = parseYamlEngines(content);
|
|
44
|
+
|
|
45
|
+
// List of general purpose search engines (whitelist approach is safer)
|
|
46
|
+
const knownGeneralEngines = ['google', 'bing', 'brave', 'duckduckgo', 'startpage', 'mojeek', 'qwant'];
|
|
47
|
+
|
|
48
|
+
// Filter for general search engines only
|
|
49
|
+
const activeGeneralEngines = allEngines
|
|
50
|
+
.filter(
|
|
51
|
+
e =>
|
|
52
|
+
!e.disabled && // Not disabled
|
|
53
|
+
knownGeneralEngines.includes(e.name.toLowerCase()) // Only known general engines
|
|
54
|
+
)
|
|
55
|
+
.map(e => e.name.toLowerCase());
|
|
56
|
+
|
|
57
|
+
if (activeGeneralEngines.length === 0) {
|
|
58
|
+
logger.warn('[searxng-config] No active general search engines found in config');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.log(`[searxng-config] Active general search engines: [${activeGeneralEngines.join(', ')}]`);
|
|
62
|
+
return activeGeneralEngines;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
65
|
+
logger.error('[searxng-config] Failed to load config:', msg);
|
|
66
|
+
// Fallback to known good defaults if config can't be loaded
|
|
67
|
+
return ['google', 'bing', 'brave'];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate that healthcheck engine list matches config
|
|
73
|
+
* Used in tests to catch drift
|
|
74
|
+
*/
|
|
75
|
+
export function validateEngineListConsistency(expectedEngines: string[]): {
|
|
76
|
+
isValid: boolean;
|
|
77
|
+
missing: string[];
|
|
78
|
+
extra: string[];
|
|
79
|
+
} {
|
|
80
|
+
const actualEngines = getActiveSearxngEngines();
|
|
81
|
+
const actualSet = new Set(actualEngines);
|
|
82
|
+
const expectedSet = new Set(expectedEngines);
|
|
83
|
+
|
|
84
|
+
const missing = actualEngines.filter(e => !expectedSet.has(e));
|
|
85
|
+
const extra = expectedEngines.filter(e => !actualSet.has(e));
|
|
86
|
+
|
|
87
|
+
const isValid = missing.length === 0 && extra.length === 0;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
isValid,
|
|
91
|
+
missing,
|
|
92
|
+
extra,
|
|
93
|
+
};
|
|
94
|
+
}
|