@@ -368,6 +368,220 @@ async function tailFile(filePath, lineCount) {
368368 }
369369}
370370
371+ const SECRET_KEYS = [ 'KEY' , 'TOKEN' , 'SECRET' , 'PASSWORD' , 'PASS' , 'CREDENTIAL' , 'API' ] ;
372+
373+ function redactValue ( value ) {
374+ if ( typeof value !== 'string' ) return value ;
375+ if ( value . length === 0 ) return value ;
376+
377+ const upper = value . toUpperCase ( ) ;
378+ const looksSecret =
379+ SECRET_KEYS . some ( key => upper . includes ( key ) ) ||
380+ / ( [ A - Z a - z 0 - 9 ] { 24 , } | s k - [ A - Z a - z 0 - 9 ] { 16 , } ) / . test ( value ) ;
381+
382+ if ( ! looksSecret ) return value ;
383+ return '***REDACTED***' ;
384+ }
385+
386+ function filterEnvironment ( env = { } , filterList , redactSecrets = true ) {
387+ const target = { } ;
388+ const entries = Object . entries ( env ) ;
389+ const shouldFilter = Array . isArray ( filterList ) && filterList . length > 0 ;
390+ for ( const [ key , value ] of entries ) {
391+ if ( shouldFilter && ! filterList . includes ( key ) ) continue ;
392+ target [ key ] = redactSecrets ? redactValue ( value ) : value ;
393+ }
394+ return target ;
395+ }
396+
397+ function sanitizeDescriptionEntry ( entry , options = { } ) {
398+ const cloned = JSON . parse ( JSON . stringify ( entry || { } ) ) ;
399+ const env = cloned . pm2_env || { } ;
400+
401+ if ( ! options . includeEnvironment ) {
402+ delete env . env ;
403+ delete env . env_pm2 ;
404+ } else {
405+ env . env = filterEnvironment ( env . env , options . environmentFilter , options . redactSecrets ) ;
406+ env . env_pm2 = filterEnvironment ( env . env_pm2 , options . environmentFilter , options . redactSecrets ) ;
407+ }
408+
409+ cloned . pm2_env = env ;
410+ return cloned ;
411+ }
412+
413+ const LOG_PATTERNS = [
414+ { id : 'copying_blob' , label : 'copying_blob' , regex : / c o p y i n g b l o b | p u l l i n g f s l a y e r | p u l l i n g | d o w n l o a d / i, semanticStatus : 'downloading' } ,
415+ { id : 'building' , label : 'building_assets' , regex : / b u i l d i n g | w e b p a c k | b u n d l i n g | c o m p i l e | t r a n s p i l / i, semanticStatus : 'processing' } ,
416+ { id : 'installing' , label : 'installing_dependencies' , regex : / i n s t a l l ( i n g ) ? p a c k a g e s | n p m i n s t a l l | y a r n i n s t a l l | p n p m i n s t a l l / i, semanticStatus : 'processing' } ,
417+ { id : 'server_listening' , label : 'server_listening' , regex : / l i s t e n i n g o n | l i s t e n i n g a t | s e r v e r ( s t a r t e d | l i s t e n i n g ) | r e a d y o n | r u n n i n g a t / i, semanticStatus : 'online' } ,
418+ { id : 'health_checks' , label : 'healthcheck' , regex : / h e a l t h c h e c k | h e a l t h c h e c k / i, semanticStatus : 'online' } ,
419+ { id : 'retrying' , label : 'retrying' , regex : / r e t r y | r e c o n n e c t | b a c k o f f / i, semanticStatus : 'degraded' } ,
420+ { id : 'error' , label : 'error' , regex : / e r r o r | e x c e p t i o n | t r a c e b a c k | f a t a l / i, semanticStatus : 'degraded' } ,
421+ { id : 'warning' , label : 'warning' , regex : / w a r n ( i n g ) ? / i, semanticStatus : 'online' }
422+ ] ;
423+
424+ function extractProgressFromLine ( line ) {
425+ const percentMatch = line . match ( / ( \d { 1 , 3 } ) \s ? % / ) ;
426+ if ( percentMatch ) {
427+ const pct = Number ( percentMatch [ 1 ] ) ;
428+ if ( ! Number . isNaN ( pct ) && pct >= 0 && pct <= 100 ) {
429+ return { metric : 'percent' , current : pct , estimated_total : 100 , trend : 'increasing' , percent : pct } ;
430+ }
431+ }
432+
433+ const fractionMatch = line . match ( / ( \d + ) \s * \/ \s * ( \d + ) (? ! \d ) / ) ;
434+ if ( fractionMatch ) {
435+ const current = Number ( fractionMatch [ 1 ] ) ;
436+ const total = Number ( fractionMatch [ 2 ] ) ;
437+ if ( ! Number . isNaN ( current ) && ! Number . isNaN ( total ) && total > 0 ) {
438+ return {
439+ metric : 'count' ,
440+ current,
441+ estimated_total : total ,
442+ trend : current >= total ? 'stable' : 'increasing' ,
443+ percent : Math . min ( 100 , Math . round ( ( current / total ) * 100 ) )
444+ } ;
445+ }
446+ }
447+ return null ;
448+ }
449+
450+ function analyzeLogPatterns ( logLines = [ ] ) {
451+ const detectedPatterns = [ ] ;
452+ const errorsFound = [ ] ;
453+ const warningsFound = [ ] ;
454+ const progressIndicators = [ ] ;
455+
456+ for ( const line of logLines ) {
457+ const lower = line . toLowerCase ( ) ;
458+
459+ const progress = extractProgressFromLine ( line ) ;
460+ if ( progress ) {
461+ progressIndicators . push ( {
462+ metric : progress . metric === 'percent' ? 'percent_complete' : 'items_processed' ,
463+ current : progress . current ,
464+ estimated_total : progress . estimated_total ,
465+ trend : progress . trend ,
466+ percent : progress . percent
467+ } ) ;
468+ }
469+
470+ for ( const pattern of LOG_PATTERNS ) {
471+ if ( ! pattern . regex . test ( lower ) ) continue ;
472+ const entry = detectedPatterns . find ( p => p . pattern === pattern . label ) ;
473+ if ( entry ) {
474+ entry . occurrences += 1 ;
475+ entry . last_seen = new Date ( ) . toISOString ( ) ;
476+ } else {
477+ detectedPatterns . push ( {
478+ pattern : pattern . label ,
479+ occurrences : 1 ,
480+ last_seen : new Date ( ) . toISOString ( ) ,
481+ sample : line ,
482+ semanticStatus : pattern . semanticStatus
483+ } ) ;
484+ }
485+ }
486+
487+ if ( / e r r o r | e x c e p t i o n | f a t a l / i. test ( line ) ) {
488+ errorsFound . push ( { line } ) ;
489+ } else if ( / w a r n ( i n g ) ? / i. test ( line ) ) {
490+ warningsFound . push ( { line } ) ;
491+ }
492+ }
493+
494+ const topPattern = detectedPatterns . length > 0 ? detectedPatterns . reduce ( ( a , b ) => ( a . occurrences >= b . occurrences ? a : b ) ) : null ;
495+
496+ return { detectedPatterns, errorsFound, warningsFound, progressIndicators, topPattern } ;
497+ }
498+
499+ async function readRecentLogLines ( env = { } , lineCount = 200 ) {
500+ const logPath = env . pm_log_path || env . pm_out_log_path || env . pm_err_log_path ;
501+ if ( ! logPath ) return { lines : [ ] , logPath : null , lastModified : null } ;
502+ try {
503+ const [ lines , stats ] = await Promise . all ( [ tailFile ( logPath , lineCount ) , fs . promises . stat ( logPath ) ] ) ;
504+ return { lines, logPath, lastModified : stats . mtimeMs } ;
505+ } catch {
506+ return { lines : [ ] , logPath, lastModified : null } ;
507+ }
508+ }
509+
510+ function buildSemanticStateFromHeuristics ( opts ) {
511+ const { env = { } , monit = { } , logAnalysis, logInfo } = opts ;
512+ const baseStatus = env . status || 'unknown' ;
513+
514+ let status = baseStatus === 'online' ? 'online' : baseStatus ;
515+ let context ;
516+ let inferredFrom = 'status' ;
517+ let confidence = 0.4 ;
518+ let progress ;
519+
520+ if ( logAnalysis ?. topPattern ) {
521+ status = logAnalysis . topPattern . semanticStatus || status ;
522+ context = logAnalysis . topPattern . sample ;
523+ inferredFrom = 'log_pattern_match' ;
524+ confidence = 0.9 ;
525+ }
526+
527+ if ( logAnalysis ?. progressIndicators ?. length ) {
528+ const latest = logAnalysis . progressIndicators [ logAnalysis . progressIndicators . length - 1 ] ;
529+ progress = latest . percent ?? latest . current ;
530+ }
531+
532+ const restartCount = env . restart_time || 0 ;
533+ if ( baseStatus === 'online' && restartCount >= 3 && confidence < 0.85 ) {
534+ status = 'degraded' ;
535+ context = `Restarted ${ restartCount } times` ;
536+ inferredFrom = 'restart_count' ;
537+ confidence = Math . max ( confidence , 0.65 ) ;
538+ }
539+
540+ const cpu = typeof monit . cpu === 'number' ? monit . cpu : null ;
541+ const now = Date . now ( ) ;
542+ const logAgeMs = logInfo ?. lastModified ? now - logInfo . lastModified : null ;
543+ const uptimeMs = env . pm_uptime || null ;
544+
545+ if (
546+ baseStatus === 'online' &&
547+ logAgeMs !== null &&
548+ uptimeMs &&
549+ uptimeMs > 2 * 60 * 1000 &&
550+ logAgeMs > 5 * 60 * 1000 &&
551+ cpu !== null &&
552+ cpu < 1
553+ ) {
554+ status = 'stuck' ;
555+ context = `No logs for ${ Math . round ( logAgeMs / 60000 ) } m, cpu ${ cpu } %` ;
556+ inferredFrom = 'log_silence' ;
557+ confidence = Math . max ( confidence , 0.7 ) ;
558+ }
559+
560+ if ( ! context ) context = `Status ${ status } ` ;
561+
562+ return {
563+ status,
564+ context,
565+ progress,
566+ confidence : Number ( confidence . toFixed ( 2 ) ) ,
567+ inferred_from : inferredFrom
568+ } ;
569+ }
570+
571+ async function buildSemanticState ( procLike ) {
572+ const env = procLike . pm2_env || { } ;
573+ const monit = procLike . monit || procLike . pm2_env ?. monit || { } ;
574+ const logInfo = await readRecentLogLines ( env , 200 ) ;
575+ const logAnalysis = analyzeLogPatterns ( logInfo . lines ) ;
576+ return buildSemanticStateFromHeuristics ( { env, monit, logAnalysis, logInfo } ) ;
577+ }
578+
579+ async function enrichProcess ( proc ) {
580+ const base = formatProcess ( proc ) ;
581+ base . semantic_state = await buildSemanticState ( proc ) ;
582+ return base ;
583+ }
584+
371585function registerTools ( ) {
372586 const startSchema = z
373587 . object ( {
@@ -419,6 +633,19 @@ function registerTools() {
419633 lines : z . number ( ) . int ( ) . positive ( ) . max ( 500 ) . default ( 60 )
420634 } ) ;
421635
636+ const analyzeLogsSchema = z . object ( {
637+ process : processTargetSchema ,
638+ timeframe_minutes : z . number ( ) . int ( ) . positive ( ) . max ( 1440 ) . default ( 5 ) ,
639+ lines : z . number ( ) . int ( ) . positive ( ) . max ( 1000 ) . default ( 400 )
640+ } ) ;
641+
642+ const describeSafeSchema = z . object ( {
643+ process : processTargetSchema ,
644+ include_environment : z . boolean ( ) . default ( false ) ,
645+ environment_filter : z . array ( z . string ( ) ) . optional ( ) ,
646+ redact_secrets : z . boolean ( ) . default ( true )
647+ } ) ;
648+
422649 server . registerTool (
423650 'pm2_list_processes' ,
424651 {
@@ -428,7 +655,7 @@ function registerTools() {
428655 wrapTool ( 'pm2_list_processes' , async ( ) => {
429656 try {
430657 await ensureConnected ( ) ;
431- const processes = ( await pm2List ( ) ) . map ( formatProcess ) ;
658+ const processes = await Promise . all ( ( await pm2List ( ) ) . map ( proc => enrichProcess ( proc ) ) ) ;
432659 return {
433660 content : textContent ( processes ) ,
434661 structuredContent : { processes }
@@ -453,9 +680,12 @@ function registerTools() {
453680 if ( ! description || description . length === 0 ) {
454681 throw new Error ( `No process found for "${ process } "` ) ;
455682 }
683+ const withState = await Promise . all (
684+ description . map ( async item => ( { ...item , semantic_state : await buildSemanticState ( item ) } ) )
685+ ) ;
456686 return {
457- content : textContent ( description ) ,
458- structuredContent : { description }
687+ content : textContent ( withState ) ,
688+ structuredContent : { description : withState }
459689 } ;
460690 } catch ( err ) {
461691 return errorResult ( err ) ;
@@ -702,6 +932,99 @@ function registerTools() {
702932 } )
703933 ) ;
704934
935+ server . registerTool (
936+ 'pm2_analyze_logs' ,
937+ {
938+ title : 'Analyze PM2 logs' ,
939+ description : 'Parse recent logs for activity, patterns, and errors.' ,
940+ inputSchema : analyzeLogsSchema
941+ } ,
942+ wrapTool ( 'pm2_analyze_logs' , async ( { process, timeframe_minutes, lines } ) => {
943+ try {
944+ await ensureConnected ( ) ;
945+ const description = await pm2Describe ( process ) ;
946+ if ( ! description || description . length === 0 ) {
947+ throw new Error ( `No process found for "${ process } "` ) ;
948+ }
949+ const env = description [ 0 ] . pm2_env || { } ;
950+ const { lines : logLines , logPath } = await readRecentLogLines ( env , lines ) ;
951+ const logAnalysis = analyzeLogPatterns ( logLines ) ;
952+ const semantic = buildSemanticStateFromHeuristics ( {
953+ env,
954+ monit : description [ 0 ] . monit ,
955+ logAnalysis,
956+ logInfo : { lastModified : null }
957+ } ) ;
958+
959+ let suggested_action = 'none' ;
960+ if ( logAnalysis . errorsFound . length > 0 ) suggested_action = 'investigate' ;
961+ else if ( logAnalysis . topPattern ?. semanticStatus === 'downloading' ) suggested_action = 'wait_for_completion' ;
962+ else if ( logAnalysis . topPattern ?. semanticStatus === 'degraded' ) suggested_action = 'investigate' ;
963+
964+ const payload = {
965+ process,
966+ timeframe_minutes,
967+ analysis : {
968+ current_activity : logAnalysis . topPattern ?. pattern || semantic . status ,
969+ detected_patterns : logAnalysis . detectedPatterns ,
970+ errors_found : logAnalysis . errorsFound ,
971+ warnings_found : logAnalysis . warningsFound ,
972+ progress_indicators : logAnalysis . progressIndicators ,
973+ anomalies : [ ] ,
974+ suggested_action
975+ } ,
976+ meta : {
977+ log_path : logPath ,
978+ semantic_state : semantic
979+ }
980+ } ;
981+
982+ return {
983+ content : textContent ( payload ) ,
984+ structuredContent : payload
985+ } ;
986+ } catch ( err ) {
987+ return errorResult ( err ) ;
988+ }
989+ } )
990+ ) ;
991+
992+ server . registerTool (
993+ 'pm2_describe_process_safe' ,
994+ {
995+ title : 'Describe a PM2 process (privacy-safe)' ,
996+ description : 'Describe a process with optional environment filtering and secret redaction.' ,
997+ inputSchema : describeSafeSchema
998+ } ,
999+ wrapTool ( 'pm2_describe_process_safe' , async ( { process, include_environment, environment_filter, redact_secrets } ) => {
1000+ try {
1001+ await ensureConnected ( ) ;
1002+ const description = await pm2Describe ( process ) ;
1003+ if ( ! description || description . length === 0 ) {
1004+ throw new Error ( `No process found for "${ process } "` ) ;
1005+ }
1006+
1007+ const sanitized = await Promise . all (
1008+ description . map ( async item => ( {
1009+ ...sanitizeDescriptionEntry ( item , {
1010+ includeEnvironment : include_environment ,
1011+ environmentFilter : environment_filter ,
1012+ redactSecrets : redact_secrets
1013+ } ) ,
1014+ semantic_state : await buildSemanticState ( item )
1015+ } ) )
1016+ ) ;
1017+
1018+ return {
1019+ content : textContent ( sanitized ) ,
1020+ structuredContent : { description : sanitized }
1021+ } ;
1022+ } catch ( err ) {
1023+ return errorResult ( err ) ;
1024+ }
1025+ } )
1026+ ) ;
1027+
7051028 server . registerTool (
7061029 'pm2_kill_daemon' ,
7071030 {
@@ -735,7 +1058,7 @@ function registerResources() {
7351058 } ,
7361059 async ( ) => {
7371060 await ensureConnected ( ) ;
738- const processes = ( await pm2List ( ) ) . map ( formatProcess ) ;
1061+ const processes = await Promise . all ( ( await pm2List ( ) ) . map ( proc => enrichProcess ( proc ) ) ) ;
7391062 return {
7401063 contents : [
7411064 {
@@ -794,7 +1117,7 @@ function registerResources() {
7941117 {
7951118 uri : uri . href ,
7961119 mimeType : 'application/json' ,
797- text : renderJson ( description [ 0 ] )
1120+ text : renderJson ( { ... description [ 0 ] , semantic_state : await buildSemanticState ( description [ 0 ] ) } )
7981121 }
7991122 ]
8001123 } ;
0 commit comments