webpeel 0.20.2 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/server/app.d.ts +14 -0
  2. package/dist/server/app.js +384 -0
  3. package/dist/server/auth-store.d.ts +27 -0
  4. package/dist/server/auth-store.js +88 -0
  5. package/dist/server/email-service.d.ts +21 -0
  6. package/dist/server/email-service.js +79 -0
  7. package/dist/server/job-queue.d.ts +100 -0
  8. package/dist/server/job-queue.js +145 -0
  9. package/dist/server/logger.d.ts +10 -0
  10. package/dist/server/logger.js +37 -0
  11. package/dist/server/middleware/auth.d.ts +28 -0
  12. package/dist/server/middleware/auth.js +221 -0
  13. package/dist/server/middleware/rate-limit.d.ts +24 -0
  14. package/dist/server/middleware/rate-limit.js +167 -0
  15. package/dist/server/middleware/url-validator.d.ts +15 -0
  16. package/dist/server/middleware/url-validator.js +186 -0
  17. package/dist/server/openapi.yaml +6418 -0
  18. package/dist/server/pg-auth-store.d.ts +132 -0
  19. package/dist/server/pg-auth-store.js +472 -0
  20. package/dist/server/pg-job-queue.d.ts +59 -0
  21. package/dist/server/pg-job-queue.js +375 -0
  22. package/dist/server/premium/domain-intel.d.ts +16 -0
  23. package/dist/server/premium/domain-intel.js +133 -0
  24. package/dist/server/premium/index.d.ts +17 -0
  25. package/dist/server/premium/index.js +35 -0
  26. package/dist/server/premium/swr-cache.d.ts +14 -0
  27. package/dist/server/premium/swr-cache.js +34 -0
  28. package/dist/server/routes/activity.d.ts +6 -0
  29. package/dist/server/routes/activity.js +74 -0
  30. package/dist/server/routes/answer.d.ts +5 -0
  31. package/dist/server/routes/answer.js +125 -0
  32. package/dist/server/routes/ask.d.ts +28 -0
  33. package/dist/server/routes/ask.js +229 -0
  34. package/dist/server/routes/batch.d.ts +6 -0
  35. package/dist/server/routes/batch.js +493 -0
  36. package/dist/server/routes/cli-usage.d.ts +6 -0
  37. package/dist/server/routes/cli-usage.js +127 -0
  38. package/dist/server/routes/compat.d.ts +23 -0
  39. package/dist/server/routes/compat.js +652 -0
  40. package/dist/server/routes/deep-fetch.d.ts +8 -0
  41. package/dist/server/routes/deep-fetch.js +57 -0
  42. package/dist/server/routes/demo.d.ts +24 -0
  43. package/dist/server/routes/demo.js +517 -0
  44. package/dist/server/routes/do.d.ts +8 -0
  45. package/dist/server/routes/do.js +72 -0
  46. package/dist/server/routes/extract.d.ts +8 -0
  47. package/dist/server/routes/extract.js +235 -0
  48. package/dist/server/routes/fetch.d.ts +7 -0
  49. package/dist/server/routes/fetch.js +999 -0
  50. package/dist/server/routes/health.d.ts +7 -0
  51. package/dist/server/routes/health.js +19 -0
  52. package/dist/server/routes/jobs.d.ts +7 -0
  53. package/dist/server/routes/jobs.js +573 -0
  54. package/dist/server/routes/mcp.d.ts +14 -0
  55. package/dist/server/routes/mcp.js +141 -0
  56. package/dist/server/routes/oauth.d.ts +9 -0
  57. package/dist/server/routes/oauth.js +396 -0
  58. package/dist/server/routes/playground.d.ts +17 -0
  59. package/dist/server/routes/playground.js +283 -0
  60. package/dist/server/routes/screenshot.d.ts +22 -0
  61. package/dist/server/routes/screenshot.js +816 -0
  62. package/dist/server/routes/search.d.ts +6 -0
  63. package/dist/server/routes/search.js +303 -0
  64. package/dist/server/routes/session.d.ts +15 -0
  65. package/dist/server/routes/session.js +397 -0
  66. package/dist/server/routes/stats.d.ts +6 -0
  67. package/dist/server/routes/stats.js +71 -0
  68. package/dist/server/routes/stripe.d.ts +15 -0
  69. package/dist/server/routes/stripe.js +294 -0
  70. package/dist/server/routes/users.d.ts +8 -0
  71. package/dist/server/routes/users.js +1671 -0
  72. package/dist/server/routes/watch.d.ts +15 -0
  73. package/dist/server/routes/watch.js +309 -0
  74. package/dist/server/routes/webhooks.d.ts +26 -0
  75. package/dist/server/routes/webhooks.js +170 -0
  76. package/dist/server/routes/youtube.d.ts +6 -0
  77. package/dist/server/routes/youtube.js +130 -0
  78. package/dist/server/sentry.d.ts +13 -0
  79. package/dist/server/sentry.js +38 -0
  80. package/dist/server/types.d.ts +15 -0
  81. package/dist/server/types.js +7 -0
  82. package/dist/server/utils/response.d.ts +44 -0
  83. package/dist/server/utils/response.js +69 -0
  84. package/dist/server/utils/sse.d.ts +22 -0
  85. package/dist/server/utils/sse.js +38 -0
  86. package/package.json +2 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Activity endpoint - provides recent API request history
