Code

/**
 * mcp/skills/getTopConsumers.ts — get_top_consumers skill
 *
 * Returns processes ranked by combined CPU and memory consumption. Provides a
 * quick snapshot of what is most impacting system performance.
 *
 * Platform strategy
 * -----------------
 * darwin  `ps -eo pid,pcpu,rss,comm` — parse and rank by combined score
 * win32   PowerShell Get-Process | Sort-Object CPU -Descending
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/getTopConsumers.ts
 */
 
import * as os                from "os";
import { exec }               from "child_process";
import { promisify }          from "util";
import { z }                  from "zod";
import { formatBytesBinary }  from "./_shared/formatBytes";
import { isAgentSelf, isSystemProcess } from "./_shared/processIdentity";
 
const execAsync = promisify(exec);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_top_consumers",
  description:
    "Returns processes ranked by combined CPU and memory consumption. Provides " +
    "a quick snapshot of what is most impacting system performance. Use when " +
    "diagnosing slowness without a specific process in mind.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    limit: z
      .number()
      .optional()
      .describe("Number of top processes to return. Default: 10"),
    metric: z
      .enum(["cpu", "memory", "combined"])
      .optional()
      .describe("Ranking metric. Default: combined"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface ConsumerEntry {
  pid:           number;
  name:          string;
  cpuPercent:    number;
  memoryMb:      number;
  /** Pre-formatted memory string (binary units — matches Activity Monitor / Task Manager). */
  memoryHuman:   string;
  combinedScore: number;
  /** True for critical OS processes that must NOT be killed (surface as a note, never an action). */
  isSystem:      boolean;
}
 
// -- PowerShell helper --------------------------------------------------------
 
async function runPS(script: string): Promise<string> {
  const encoded = Buffer.from(script, "utf16le").toString("base64");
  const { stdout } = await execAsync(
    `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
    { maxBuffer: 20 * 1024 * 1024 },
  );
  return stdout.trim();
}
 
// -- darwin implementation ----------------------------------------------------
 
async function getTopConsumersDarwin(
  limit:  number,
  metric: "cpu" | "memory" | "combined",
): Promise<ConsumerEntry[]> {
  const { stdout } = await execAsync(
    "ps -eo pid,pcpu,rss,comm 2>/dev/null",
    { maxBuffer: 10 * 1024 * 1024 },
  );
 
  const rows = stdout
    .trim()
    .split("\n")
    .slice(1)
    .flatMap((line) => {
      const parts = line.trim().split(/\s+/);
      if (parts.length < 4) return [];
      const pid      = parseInt(parts[0], 10);
      const cpu      = parseFloat(parts[1]);
      const rssKb    = parseInt(parts[2], 10);
      const fullComm = parts.slice(3).join(" ");
      const name     = fullComm.split("/").at(-1) ?? fullComm;
      if (isNaN(pid)) return [];
      // Never surface the agent's own process(es) — it must not be offered for
      // kill/restart (would terminate the agent mid-run).
      if (isAgentSelf(name, pid, fullComm)) return [];
      const memoryMb    = Math.round((rssKb / 1024) * 10) / 10;
      const memoryHuman = formatBytesBinary(rssKb * 1024);
      return [{ pid, name, cpuPercent: cpu, memoryMb, memoryHuman, combinedScore: 0,
                isSystem: isSystemProcess(name, fullComm) }];
    });
 
  // Normalise and compute combined score
  const maxCpu = Math.max(...rows.map(r => r.cpuPercent), 1);
  const maxMem = Math.max(...rows.map(r => r.memoryMb), 1);
 
  for (const r of rows) {
    r.combinedScore = Math.round(
      ((r.cpuPercent / maxCpu) * 50 + (r.memoryMb / maxMem) * 50) * 100,
    ) / 100;
  }
 
  const sortKey: keyof ConsumerEntry =
    metric === "cpu"    ? "cpuPercent" :
    metric === "memory" ? "memoryMb"   : "combinedScore";
 
  return rows
    .sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number))
    .slice(0, limit);
}
 
// -- win32 implementation -----------------------------------------------------
 
async function getTopConsumersWin32(
  limit:  number,
  metric: "cpu" | "memory" | "combined",
): Promise<ConsumerEntry[]> {
  // Two-snapshot sampling (1 s apart) so cpuPercent is a live per-interval %.
  // $_.CPU on Get-Process is TotalProcessorTime.TotalSeconds — cumulative, NOT a
  // live percentage. Comparing it against a 20% threshold in the classifier gives
  // completely wrong results (old processes appear "pegged"; idle processes appear 0).
  // Delta / elapsed / logical-CPU-count gives the same metric Task Manager shows.
  const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$cpuCount = [Math]::Max([Environment]::ProcessorCount, 1)
$snap = @{}
Get-Process | ForEach-Object { $snap[[int]$_.Id] = [double]($_.CPU -as [double]) }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Start-Sleep -Milliseconds 1000
$sw.Stop()
$elapsed = [Math]::Max($sw.Elapsed.TotalSeconds, 0.1)
$result = @(Get-Process | ForEach-Object {
  $prev  = if ($snap.ContainsKey([int]$_.Id)) { $snap[[int]$_.Id] } else { [double]($_.CPU -as [double]) }
  $delta = [Math]::Max([double]($_.CPU -as [double]) - $prev, 0)
  [PSCustomObject]@{
    pid        = [int]$_.Id
    name       = $_.ProcessName
    cpuPercent = [Math]::Round(($delta / $elapsed / $cpuCount) * 100, 2)
    memoryMb   = [Math]::Round($_.WorkingSet64 / 1MB, 1)
  }
})
$result | ConvertTo-Json -Depth 2 -Compress`.trim();
 
  const raw = await runPS(ps);
  if (!raw) return [];
  let rawArr: Omit<ConsumerEntry, "combinedScore" | "isSystem" | "memoryHuman">[];
  try {
    const parsed = JSON.parse(raw) as Omit<ConsumerEntry, "combinedScore" | "isSystem" | "memoryHuman">[] | Omit<ConsumerEntry, "combinedScore" | "isSystem" | "memoryHuman">;
    rawArr = Array.isArray(parsed) ? parsed : [parsed];
  } catch {
    return [];
  }
  // Never surface the agent's own process(es) (no exec path on win32 — match by name/pid).
  const arr = rawArr.filter(r => !isAgentSelf(r.name, r.pid));
 
  const maxCpu = Math.max(...arr.map(r => r.cpuPercent), 1);
  const maxMem = Math.max(...arr.map(r => r.memoryMb), 1);
 
  const entries = arr.map(r => ({
    ...r,
    memoryHuman:   formatBytesBinary(r.memoryMb * 1024 * 1024),
    combinedScore: Math.round(
      ((r.cpuPercent / maxCpu) * 50 + (r.memoryMb / maxMem) * 50) * 100,
    ) / 100,
    isSystem:      isSystemProcess(r.name),
  }));
 
  const sortKey: keyof ConsumerEntry =
    metric === "cpu"    ? "cpuPercent" :
    metric === "memory" ? "memoryMb"   : "combinedScore";
 
  return entries
    .sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number))
    .slice(0, limit);
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  limit  = 10,
  metric = "combined",
}: {
  limit?:  number;
  metric?: "cpu" | "memory" | "combined";
} = {}) {
  const platform  = os.platform();
  const processes = platform === "win32"
    ? await getTopConsumersWin32(limit, metric)
    : await getTopConsumersDarwin(limit, metric);
 
  return {
    platform,
    metric,
    processes,
    sampledAt: new Date().toISOString(),
  };
}
 
// -- Smoke test ---------------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}