fix(local-llm): show accurate macOS memory (app vs cached vs free)

Replace Node.js os.freemem() with vm_stat parsing for macOS. The old
approach reported ~47.7 GB / 48 GB 'used' because os.freemem() only
counts truly free pages, ignoring ~20 GB of inactive/reclaimable cache.

New memory breakdown:
- App Memory: active + wired + compressor (actual process usage)
- Cached: inactive + purgeable + speculative (reclaimable on demand)
- Available: free + cached (what apps can actually use)
- Pressure: normal/warning/critical based on app memory ratio

Dashboard UI updated to show app memory, cached (reclaimable) label,
and pressure-based color coding on progress bars.
This commit is contained in:
saravanakumardb1 2026-02-19 13:22:17 -08:00
parent b77afce9ae
commit 43f8103c5a
2 changed files with 72 additions and 13 deletions

View File

@ -89,23 +89,66 @@ async function getStaticInfo() {
return staticCache;
}
// macOS vm_stat gives accurate memory breakdown (os.freemem() excludes reclaimable cache)
async function getAccurateMemory(): Promise<{
total: number;
appMemory: number;
cached: number;
free: number;
pressure: string;
}> {
const totalMem = os.totalmem();
try {
const { stdout } = await execAsync('vm_stat', { timeout: 2000 });
const pageSize = 16384; // macOS Apple Silicon default
const parse = (label: string): number => {
const match = stdout.match(new RegExp(`${label}:\\s+(\\d+)`));
return match ? parseInt(match[1]) * pageSize : 0;
};
const active = parse('Pages active');
const wired = parse('Pages wired down');
const inactive = parse('Pages inactive');
const purgeable = parse('Pages purgeable');
const speculative = parse('Pages speculative');
const free = parse('Pages free');
const compressor = parse('Pages occupied by compressor');
const appMemory = active + wired + compressor;
const cached = inactive + purgeable + speculative;
const trueFree = free + cached; // macOS reclaims cached on demand
const ratio = appMemory / totalMem;
const pressure = ratio > 0.85 ? 'critical' : ratio > 0.7 ? 'warning' : 'normal';
return { total: totalMem, appMemory, cached, free: trueFree, pressure };
} catch {
// Fallback to Node.js (inaccurate on macOS but works everywhere)
const freeMem = os.freemem();
return {
total: totalMem,
appMemory: totalMem - freeMem,
cached: 0,
free: freeMem,
pressure: 'unknown',
};
}
}
export async function GET() {
const [staticInfo, disk, ollamaDisk] = await Promise.all([
const [staticInfo, disk, ollamaDisk, memory] = await Promise.all([
getStaticInfo(),
getDiskSpace(),
getOllamaModelsDiskUsage(),
getAccurateMemory(),
]);
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuCount = os.cpus().length;
const uptime = os.uptime();
return NextResponse.json({
chip: staticInfo.chip,
gpu: staticInfo.gpu,
memory: { total: totalMem, used: usedMem, free: freeMem },
memory,
disk,
ollamaDiskUsage: ollamaDisk,
cpuCores: cpuCount,

View File

@ -75,7 +75,7 @@ interface WhisperData {
interface SystemData {
chip: string;
gpu: string;
memory: { total: number; used: number; free: number };
memory: { total: number; appMemory: number; cached: number; free: number; pressure: string };
disk: { total: number; used: number; free: number };
ollamaDiskUsage: number;
cpuCores: number;
@ -428,18 +428,21 @@ export default function Dashboard() {
MEMORY
</span>
</div>
<span className="text-lg font-bold">{formatBytes(system?.memory.used || 0)}</span>
<span className="text-lg font-bold">{formatBytes(system?.memory.appMemory || 0)}</span>
<span className="text-sm ml-1" style={{ color: 'var(--text-tertiary)' }}>
/ {formatBytes(system?.memory.total || 0)}
</span>
<p className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{formatBytes(system?.memory.cached || 0)} cached (reclaimable)
</p>
<div className="mt-2">
<ProgressBar
value={system?.memory.used || 0}
value={system?.memory.appMemory || 0}
max={system?.memory.total || 1}
color={
(system?.memory.used || 0) / (system?.memory.total || 1) > 0.85
system?.memory.pressure === 'critical'
? 'var(--danger)'
: (system?.memory.used || 0) / (system?.memory.total || 1) > 0.7
: system?.memory.pressure === 'warning'
? 'var(--warning)'
: 'var(--accent-primary)'
}
@ -750,14 +753,27 @@ export default function Dashboard() {
</span>
</div>
<span className="text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
{formatBytes(system?.memory.free || 0)} free
{formatBytes(system?.memory.free || 0)} avail
</span>
</div>
<ProgressBar
value={system?.memory.used || 0}
value={system?.memory.appMemory || 0}
max={system?.memory.total || 1}
color="var(--warning)"
color={
system?.memory.pressure === 'critical'
? 'var(--danger)'
: system?.memory.pressure === 'warning'
? 'var(--warning)'
: 'var(--accent-secondary)'
}
/>
<div
className="flex justify-between mt-1 text-[10px]"
style={{ color: 'var(--text-tertiary)' }}
>
<span>App: {formatBytes(system?.memory.appMemory || 0)}</span>
<span>Cache: {formatBytes(system?.memory.cached || 0)}</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">