3
+ */
4
+ import { Router } from 'express';
5
+ import { PostgresAuthStore } from '../pg-auth-store.js';
6
+ export function createActivityRouter(authStore) {
7
+ const router = Router();
8
+ router.get('/v1/activity', async (req, res) => {
9
+ try {
10
+ // Require authentication (API key or JWT session token)
11
+ const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
12
+ if (!userId) {
13
+ res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'Authentication required', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#unauthorized' }, requestId: req.requestId });
14
+ return;
15
+ }
16
+ // Only works with PostgreSQL backend
17
+ if (!(authStore instanceof PostgresAuthStore)) {
18
+ res.status(501).json({
19
+ success: false,
20
+ error: {
21
+ type: 'not_implemented',
22
+ message: 'Activity endpoint requires PostgreSQL backend',
23
+ docs: 'https://webpeel.dev/docs/errors#not_implemented',
24
+ },
25
+ requestId: req.requestId,
26
+ });
27
+ return;
28
+ }
29
+ // Access pool via any cast (pool is private but we need direct DB access)
30
+ const pgStore = authStore;
31
+ const limit = Math.min(parseInt(req.query.limit) || 50, 100);
32
+ // Get recent requests from usage_logs
33
+ const activityQuery = `
34
+ SELECT
35
+ id,
36
+ url,
37
+ method,
38
+ status_code,
39
+ processing_time_ms,
40
+ tokens_used,
41
+ created_at
42
+ FROM usage_logs
43
+ WHERE user_id = $1
44
+ ORDER BY created_at DESC
45
+ LIMIT $2
46
+ `;
47
+ const result = await pgStore.pool.query(activityQuery, [userId, limit]);
48
+ // Transform to frontend format
49
+ const requests = result.rows.map((row) => ({
50
+ id: row.id,
51
+ url: row.url || 'N/A',
52
+ status: (row.status_code >= 200 && row.status_code < 300) ? 'success' : 'error',
53
+ responseTime: row.processing_time_ms || 0,
54
+ mode: row.method || 'basic',
55
+ timestamp: row.created_at,
56
+ tokensUsed: row.tokens_used || null,
57
+ }));
58
+ res.json({ requests });
59
+ }
60
+ catch (error) {
61
+ console.error('Activity error:', error);
62
+ res.status(500).json({
63
+ success: false,
64
+ error: {
65
+ type: 'internal_error',
66
+ message: 'Failed to retrieve activity',
67
+ docs: 'https://webpeel.dev/docs/errors#internal_error',
68
+ },
69
+ requestId: req.requestId,
70
+ });
71
+ }
72
+ });
73
+ return router;
74
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * /v1/answer — search + fetch + LLM-generated answer with citations (BYOK)
3
+ */
4
+ import { Router } from 'express';
5
+ export declare function createAnswerRouter(): Router;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * /v1/answer — search + fetch + LLM-generated answer with citations (BYOK)
3
+ */
4
+ import { Router } from 'express';
5
+ import { answerQuestion, } from '../../core/answer.js';
6
+ const VALID_LLM_PROVIDERS = ['openai', 'anthropic', 'google'];
7
+ const VALID_SEARCH_PROVIDERS = ['duckduckgo', 'brave'];
8
+ export function createAnswerRouter() {
9
+ const router = Router();
10
+ router.post('/v1/answer', async (req, res) => {
11
+ // Deprecation notice — prefer /v1/fetch?question=... which is LLM-free
12
+ res.setHeader('X-Deprecated', 'true');
13
+ res.setHeader('X-Deprecated-Use', '/v1/fetch?question=...');
14
+ // AUTH: require authentication (global middleware sets req.auth)
15
+ const ansAuthId = req.auth?.keyInfo?.accountId || req.user?.userId;
16
+ if (!ansAuthId) {
17
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required. Get one at https://app.webpeel.dev/keys', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#authentication_required' }, requestId: req.requestId });
18
+ return;
19
+ }
20
+ try {
21
+ const { question, searchProvider, searchApiKey, llmProvider, llmApiKey, llmModel, maxSources, stream, } = req.body;
22
+ // --- Validation -----------------------------------------------------------
23
+ if (!question || typeof question !== 'string' || question.trim().length === 0) {
24
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "question" parameter', hint: 'Include a "question" string in the request body', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
25
+ return;
26
+ }
27
+ if (question.length > 2000) {
28
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: '"question" too long (max 2000 characters)', hint: 'Keep the question under 2000 characters', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
29
+ return;
30
+ }
31
+ if (!llmProvider || !VALID_LLM_PROVIDERS.includes(llmProvider)) {
32
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: `"llmProvider" is required and must be one of: ${VALID_LLM_PROVIDERS.join(', ')}`, hint: `Supported providers: ${VALID_LLM_PROVIDERS.join(', ')}`, docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
33
+ return;
34
+ }
35
+ if (!llmApiKey || typeof llmApiKey !== 'string' || llmApiKey.trim().length === 0) {
36
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "llmApiKey" (BYOK required)', hint: 'Provide your own LLM API key in the "llmApiKey" field', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
37
+ return;
38
+ }
39
+ const resolvedSearchProvider = searchProvider && VALID_SEARCH_PROVIDERS.includes(searchProvider)
40
+ ? searchProvider
41
+ : 'duckduckgo';
42
+ // Accept search API key from body or header
43
+ const resolvedSearchApiKey = searchApiKey || req.headers['x-search-api-key'] || undefined;
44
+ const resolvedMaxSources = typeof maxSources === 'number'
45
+ ? Math.min(Math.max(maxSources, 1), 10)
46
+ : 5;
47
+ const shouldStream = stream === true;
48
+ // --- Streaming response (SSE) -------------------------------------------
49
+ if (shouldStream) {
50
+ res.setHeader('Content-Type', 'text/event-stream');
51
+ res.setHeader('Cache-Control', 'no-cache');
52
+ res.setHeader('Connection', 'keep-alive');
53
+ res.setHeader('X-Accel-Buffering', 'no'); // nginx
54
+ res.flushHeaders();
55
+ const answerReq = {
56
+ question: question.trim(),
57
+ searchProvider: resolvedSearchProvider,
58
+ searchApiKey: resolvedSearchApiKey,
59
+ llmProvider: llmProvider,
60
+ llmApiKey: llmApiKey.trim(),
61
+ llmModel,
62
+ maxSources: resolvedMaxSources,
63
+ stream: true,
64
+ onChunk: (text) => {
65
+ const payload = JSON.stringify({ type: 'chunk', text });
66
+ res.write(`data: ${payload}\n\n`);
67
+ },
68
+ };
69
+ try {
70
+ const result = await answerQuestion(answerReq);
71
+ const donePayload = JSON.stringify({
72
+ type: 'done',
73
+ citations: result.citations,
74
+ searchProvider: result.searchProvider,
75
+ llmProvider: result.llmProvider,
76
+ llmModel: result.llmModel,
77
+ tokensUsed: result.tokensUsed,
78
+ });
79
+ res.write(`data: ${donePayload}\n\n`);
80
+ }
81
+ catch (err) {
82
+ const errMsg = err instanceof Error ? err.message : 'Unknown error';
83
+ const errPayload = JSON.stringify({ type: 'error', message: errMsg });
84
+ res.write(`data: ${errPayload}\n\n`);
85
+ }
86
+ res.end();
87
+ return;
88
+ }
89
+ // --- Non-streaming response ---------------------------------------------
90
+ const answerReq = {
91
+ question: question.trim(),
92
+ searchProvider: resolvedSearchProvider,
93
+ searchApiKey: resolvedSearchApiKey,
94
+ llmProvider: llmProvider,
95
+ llmApiKey: llmApiKey.trim(),
96
+ llmModel,
97
+ maxSources: resolvedMaxSources,
98
+ stream: false,
99
+ };
100
+ const result = await answerQuestion(answerReq);
101
+ res.json({
102
+ answer: result.answer,
103
+ citations: result.citations,
104
+ searchProvider: result.searchProvider,
105
+ llmProvider: result.llmProvider,
106
+ llmModel: result.llmModel,
107
+ tokensUsed: result.tokensUsed,
108
+ });
109
+ }
110
+ catch (error) {
111
+ const err = error;
112
+ console.error('Answer error:', err);
113
+ res.status(500).json({
114
+ success: false,
115
+ error: {
116
+ type: 'answer_failed',
117
+ message: 'Failed to generate answer. Please try again.',
118
+ docs: 'https://webpeel.dev/docs/errors#answer_failed',
119
+ },
120
+ requestId: req.requestId,
121
+ });
122
+ }
123
+ });
124
+ return router;
125
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /v1/ask?q=<question>&sources=3
3
+ * POST /v1/ask { "question": "...", "sources": 3 }
4
+ *
5
+ * LLM-free web Q&A: search → fetch top pages → BM25 → best answer
6
+ *
7
+ * Returns:
8
+ * {
9
+ * question: string,
10
+ * answer: string, // best passage from top sources
11
+ * confidence: number, // 0-1
12
+ * sources: [{url, title, snippet, confidence}],
13
+ * method: "bm25",
14
+ * elapsed: number // ms
15
+ * }
16
+ *
17
+ * No LLM key required — 100% deterministic BM25 ranking.
18
+ * Competitors: Tavily charges $50/mo and requires an API key.
19
+ * We do this with zero LLM cost, included in every plan.
20
+ *
21
+ * Performance targets:
22
+ * - Source pages fetched in parallel with 5s timeout (no browser escalation)
23
+ * - Early termination when high-confidence answer found (>=0.85)
24
+ * - 10s hard timeout on the entire flow
25
+ * - 5-minute in-memory cache for repeated questions
26
+ */
27
+ import { Router } from 'express';
28
+ export declare function createAskRouter(): Router;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * GET /v1/ask?q=<question>&sources=3
3
+ * POST /v1/ask { "question": "...", "sources": 3 }
4
+ *
5
+ * LLM-free web Q&A: search → fetch top pages → BM25 → best answer
6
+ *
7
+ * Returns:
8
+ * {
9
+ * question: string,
10
+ * answer: string, // best passage from top sources
11
+ * confidence: number, // 0-1
12
+ * sources: [{url, title, snippet, confidence}],
13
+ * method: "bm25",
14
+ * elapsed: number // ms
15
+ * }
16
+ *
17
+ * No LLM key required — 100% deterministic BM25 ranking.
18
+ * Competitors: Tavily charges $50/mo and requires an API key.
19
+ * We do this with zero LLM cost, included in every plan.
20
+ *
21
+ * Performance targets:
22
+ * - Source pages fetched in parallel with 5s timeout (no browser escalation)
23
+ * - Early termination when high-confidence answer found (>=0.85)
24
+ * - 10s hard timeout on the entire flow
25
+ * - 5-minute in-memory cache for repeated questions
26
+ */
27
+ import { Router } from 'express';
28
+ import { peel } from '../../index.js';
29
+ import { quickAnswer } from '../../core/quick-answer.js';
30
+ import { getBestSearchProvider } from '../../core/search-provider.js';
31
+ const resultCache = new Map();
32
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
33
+ function getCacheKey(question, numSources) {
34
+ return `${question.trim().toLowerCase()}|${numSources}`;
35
+ }
36
+ function getFromCache(key) {
37
+ const entry = resultCache.get(key);
38
+ if (!entry)
39
+ return null;
40
+ if (Date.now() > entry.expiresAt) {
41
+ resultCache.delete(key);
42
+ return null;
43
+ }
44
+ return entry.result;
45
+ }
46
+ function setInCache(key, result) {
47
+ // Evict stale entries periodically (simple GC — keep max 500 entries)
48
+ if (resultCache.size >= 500) {
49
+ const now = Date.now();
50
+ for (const [k, v] of resultCache) {
51
+ if (v.expiresAt < now)
52
+ resultCache.delete(k);
53
+ }
54
+ }
55
+ resultCache.set(key, { result, expiresAt: Date.now() + CACHE_TTL_MS });
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Route factory
59
+ // ---------------------------------------------------------------------------
60
+ export function createAskRouter() {
61
+ const router = Router();
62
+ async function handleAsk(question, numSources, req, res) {
63
+ const startMs = Date.now();
64
+ const elapsed = () => Date.now() - startMs;
65
+ if (!question?.trim()) {
66
+ res.status(400).json({ success: false, error: { type: 'missing_question', message: 'Provide q= or question= parameter', hint: 'GET /v1/ask?q=your+question or POST {"question": "your question"}', docs: 'https://webpeel.dev/docs/errors#missing_question' }, requestId: req.requestId });
67
+ return;
68
+ }
69
+ // Auth check — global middleware sets req.auth
70
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
71
+ if (!authId) {
72
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required. Get one at https://app.webpeel.dev/keys', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#authentication_required' }, requestId: req.requestId });
73
+ return;
74
+ }
75
+ const clampedSources = Math.min(Math.max(numSources, 1), 5);
76
+ // Cache check — return cached result immediately for repeated questions
77
+ const cacheKey = getCacheKey(question, clampedSources);
78
+ const cached = getFromCache(cacheKey);
79
+ if (cached) {
80
+ if (process.env.DEBUG)
81
+ console.debug('[ask] cache hit in', elapsed(), 'ms');
82
+ res.json({ ...cached, elapsed: elapsed() });
83
+ return;
84
+ }
85
+ // -----------------------------------------------------------------------
86
+ // Total flow timeout — 10s hard cap.
87
+ // -----------------------------------------------------------------------
88
+ const TOTAL_TIMEOUT_MS = 10000;
89
+ let timedOut = false;
90
+ const totalTimer = setTimeout(() => { timedOut = true; }, TOTAL_TIMEOUT_MS);
91
+ try {
92
+ // Step 1: Search
93
+ const searchStart = Date.now();
94
+ const { provider, apiKey } = getBestSearchProvider();
95
+ let searchResults;
96
+ try {
97
+ searchResults = await provider.searchWeb(question.trim(), {
98
+ count: clampedSources,
99
+ apiKey,
100
+ });
101
+ }
102
+ catch {
103
+ searchResults = [];
104
+ }
105
+ if (process.env.DEBUG)
106
+ console.debug(`[ask] search ${Date.now() - searchStart}ms, ${searchResults.length} results`);
107
+ if (!searchResults.length) {
108
+ clearTimeout(totalTimer);
109
+ res.json({
110
+ question,
111
+ answer: null,
112
+ confidence: 0,
113
+ sources: [],
114
+ method: 'bm25',
115
+ elapsed: elapsed(),
116
+ });
117
+ return;
118
+ }
119
+ // -----------------------------------------------------------------------
120
+ // Step 2: Fetch top sources in parallel
121
+ // - noEscalate: true → skip browser escalation (simple HTTP only)
122
+ // - render: false → don't start headless browser
123
+ // - timeout: 5000 → 5s per source max
124
+ // - budget: 3000 → keep content manageable
125
+ // -----------------------------------------------------------------------
126
+ const PER_SOURCE_TIMEOUT_MS = 5000;
127
+ const fetchStart = Date.now();
128
+ const sourceUrls = searchResults.slice(0, clampedSources);
129
+ const fetchPromises = sourceUrls.map((r) => Promise.race([
130
+ peel(r.url, {
131
+ render: false,
132
+ noEscalate: true,
133
+ format: 'markdown',
134
+ timeout: PER_SOURCE_TIMEOUT_MS,
135
+ budget: 3000,
136
+ }).then((result) => ({ result, searchResult: r })),
137
+ new Promise((_, reject) => setTimeout(() => reject(new Error('per-source timeout')), PER_SOURCE_TIMEOUT_MS)),
138
+ ]));
139
+ const fetched = await Promise.allSettled(fetchPromises);
140
+ if (process.env.DEBUG) {
141
+ const ok = fetched.filter(f => f.status === 'fulfilled').length;
142
+ console.debug(`[ask] fetch ${Date.now() - fetchStart}ms, ${ok}/${sourceUrls.length} ok`);
143
+ }
144
+ // -----------------------------------------------------------------------
145
+ // Step 3: Score with quickAnswer, sort by confidence
146
+ // Early termination: if any source yields >=0.85 confidence, use it now
147
+ // -----------------------------------------------------------------------
148
+ const HIGH_CONFIDENCE_THRESHOLD = 0.85;
149
+ const answers = [];
150
+ for (const f of fetched) {
151
+ if (timedOut)
152
+ break;
153
+ if (f.status !== 'fulfilled')
154
+ continue;
155
+ const { result, searchResult } = f.value;
156
+ const qa = quickAnswer({
157
+ question,
158
+ content: result.content,
159
+ url: result.url,
160
+ maxPassages: 2,
161
+ });
162
+ answers.push({
163
+ answer: qa.answer,
164
+ confidence: qa.confidence,
165
+ source: {
166
+ url: result.url,
167
+ title: result.title || searchResult.title,
168
+ snippet: searchResult.snippet,
169
+ },
170
+ });
171
+ // Early termination on high confidence
172
+ if (qa.confidence >= HIGH_CONFIDENCE_THRESHOLD) {
173
+ if (process.env.DEBUG)
174
+ console.debug(`[ask] early exit confidence=${qa.confidence.toFixed(2)} at ${elapsed()}ms`);
175
+ break;
176
+ }
177
+ }
178
+ answers.sort((a, b) => b.confidence - a.confidence);
179
+ const best = answers[0];
180
+ clearTimeout(totalTimer);
181
+ const response = {
182
+ question,
183
+ answer: best?.answer || null,
184
+ confidence: best?.confidence || 0,
185
+ sources: answers.map((a) => ({
186
+ ...a.source,
187
+ confidence: a.confidence,
188
+ })),
189
+ method: 'bm25',
190
+ elapsed: elapsed(),
191
+ };
192
+ if (timedOut) {
193
+ response.warning = 'Partial result — 10s timeout reached';
194
+ }
195
+ // Cache successful results (only when we have an answer)
196
+ if (best?.answer && !timedOut) {
197
+ setInCache(cacheKey, response);
198
+ }
199
+ if (process.env.DEBUG)
200
+ console.debug(`[ask] done ${elapsed()}ms confidence=${best?.confidence?.toFixed(2) ?? 0}`);
201
+ res.json(response);
202
+ }
203
+ catch (err) {
204
+ clearTimeout(totalTimer);
205
+ if (process.env.DEBUG)
206
+ console.debug('[ask] error:', err);
207
+ res.json({
208
+ question,
209
+ answer: null,
210
+ confidence: 0,
211
+ sources: [],
212
+ method: 'bm25',
213
+ elapsed: elapsed(),
214
+ ...(timedOut ? { warning: 'Request timed out after 10s' } : {}),
215
+ });
216
+ }
217
+ }
218
+ router.get('/v1/ask', async (req, res) => {
219
+ const question = req.query.q || req.query.question || '';
220
+ const sources = Math.min(parseInt(req.query.sources || '3', 10) || 3, 5);
221
+ await handleAsk(question, sources, req, res);
222
+ });
223
+ router.post('/v1/ask', async (req, res) => {
224
+ const question = req.body?.question || req.body?.q || '';
225
+ const sources = Math.min(parseInt(req.body?.sources ?? 3, 10) || 3, 5);
226
+ await handleAsk(question, sources, req, res);
227
+ });
228
+ return router;
229
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Batch scrape API - process multiple URLs concurrently
3
+ */
4
+ import { Router } from 'express';
5
+ import type { IJobQueue } from '../job-queue.js';
6
+ export declare function createBatchRouter(jobQueue: IJobQueue): Router;