crawlforge-mcp-server 6.21.1 → 6.22.1
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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +228 -21
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +76 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +22 -14
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
package/src/core/AuthManager.js
CHANGED
|
@@ -758,7 +758,10 @@ class AuthManager {
|
|
|
758
758
|
break;
|
|
759
759
|
}
|
|
760
760
|
case 'extract_with_llm':
|
|
761
|
-
|
|
761
|
+
// Only the cloud providers bill you; the default runs on local Ollama.
|
|
762
|
+
if (params?.provider === 'openai' || params?.provider === 'anthropic') {
|
|
763
|
+
note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
|
|
764
|
+
}
|
|
762
765
|
break;
|
|
763
766
|
case 'stealth_mode':
|
|
764
767
|
note = projected === 1
|
|
@@ -236,7 +236,9 @@ export class LLMsTxtAnalyzer {
|
|
|
236
236
|
const batch = commonPaths.slice(i, i + PROBE_CONCURRENCY);
|
|
237
237
|
const results = await Promise.allSettled(
|
|
238
238
|
batch.map(async (path) => {
|
|
239
|
-
|
|
239
|
+
// Resolved, not concatenated: the URL arrives as typed, and
|
|
240
|
+
// "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
|
|
241
|
+
const apiUrl = new URL(path, baseUrl).toString();
|
|
240
242
|
const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
|
|
241
243
|
if (response.ok) {
|
|
242
244
|
const contentType = response.headers.get('content-type') || '';
|
|
@@ -367,7 +369,7 @@ export class LLMsTxtAnalyzer {
|
|
|
367
369
|
const batch = sensitivePaths.slice(i, i + PROBE_CONCURRENCY);
|
|
368
370
|
const results = await Promise.allSettled(
|
|
369
371
|
batch.map(async (path) => {
|
|
370
|
-
const testUrl =
|
|
372
|
+
const testUrl = new URL(path, baseUrl).toString();
|
|
371
373
|
const response = await this.fetchWithTimeout(testUrl, { timeout: 3000 });
|
|
372
374
|
if (response.status === 200 || response.status === 302 || response.status === 401) {
|
|
373
375
|
return {
|
|
@@ -571,7 +573,6 @@ export class LLMsTxtAnalyzer {
|
|
|
571
573
|
*/
|
|
572
574
|
prioritizeUrls(urls, baseUrl, limit) {
|
|
573
575
|
const sections = new Map();
|
|
574
|
-
let sawAny = false;
|
|
575
576
|
|
|
576
577
|
for (const url of urls) {
|
|
577
578
|
let segments;
|
|
@@ -580,7 +581,6 @@ export class LLMsTxtAnalyzer {
|
|
|
580
581
|
} catch {
|
|
581
582
|
continue;
|
|
582
583
|
}
|
|
583
|
-
sawAny = true;
|
|
584
584
|
// The root arrives as either "https://site.com" or "https://site.com/"
|
|
585
585
|
// (normalizeUrl strips a trailing slash from every path but "/"), so it
|
|
586
586
|
// is recognised by its path and re-added below in one canonical form.
|
|
@@ -590,10 +590,9 @@ export class LLMsTxtAnalyzer {
|
|
|
590
590
|
sections.get(section).push({ url, depth: segments.length });
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
-
if (!sawAny) return [];
|
|
594
|
-
|
|
595
593
|
// The site root leads: it is always fetched, and a site guide that omits
|
|
596
|
-
// the homepage is a misleading one.
|
|
594
|
+
// the homepage is a misleading one. That holds with no other candidate
|
|
595
|
+
// too: a one-page site was analysed as 0 pages (R25 1.2).
|
|
597
596
|
const ordered = [`${baseUrl}/`];
|
|
598
597
|
const queues = [...sections.keys()].sort().map((name) =>
|
|
599
598
|
sections.get(name).sort((a, b) => a.depth - b.depth || a.url.localeCompare(b.url)));
|
|
@@ -778,15 +778,40 @@ export class LocalizationManager extends EventEmitter {
|
|
|
778
778
|
}
|
|
779
779
|
|
|
780
780
|
getFirstDayOfWeek(countryCode) {
|
|
781
|
-
// 0 = Sunday, 1 = Monday
|
|
781
|
+
// 0 = Sunday, 1 = Monday. From CLDR 48 supplemental weekData "firstDay";
|
|
782
|
+
// covers every SUPPORTED_COUNTRIES entry. The fallback is CLDR's world
|
|
783
|
+
// default (Monday). `??`, not `||`: Sunday is 0 (R25).
|
|
782
784
|
const firstDays = {
|
|
783
785
|
'US': 0,
|
|
784
786
|
'GB': 1,
|
|
785
787
|
'DE': 1,
|
|
786
|
-
'FR': 1
|
|
788
|
+
'FR': 1,
|
|
789
|
+
'JP': 0,
|
|
790
|
+
'CN': 1,
|
|
791
|
+
'AU': 1,
|
|
792
|
+
'CA': 0,
|
|
793
|
+
'IT': 1,
|
|
794
|
+
'ES': 1,
|
|
795
|
+
'RU': 1,
|
|
796
|
+
'BR': 0,
|
|
797
|
+
'IN': 0,
|
|
798
|
+
'KR': 0,
|
|
799
|
+
'MX': 0,
|
|
800
|
+
'NL': 1,
|
|
801
|
+
'SE': 1,
|
|
802
|
+
'NO': 1,
|
|
803
|
+
'SA': 0,
|
|
804
|
+
'AE': 1,
|
|
805
|
+
'TR': 1,
|
|
806
|
+
'IL': 0,
|
|
807
|
+
'TH': 0,
|
|
808
|
+
'SG': 0,
|
|
809
|
+
'PL': 1,
|
|
810
|
+
'ZA': 0,
|
|
811
|
+
'CH': 1
|
|
787
812
|
};
|
|
788
|
-
|
|
789
|
-
return firstDays[countryCode]
|
|
813
|
+
|
|
814
|
+
return firstDays[countryCode] ?? 1;
|
|
790
815
|
}
|
|
791
816
|
|
|
792
817
|
generateScreenProperties(countryCode) {
|
|
@@ -63,6 +63,63 @@ export function reportedFindings(keyFindings) {
|
|
|
63
63
|
return material.length > 0 ? material : keyFindings;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Citation markers left in extracted text: "(3)", "[12]", "[3, 4]", "[2-5]".
|
|
67
|
+
// At most three digits, so a year such as "(2024)" stays.
|
|
68
|
+
const CITATION_MARKER = /\s*(?:\(\d{1,3}(?:\s*[,\u2013-]\s*\d{1,3})*\)|\[\d{1,3}(?:\s*[,\u2013-]\s*\d{1,3})*\])/g;
|
|
69
|
+
const LIST_MARKER = /^(?:[-*\u2022\u25AA\u2013]|\d{1,2}[.)])\s+/;
|
|
70
|
+
// A lowercase word run straight into a capitalized one: "glanceWhat". A
|
|
71
|
+
// camelCase name starts with a capital ("JavaScript", "DataDome") and never matches.
|
|
72
|
+
const WELDED_WORDS = /\b[a-z]{2,}[A-Z][a-z]/g;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A line that is a table row or headings welded together, not a statement.
|
|
76
|
+
* extract_content reads a table one row per line, cells joined by " | " (a
|
|
77
|
+
* two-cell row has just one), and crawlforge-extractors' flattenText joins
|
|
78
|
+
* inline elements with no separator, so a table of contents of `<a class="block">` links came back as "The six
|
|
79
|
+
* vendors at a glanceWhat the independent benchmarks showWhy your approach…"
|
|
80
|
+
* (R25 re-check, 2026-10-19). A sentence ends in punctuation; welded headings
|
|
81
|
+
* have none.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} line
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isPageFragment(line) {
|
|
87
|
+
if (/\s\|\s/.test(line) || (line.match(/\|/g) || []).length >= 2) return true;
|
|
88
|
+
return line.length <= 300 && !/[.!?](?:\s|$)/.test(line) &&
|
|
89
|
+
(line.match(WELDED_WORDS) || []).length >= 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The statements in one extracted key point. A key point is a sentence as the
|
|
94
|
+
* splitter found it, so a heading or list item above it arrives in the same
|
|
95
|
+
* string ("Built-in encryption\nQUIC was built with security in mind.").
|
|
96
|
+
* Each line becomes its own statement, except a line starting in lowercase,
|
|
97
|
+
* which continues the one before it (wrapped text). List and citation markers
|
|
98
|
+
* are removed. Table rows and welded headings are dropped; heading-only lines
|
|
99
|
+
* are left for isAdmissibleClaim to reject.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} point
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
export function claimStatements(point) {
|
|
105
|
+
const blocks = [];
|
|
106
|
+
let afterFragment = false;
|
|
107
|
+
for (const raw of String(point).split('\n')) {
|
|
108
|
+
const line = raw.trim();
|
|
109
|
+
if (!line) continue;
|
|
110
|
+
if (isPageFragment(line)) {
|
|
111
|
+
afterFragment = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (blocks.length > 0 && !afterFragment && /^[a-z]/.test(line)) blocks[blocks.length - 1] += ` ${line}`;
|
|
115
|
+
else blocks.push(line);
|
|
116
|
+
afterFragment = false;
|
|
117
|
+
}
|
|
118
|
+
return blocks
|
|
119
|
+
.map(block => block.replace(LIST_MARKER, '').replace(CITATION_MARKER, '').replace(/\s+/g, ' ').trim())
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
}
|
|
122
|
+
|
|
66
123
|
/**
|
|
67
124
|
* Page text for the plain-fetch fallback, one line per block element. The
|
|
68
125
|
* regex tag-strip it replaces ran every block together on one line.
|
|
@@ -1384,7 +1441,8 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1384
1441
|
const keyPoints = summary.keypoints || summary.keyPoints || [];
|
|
1385
1442
|
if (keyPoints.length > 0) {
|
|
1386
1443
|
const relevance = this.sourceRelevance(source);
|
|
1387
|
-
|
|
1444
|
+
let claimIndex = 0;
|
|
1445
|
+
keyPoints.forEach((keyPoint, index) => claimStatements(keyPoint).forEach(point => {
|
|
1388
1446
|
const credibility = source.overallCredibility || 0.65;
|
|
1389
1447
|
// A recommendation is not evidence. Two routes to the same flag: a
|
|
1390
1448
|
// page promoting itself, and — whoever published it — a claim whose
|
|
@@ -1392,7 +1450,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1392
1450
|
const promotional = isVendorSelfPromotion(point, source.link) ||
|
|
1393
1451
|
isProductRecommendation(point);
|
|
1394
1452
|
claims.push({
|
|
1395
|
-
id: `${source.link}_claim_${
|
|
1453
|
+
id: `${source.link}_claim_${claimIndex++}`,
|
|
1396
1454
|
claim: point,
|
|
1397
1455
|
source: source.link,
|
|
1398
1456
|
sourceTitle: source.title,
|
|
@@ -1402,7 +1460,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1402
1460
|
context: summary.supporting?.[index] || '',
|
|
1403
1461
|
extractedAt: new Date().toISOString()
|
|
1404
1462
|
});
|
|
1405
|
-
});
|
|
1463
|
+
}));
|
|
1406
1464
|
}
|
|
1407
1465
|
} catch (error) {
|
|
1408
1466
|
this.logger.warn('Claim extraction failed', {
|
|
@@ -1464,31 +1522,33 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1464
1522
|
/**
|
|
1465
1523
|
* Keep only claims that can be research findings: prose about the topic, not
|
|
1466
1524
|
* document front matter (author/affiliation blocks, DOI stubs, "Retrieved
|
|
1467
|
-
* from" lines), not
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1525
|
+
* from" lines), and not a sentence the LLM scored as barely about the topic.
|
|
1526
|
+
* Those two gates fall back to the previous claim set rather than returning
|
|
1527
|
+
* nothing. A claim from an off-topic source — one the relevance analysis
|
|
1528
|
+
* scored below MIN_CLAIM_RELEVANCE — is always dropped: when every source is
|
|
1529
|
+
* off-topic, no findings is the honest result (R25 reported such claims as
|
|
1530
|
+
* findings through the fallback). A source with no score is kept.
|
|
1471
1531
|
*/
|
|
1472
1532
|
admitClaims(claims) {
|
|
1473
1533
|
if (claims.length === 0) return claims;
|
|
1474
1534
|
|
|
1475
|
-
const
|
|
1476
|
-
const relevant = substantive.filter(
|
|
1535
|
+
const relevant = claims.filter(
|
|
1477
1536
|
claim => typeof claim.relevance !== 'number' || claim.relevance >= MIN_CLAIM_RELEVANCE
|
|
1478
1537
|
);
|
|
1479
|
-
const
|
|
1538
|
+
const substantive = relevant.filter(claim => isAdmissibleClaim(claim.claim));
|
|
1539
|
+
const onTopic = substantive.filter(
|
|
1480
1540
|
claim => typeof claim.topicRelevance !== 'number' ||
|
|
1481
1541
|
claim.topicRelevance >= MIN_CLAIM_TOPIC_RELEVANCE
|
|
1482
1542
|
);
|
|
1483
1543
|
|
|
1484
1544
|
const admitted = onTopic.length > 0
|
|
1485
1545
|
? onTopic
|
|
1486
|
-
: (
|
|
1546
|
+
: (substantive.length > 0 ? substantive : relevant);
|
|
1487
1547
|
|
|
1488
1548
|
this.logger.debug('Claim admission', {
|
|
1489
1549
|
candidates: claims.length,
|
|
1490
|
-
substantive: substantive.length,
|
|
1491
1550
|
relevant: relevant.length,
|
|
1551
|
+
substantive: substantive.length,
|
|
1492
1552
|
onTopic: onTopic.length,
|
|
1493
1553
|
admitted: admitted.length
|
|
1494
1554
|
});
|
|
@@ -2007,8 +2067,10 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
2007
2067
|
findings.push({
|
|
2008
2068
|
finding: claim.claim,
|
|
2009
2069
|
supportingClaims: group.claims.length,
|
|
2070
|
+
supportingSources: group.sourceCount,
|
|
2010
2071
|
credibility: group.avgCredibility,
|
|
2011
|
-
|
|
2072
|
+
// One entry per source: a group holds several claims from one page.
|
|
2073
|
+
sources: [...new Set(group.claims.map(c => c.source))],
|
|
2012
2074
|
...(claim.promotional ? { promotional: true } : {}),
|
|
2013
2075
|
...(typeof claim.topicRelevance === 'number'
|
|
2014
2076
|
? { topicRelevance: claim.topicRelevance }
|
|
@@ -2032,6 +2094,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
2032
2094
|
title: source.title,
|
|
2033
2095
|
url: source.link,
|
|
2034
2096
|
credibility: source.overallCredibility,
|
|
2097
|
+
relevance: this.sourceRelevance(source),
|
|
2035
2098
|
evidence: source.extractedContent?.substring(0, 300) + '...'
|
|
2036
2099
|
}))
|
|
2037
2100
|
.slice(0, 15);
|
|
@@ -15,13 +15,24 @@
|
|
|
15
15
|
|
|
16
16
|
import { ollamaBaseUrl as OLLAMA_BASE_URL, ollamaHeaders, selectOllamaModel } from '../utils/ollamaConfig.js';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A provider call's own 30 s bound, cut short by the caller's time budget
|
|
20
|
+
* when there is one.
|
|
21
|
+
* @param {AbortSignal|null} [budget]
|
|
22
|
+
* @returns {AbortSignal}
|
|
23
|
+
*/
|
|
24
|
+
function callSignal(budget) {
|
|
25
|
+
const own = AbortSignal.timeout(30_000);
|
|
26
|
+
return budget ? AbortSignal.any([own, budget]) : own;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Attempt an Ollama completion.
|
|
20
31
|
* @param {string} prompt
|
|
21
32
|
* @param {object} options
|
|
22
33
|
* @returns {Promise<string>}
|
|
23
34
|
*/
|
|
24
|
-
async function tryOllama(prompt, { model, maxTokens } = {}) {
|
|
35
|
+
async function tryOllama(prompt, { model, maxTokens, budget } = {}) {
|
|
25
36
|
const ollamaModel = model || await selectOllamaModel();
|
|
26
37
|
const url = `${OLLAMA_BASE_URL()}/api/generate`;
|
|
27
38
|
const res = await fetch(url, {
|
|
@@ -33,7 +44,7 @@ async function tryOllama(prompt, { model, maxTokens } = {}) {
|
|
|
33
44
|
stream: false,
|
|
34
45
|
...(maxTokens ? { options: { num_predict: maxTokens } } : {}),
|
|
35
46
|
}),
|
|
36
|
-
signal:
|
|
47
|
+
signal: callSignal(budget),
|
|
37
48
|
});
|
|
38
49
|
if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
|
|
39
50
|
const data = await res.json();
|
|
@@ -47,7 +58,7 @@ async function tryOllama(prompt, { model, maxTokens } = {}) {
|
|
|
47
58
|
* @param {object} options
|
|
48
59
|
* @returns {Promise<string>}
|
|
49
60
|
*/
|
|
50
|
-
async function tryOpenAI(prompt, { model, maxTokens } = {}) {
|
|
61
|
+
async function tryOpenAI(prompt, { model, maxTokens, budget } = {}) {
|
|
51
62
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
52
63
|
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
|
53
64
|
const base = (process.env.OPENAI_BASE_URL || 'https://api.openai.com').replace(/\/$/, '');
|
|
@@ -62,7 +73,7 @@ async function tryOpenAI(prompt, { model, maxTokens } = {}) {
|
|
|
62
73
|
messages: [{ role: 'user', content: prompt }],
|
|
63
74
|
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
|
64
75
|
}),
|
|
65
|
-
signal:
|
|
76
|
+
signal: callSignal(budget),
|
|
66
77
|
});
|
|
67
78
|
if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`);
|
|
68
79
|
const data = await res.json();
|
|
@@ -75,7 +86,7 @@ async function tryOpenAI(prompt, { model, maxTokens } = {}) {
|
|
|
75
86
|
* @param {object} options
|
|
76
87
|
* @returns {Promise<string>}
|
|
77
88
|
*/
|
|
78
|
-
async function tryAnthropic(prompt, { model, maxTokens } = {}) {
|
|
89
|
+
async function tryAnthropic(prompt, { model, maxTokens, budget } = {}) {
|
|
79
90
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
80
91
|
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
81
92
|
const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
|
|
@@ -91,7 +102,7 @@ async function tryAnthropic(prompt, { model, maxTokens } = {}) {
|
|
|
91
102
|
max_tokens: maxTokens || 1024,
|
|
92
103
|
messages: [{ role: 'user', content: prompt }],
|
|
93
104
|
}),
|
|
94
|
-
signal:
|
|
105
|
+
signal: callSignal(budget),
|
|
95
106
|
});
|
|
96
107
|
if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}`);
|
|
97
108
|
const data = await res.json();
|
|
@@ -116,12 +127,18 @@ export class SamplingClient {
|
|
|
116
127
|
* @param {string} [options.model] - Override model name
|
|
117
128
|
* @param {number} [options.maxTokens] - Max tokens for response
|
|
118
129
|
* @param {string} [options.systemPrompt] - Optional system-level instruction
|
|
130
|
+
* @param {number} [options.timeoutMs] - Time budget for the whole chain. Each
|
|
131
|
+
* provider call stops at its own 30 s bound or when the budget runs out,
|
|
132
|
+
* whichever comes first, and the sampling fallback is raced against it.
|
|
133
|
+
* An exhausted budget throws an error with code 'LLM_TIMEOUT'.
|
|
119
134
|
* @returns {Promise<{ text: string, provider: string }>}
|
|
120
135
|
*/
|
|
121
136
|
async complete(prompt, options = {}) {
|
|
122
137
|
const fullPrompt = options.systemPrompt
|
|
123
138
|
? `${options.systemPrompt}\n\n${prompt}`
|
|
124
139
|
: prompt;
|
|
140
|
+
const budget = options.timeoutMs != null ? AbortSignal.timeout(Math.max(0, options.timeoutMs)) : null;
|
|
141
|
+
options = { ...options, budget };
|
|
125
142
|
|
|
126
143
|
// 1. Try Ollama (local, no API key)
|
|
127
144
|
try {
|
|
@@ -153,11 +170,19 @@ export class SamplingClient {
|
|
|
153
170
|
// 3. Try MCP sampling (client-side LLM)
|
|
154
171
|
if (this._mcpServer?.server?.createMessage) {
|
|
155
172
|
try {
|
|
156
|
-
const
|
|
173
|
+
const request = this._mcpServer.server.createMessage({
|
|
157
174
|
messages: [{ role: 'user', content: { type: 'text', text: fullPrompt } }],
|
|
158
175
|
maxTokens: options.maxTokens || 1024,
|
|
159
176
|
includeContext: 'none',
|
|
160
|
-
});
|
|
177
|
+
}, budget ? { signal: budget } : undefined);
|
|
178
|
+
// Raced as well: a client that ignores the cancellation would
|
|
179
|
+
// otherwise hold the caller past its budget.
|
|
180
|
+
const samplingResult = await (budget
|
|
181
|
+
? Promise.race([request, new Promise((_, reject) => {
|
|
182
|
+
if (budget.aborted) reject(budget.reason);
|
|
183
|
+
else budget.addEventListener('abort', () => reject(budget.reason), { once: true });
|
|
184
|
+
})])
|
|
185
|
+
: request);
|
|
161
186
|
const text = samplingResult?.content?.text || '';
|
|
162
187
|
if (text) {
|
|
163
188
|
// stderr, never stdout — stdout is the JSON-RPC stream on stdio.
|
|
@@ -170,6 +195,11 @@ export class SamplingClient {
|
|
|
170
195
|
}
|
|
171
196
|
|
|
172
197
|
// 4. All fallbacks exhausted
|
|
198
|
+
if (budget?.aborted) {
|
|
199
|
+
const err = new Error(`LLM time budget of ${options.timeoutMs} ms ran out before a provider answered.`);
|
|
200
|
+
err.code = 'LLM_TIMEOUT';
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
173
203
|
throw new Error(
|
|
174
204
|
'No LLM available: Ollama is not running, no API keys set (OPENAI_API_KEY / ANTHROPIC_API_KEY), and the MCP client does not support sampling.'
|
|
175
205
|
);
|
|
@@ -12,6 +12,7 @@ import { gzip, gunzip } from 'zlib';
|
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
|
+
import { calculateSimilarity } from '../tools/tracking/trackChanges/differ.js';
|
|
15
16
|
|
|
16
17
|
const gzipAsync = promisify(gzip);
|
|
17
18
|
const gunzipAsync = promisify(gunzip);
|
|
@@ -362,9 +363,11 @@ export class SnapshotManager extends EventEmitter {
|
|
|
362
363
|
if (metadata.compression && metadata.compression.enabled) {
|
|
363
364
|
if (metadata.compression.algorithm === 'gzip') {
|
|
364
365
|
content = await gunzipAsync(content);
|
|
365
|
-
content = content.toString();
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
|
+
// readSnapshotFile returns a Buffer; an uncompressed snapshot was
|
|
369
|
+
// returned as one, not as its text.
|
|
370
|
+
content = content.toString();
|
|
368
371
|
|
|
369
372
|
// Reconstruct from delta if needed
|
|
370
373
|
if (metadata.delta && metadata.delta.enabled) {
|
|
@@ -502,14 +505,21 @@ export class SnapshotManager extends EventEmitter {
|
|
|
502
505
|
|
|
503
506
|
const history = [];
|
|
504
507
|
const snapshotList = snapshots.snapshots;
|
|
505
|
-
|
|
508
|
+
|
|
509
|
+
// Each snapshot's content is read once and carried to the next pair.
|
|
510
|
+
let currentContent = snapshotList.length > 1
|
|
511
|
+
? await this.loadSnapshotContent(snapshotList[0].id)
|
|
512
|
+
: null;
|
|
513
|
+
|
|
506
514
|
for (let i = 0; i < snapshotList.length - 1; i++) {
|
|
507
515
|
const current = snapshotList[i];
|
|
508
516
|
const previous = snapshotList[i + 1];
|
|
509
|
-
|
|
517
|
+
const previousContent = await this.loadSnapshotContent(previous.id);
|
|
518
|
+
|
|
510
519
|
// Calculate change metrics
|
|
511
|
-
const changeMetrics = await this.calculateChangeMetrics(previous, current);
|
|
512
|
-
|
|
520
|
+
const changeMetrics = await this.calculateChangeMetrics(previous, current, previousContent, currentContent);
|
|
521
|
+
currentContent = previousContent;
|
|
522
|
+
|
|
513
523
|
history.push({
|
|
514
524
|
timestamp: current.metadata.timestamp,
|
|
515
525
|
snapshotId: current.id,
|
|
@@ -842,7 +852,17 @@ export class SnapshotManager extends EventEmitter {
|
|
|
842
852
|
throw new Error('Cannot reconstruct legacy delta snapshot: no diff data was stored for it. Re-fetch or re-create this snapshot from the source.');
|
|
843
853
|
}
|
|
844
854
|
|
|
845
|
-
|
|
855
|
+
/** A stored snapshot's content, or null when it cannot be read back. */
|
|
856
|
+
async loadSnapshotContent(snapshotId) {
|
|
857
|
+
try {
|
|
858
|
+
const snapshot = await this.retrieveSnapshot(snapshotId);
|
|
859
|
+
return typeof snapshot.content === 'string' ? snapshot.content : null;
|
|
860
|
+
} catch {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async calculateChangeMetrics(previousSnapshot, currentSnapshot, previousContent = null, currentContent = null) {
|
|
846
866
|
// Calculate various change metrics between snapshots
|
|
847
867
|
const metrics = {
|
|
848
868
|
sizeDelta: currentSnapshot.metadata.contentLength - previousSnapshot.metadata.contentLength,
|
|
@@ -852,14 +872,13 @@ export class SnapshotManager extends EventEmitter {
|
|
|
852
872
|
similarity: 0
|
|
853
873
|
};
|
|
854
874
|
|
|
855
|
-
//
|
|
875
|
+
// Content similarity, computed from the stored content. A size-based
|
|
876
|
+
// estimate scored two different pages of the same length 1.0. null when
|
|
877
|
+
// either snapshot's content could not be read back.
|
|
856
878
|
if (metrics.hashChanged) {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
currentSnapshot.metadata.contentLength,
|
|
861
|
-
previousSnapshot.metadata.contentLength
|
|
862
|
-
);
|
|
879
|
+
metrics.similarity = typeof previousContent === 'string' && typeof currentContent === 'string'
|
|
880
|
+
? calculateSimilarity(previousContent, currentContent)
|
|
881
|
+
: null;
|
|
863
882
|
} else {
|
|
864
883
|
metrics.similarity = 1.0;
|
|
865
884
|
}
|
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
needsWordSegmentation
|
|
11
11
|
} from '../../utils/languageDetection.js';
|
|
12
12
|
import nlp from 'compromise';
|
|
13
|
+
import Sentiment from 'sentiment';
|
|
13
14
|
import { z } from 'zod';
|
|
14
15
|
import { splitSentences } from './sentenceUtils.js';
|
|
15
16
|
import { LANGUAGE_STOP_WORDS } from './languageStopWords.js';
|
|
16
17
|
|
|
18
|
+
const sentimentAnalyzer = new Sentiment();
|
|
19
|
+
|
|
17
20
|
const ContentAnalyzerSchema = z.object({
|
|
18
21
|
text: z.string().min(1),
|
|
19
22
|
options: z.object({
|
|
@@ -845,25 +848,20 @@ export class ContentAnalyzer {
|
|
|
845
848
|
*/
|
|
846
849
|
async analyzeSentiment(text, options = {}) {
|
|
847
850
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (positiveWords.includes(word)) positiveCount++;
|
|
862
|
-
if (negativeWords.includes(word)) negativeCount++;
|
|
863
|
-
});
|
|
851
|
+
// AFINN-165 lexicon (about 3,400 English words scored -5..+5) with
|
|
852
|
+
// negation ("not happy" scores negative). A 25-word list used to leave
|
|
853
|
+
// clearly positive text "neutral" (R25).
|
|
854
|
+
const { calculation, tokens } = sentimentAnalyzer.analyze(text);
|
|
855
|
+
// Drop hits that are function words of the detected language: German
|
|
856
|
+
// "die" is -3 in the English lexicon.
|
|
857
|
+
const foreignStopWords = LANGUAGE_STOP_WORDS[options.detectedLanguage];
|
|
858
|
+
const scores = calculation
|
|
859
|
+
.map(entry => Object.entries(entry)[0])
|
|
860
|
+
.filter(([word]) => !foreignStopWords?.has(word))
|
|
861
|
+
.map(([, score]) => score);
|
|
862
|
+
|
|
863
|
+
const totalSentimentWords = scores.length;
|
|
864
864
|
|
|
865
|
-
const totalSentimentWords = positiveCount + negativeCount;
|
|
866
|
-
|
|
867
865
|
if (totalSentimentWords === 0) {
|
|
868
866
|
// The lexicon is English. Finding none of its words in German or
|
|
869
867
|
// Japanese text is not evidence of neutrality, so say the measure does
|
|
@@ -879,22 +877,25 @@ export class ContentAnalyzer {
|
|
|
879
877
|
notApplicable: 'sentiment-lexicon-is-english-only'
|
|
880
878
|
};
|
|
881
879
|
}
|
|
880
|
+
// No opinion words at all: neutral, but with no evidence behind it.
|
|
882
881
|
return {
|
|
883
882
|
polarity: 0,
|
|
884
883
|
subjectivity: 0,
|
|
885
884
|
label: 'neutral',
|
|
886
|
-
confidence: 0
|
|
885
|
+
confidence: 0
|
|
887
886
|
};
|
|
888
887
|
}
|
|
889
888
|
|
|
890
|
-
|
|
891
|
-
const
|
|
892
|
-
|
|
889
|
+
// Net lexicon score per word, clamped to -1..1.
|
|
890
|
+
const net = scores.reduce((sum, score) => sum + score, 0);
|
|
891
|
+
const polarity = Math.max(-1, Math.min(1, net / Math.max(tokens.length, 1)));
|
|
892
|
+
const subjectivity = totalSentimentWords / Math.max(tokens.length, 1);
|
|
893
|
+
|
|
893
894
|
let label = 'neutral';
|
|
894
|
-
if (polarity > 0.
|
|
895
|
-
else if (polarity < -0.
|
|
895
|
+
if (polarity > 0.05) label = 'positive';
|
|
896
|
+
else if (polarity < -0.05) label = 'negative';
|
|
896
897
|
|
|
897
|
-
const confidence = Math.min(1, totalSentimentWords /
|
|
898
|
+
const confidence = Math.min(1, totalSentimentWords / 5);
|
|
898
899
|
|
|
899
900
|
return {
|
|
900
901
|
polarity: Math.round(polarity * 100) / 100,
|