feat: migrate senses to TypeScript source + esbuild bundle

- Move index.js → src/index.ts with proper types for all 4 senses
- Move schema.ts → src/schema.ts
- Add package.json with esbuild build script per sense
- Bundle to index.js at sense root (daemon loads this)
- Update sense-generator coder prompt with TypeScript conventions

Fixes #224
This commit is contained in:
小橘 2026-04-28 07:26:53 +00:00
parent 1940ccedd6
commit 4cf10ad7bf
22 changed files with 2195 additions and 171 deletions

View File

@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest", "@uncaged/nerve-daemon": "latest",
"@uncaged/nerve-workflow-utils": "latest", "@uncaged/nerve-workflow-utils": "link:../repos/nerve/packages/workflow-utils",
"drizzle-orm": "latest", "drizzle-orm": "latest",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@ -1,42 +1,44 @@
// src/index.ts
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { hermesGatewayHealth } from "./schema.ts";
/** Keep subprocess deadlines slightly under typical sense timeout (30s). */ // src/schema.ts
const EXEC_TIMEOUT_MS = 25_000; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
var hermesGatewayHealth = sqliteTable("hermes_gateway_health", {
/** HTTP probe stays below EXEC_TIMEOUT_MS and sense timeout (30s). */ id: integer("id").primaryKey({ autoIncrement: true }),
const HTTP_TIMEOUT_MS = Math.min(23_000, EXEC_TIMEOUT_MS - 2000); ts: integer("ts").notNull(),
alive: integer("alive").notNull(),
const HTTP_ERROR_MAX_LEN = 256; mainPid: integer("main_pid").notNull(),
rssBytes: integer("rss_bytes").notNull(),
cpuPercent: real("cpu_percent").notNull(),
uptimeSec: integer("uptime_sec").notNull(),
activeSessions: integer("active_sessions").notNull(),
childProcessCount: integer("child_process_count").notNull(),
httpOk: integer("http_ok").notNull(),
httpStatusCode: integer("http_status_code").notNull(),
httpLatencyMs: integer("http_latency_ms").notNull(),
httpError: text("http_error").notNull()
});
// src/index.ts
var EXEC_TIMEOUT_MS = 25e3;
var HTTP_TIMEOUT_MS = Math.min(23e3, EXEC_TIMEOUT_MS - 2e3);
var HTTP_ERROR_MAX_LEN = 256;
function gatewayProbeUrl() { function gatewayProbeUrl() {
const u = const u = process.env.HERMES_GATEWAY_HEALTH_URL ?? process.env.NERVE_HERMES_GATEWAY_URL ?? "";
process.env.HERMES_GATEWAY_HEALTH_URL ??
process.env.NERVE_HERMES_GATEWAY_URL ??
"";
return String(u).trim(); return String(u).trim();
} }
function truncateHttpError(err) { function truncateHttpError(err) {
const raw = const raw = err && typeof err === "object" && "code" in err && err.code ? String(err.code) : String(err?.message ?? err ?? "error");
err && typeof err === "object" && "code" in err && err.code
? String(err.code)
: String(err?.message ?? err ?? "error");
const s = raw.trim() || "error"; const s = raw.trim() || "error";
return s.length > HTTP_ERROR_MAX_LEN ? s.slice(0, HTTP_ERROR_MAX_LEN) : s; return s.length > HTTP_ERROR_MAX_LEN ? s.slice(0, HTTP_ERROR_MAX_LEN) : s;
} }
/**
* GET the gateway URL; success = HTTP 200399.
* URL must be set via HERMES_GATEWAY_HEALTH_URL or NERVE_HERMES_GATEWAY_URL.
*/
async function probeGatewayHttp(url) { async function probeGatewayHttp(url) {
if (!url) { if (!url) {
return { return {
httpOk: 0, httpOk: 0,
httpStatusCode: 0, httpStatusCode: 0,
httpLatencyMs: 0, httpLatencyMs: 0,
httpError: "missing_url", httpError: "missing_url"
}; };
} }
const t0 = Date.now(); const t0 = Date.now();
@ -45,7 +47,7 @@ async function probeGatewayHttp(url) {
const res = await fetch(url, { const res = await fetch(url, {
method: "GET", method: "GET",
signal, signal,
redirect: "follow", redirect: "follow"
}); });
const httpLatencyMs = Date.now() - t0; const httpLatencyMs = Date.now() - t0;
const code = res.status; const code = res.status;
@ -54,7 +56,7 @@ async function probeGatewayHttp(url) {
httpOk: ok ? 1 : 0, httpOk: ok ? 1 : 0,
httpStatusCode: code, httpStatusCode: code,
httpLatencyMs, httpLatencyMs,
httpError: ok ? "" : truncateHttpError({ message: `HTTP ${code}` }), httpError: ok ? "" : truncateHttpError({ message: `HTTP ${code}` })
}; };
} catch (err) { } catch (err) {
const httpLatencyMs = Date.now() - t0; const httpLatencyMs = Date.now() - t0;
@ -62,15 +64,10 @@ async function probeGatewayHttp(url) {
httpOk: 0, httpOk: 0,
httpStatusCode: 0, httpStatusCode: 0,
httpLatencyMs, httpLatencyMs,
httpError: truncateHttpError(err), httpError: truncateHttpError(err)
}; };
} }
} }
/**
* When `ps` lacks `etimes` (wall-clock seconds since start), parse `etime`
* ([[dd-]hh:]mm:ss) into seconds. See ps(1) `etime` field description.
*/
function etimeToSeconds(etime) { function etimeToSeconds(etime) {
let s = String(etime).trim(); let s = String(etime).trim();
if (!s) return 0; if (!s) return 0;
@ -84,17 +81,16 @@ function etimeToSeconds(etime) {
const parts = s.split(":").map((x) => Number.parseInt(String(x).trim(), 10)); const parts = s.split(":").map((x) => Number.parseInt(String(x).trim(), 10));
if (parts.some((n) => !Number.isFinite(n))) return 0; if (parts.some((n) => !Number.isFinite(n))) return 0;
if (parts.length === 3) { if (parts.length === 3) {
return Math.trunc(days * 86_400 + parts[0] * 3600 + parts[1] * 60 + parts[2]); return Math.trunc(days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]);
} }
if (parts.length === 2) { if (parts.length === 2) {
return Math.trunc(days * 86_400 + parts[0] * 60 + parts[1]); return Math.trunc(days * 86400 + parts[0] * 60 + parts[1]);
} }
if (parts.length === 1) { if (parts.length === 1) {
return Math.trunc(days * 86_400 + parts[0]); return Math.trunc(days * 86400 + parts[0]);
} }
return 0; return 0;
} }
function execFileUtf8(file, args, opts = {}) { function execFileUtf8(file, args, opts = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
execFile( execFile(
@ -104,47 +100,43 @@ function execFileUtf8(file, args, opts = {}) {
encoding: "utf8", encoding: "utf8",
maxBuffer: 8 * 1024 * 1024, maxBuffer: 8 * 1024 * 1024,
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
...opts, ...opts
}, },
(err, stdout, stderr) => { (err, stdout, stderr) => {
const exitCode = const exitCode = err && typeof err.status === "number" ? err.status : err ? -1 : 0;
err && typeof err.status === "number" ? err.status : err ? -1 : 0;
resolve({ resolve({
exitCode, exitCode,
errCode: err?.code, errCode: err?.code,
stdout: String(stdout ?? ""), stdout: String(stdout ?? ""),
stderr: String(stderr ?? ""), stderr: String(stderr ?? "")
}); });
}, }
); );
}); });
} }
function parseMainPidFromStatus(text2) {
function parseMainPidFromStatus(text) { const m = text2.match(/Main PID:\s*(\d+)/i);
const m = text.match(/Main PID:\s*(\d+)/i);
return m ? Math.trunc(Number.parseInt(m[1], 10)) || 0 : 0; return m ? Math.trunc(Number.parseInt(m[1], 10)) || 0 : 0;
} }
function parseActiveLineFromStatus(text2) {
function parseActiveLineFromStatus(text) { for (const line of text2.split("\n")) {
for (const line of text.split("\n")) {
if (/^\s*Active:/i.test(line)) { if (/^\s*Active:/i.test(line)) {
const m = line.match(/Active:\s*(\S+)\s*\(([^)]*)\)/i); const m = line.match(/Active:\s*(\S+)\s*\(([^)]*)\)/i);
if (m) { if (m) {
return { return {
active: m[1].toLowerCase() === "active", active: m[1].toLowerCase() === "active",
subRunning: m[2].toLowerCase().includes("running"), subRunning: m[2].toLowerCase().includes("running")
}; };
} }
} }
} }
return { active: false, subRunning: false }; return { active: false, subRunning: false };
} }
function parseSystemctlShow(text2) {
function parseSystemctlShow(text) {
let mainPid = 0; let mainPid = 0;
let active = false; let active = false;
let subRunning = false; let subRunning = false;
for (const line of text.split("\n")) { for (const line of text2.split("\n")) {
const t = line.trim(); const t = line.trim();
if (t.startsWith("MainPID=")) { if (t.startsWith("MainPID=")) {
mainPid = Math.trunc(Number.parseInt(t.slice("MainPID=".length), 10)) || 0; mainPid = Math.trunc(Number.parseInt(t.slice("MainPID=".length), 10)) || 0;
@ -156,21 +148,18 @@ function parseSystemctlShow(text) {
} }
return { mainPid, active, subRunning }; return { mainPid, active, subRunning };
} }
async function readSystemdState() { async function readSystemdState() {
const status = await execFileUtf8("systemctl", [ const status = await execFileUtf8("systemctl", [
"--user", "--user",
"--no-pager", "--no-pager",
"status", "status",
"hermes-gateway", "hermes-gateway"
]); ]);
const combined = `${status.stdout}\n${status.stderr}`.trim(); const combined = `${status.stdout}
${status.stderr}`.trim();
let mainPid = parseMainPidFromStatus(combined); let mainPid = parseMainPidFromStatus(combined);
let { active, subRunning } = parseActiveLineFromStatus(combined); let { active, subRunning } = parseActiveLineFromStatus(combined);
const needShow = mainPid <= 0 || !active || !subRunning;
const needShow =
mainPid <= 0 || !active || !subRunning;
if (needShow) { if (needShow) {
const show = await execFileUtf8("systemctl", [ const show = await execFileUtf8("systemctl", [
"--user", "--user",
@ -182,25 +171,23 @@ async function readSystemdState() {
"-p", "-p",
"ActiveState", "ActiveState",
"-p", "-p",
"SubState", "SubState"
]); ]);
const showText = `${show.stdout}\n${show.stderr}`; const showText = `${show.stdout}
${show.stderr}`;
const s = parseSystemctlShow(showText); const s = parseSystemctlShow(showText);
if (mainPid <= 0 && s.mainPid > 0) mainPid = s.mainPid; if (mainPid <= 0 && s.mainPid > 0) mainPid = s.mainPid;
if (!active) active = s.active; if (!active) active = s.active;
if (!subRunning) subRunning = s.subRunning; if (!subRunning) subRunning = s.subRunning;
} }
return { mainPid, systemdActiveRunning: active && subRunning }; return { mainPid, systemdActiveRunning: active && subRunning };
} }
async function processExists(mainPid) { async function processExists(mainPid) {
if (mainPid <= 0) return false; if (mainPid <= 0) return false;
const r = await execFileUtf8("ps", ["-p", String(mainPid), "-o", "pid="]); const r = await execFileUtf8("ps", ["-p", String(mainPid), "-o", "pid="]);
if (r.errCode === "ENOENT") return false; if (r.errCode === "ENOENT") return false;
return r.stdout.trim().length > 0; return r.stdout.trim().length > 0;
} }
async function readPsMetrics(mainPid) { async function readPsMetrics(mainPid) {
if (mainPid <= 0) { if (mainPid <= 0) {
return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 }; return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 };
@ -209,7 +196,7 @@ async function readPsMetrics(mainPid) {
"-p", "-p",
String(mainPid), String(mainPid),
"-o", "-o",
"rss=,%cpu=,etimes=", "rss=,%cpu=,etimes="
]); ]);
let line = r.stdout.trim().replace(/\s+/g, " "); let line = r.stdout.trim().replace(/\s+/g, " ");
if (r.errCode === "ENOENT" || !line) { if (r.errCode === "ENOENT" || !line) {
@ -221,41 +208,34 @@ async function readPsMetrics(mainPid) {
"-p", "-p",
String(mainPid), String(mainPid),
"-o", "-o",
"rss=,%cpu=,etime=", "rss=,%cpu=,etime="
]); ]);
line = r.stdout.trim().replace(/\s+/g, " "); line = r.stdout.trim().replace(/\s+/g, " ");
parts = line.split(" ").filter(Boolean); parts = line.split(" ").filter(Boolean);
if (parts.length < 3) { if (parts.length < 3) {
return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 }; return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 };
} }
const rssKiB = Number(parts[0]); const rssKiB2 = Number(parts[0]);
const cpu = Number(parts[1]); const cpu2 = Number(parts[1]);
const uptimeSec = etimeToSeconds(parts.slice(2).join(" ")); const uptimeSec2 = etimeToSeconds(parts.slice(2).join(" "));
const rssBytes = Number.isFinite(rssKiB) const rssBytes2 = Number.isFinite(rssKiB2) ? Math.trunc(rssKiB2 * 1024) : 0;
? Math.trunc(rssKiB * 1024) const cpuPercent2 = Number.isFinite(cpu2) ? Math.round(cpu2 * 100) / 100 : 0;
: 0; return { rssBytes: rssBytes2, cpuPercent: cpuPercent2, uptimeSec: uptimeSec2 };
const cpuPercent = Number.isFinite(cpu)
? Math.round(cpu * 100) / 100
: 0;
return { rssBytes, cpuPercent, uptimeSec };
} }
const rssKiB = Number(parts[0]); const rssKiB = Number(parts[0]);
const cpu = Number(parts[1]); const cpu = Number(parts[1]);
const etimes = Number(parts[2]); const etimes = Number(parts[2]);
const rssBytes = Number.isFinite(rssKiB) ? Math.trunc(rssKiB * 1024) : 0; const rssBytes = Number.isFinite(rssKiB) ? Math.trunc(rssKiB * 1024) : 0;
const cpuPercent = Number.isFinite(cpu) ? Math.round(cpu * 100) / 100 : 0; const cpuPercent = Number.isFinite(cpu) ? Math.round(cpu * 100) / 100 : 0;
const uptimeSec = Number.isFinite(etimes) const uptimeSec = Number.isFinite(etimes) ? Math.trunc(etimes) : 0;
? Math.trunc(etimes)
: 0;
return { rssBytes, cpuPercent, uptimeSec }; return { rssBytes, cpuPercent, uptimeSec };
} }
function parseActiveSessionsFromHermesStats(text2) {
function parseActiveSessionsFromHermesStats(text) { const src = String(text2);
const src = String(text);
const patterns = [ const patterns = [
/^\s*Active\s+sessions?:\s*(\d+)/gim, /^\s*Active\s+sessions?:\s*(\d+)/gim,
/^\s*active\s+sessions?:\s*(\d+)/gim, /^\s*active\s+sessions?:\s*(\d+)/gim,
/^\s*Total\s+sessions?:\s*(\d+)/gim, /^\s*Total\s+sessions?:\s*(\d+)/gim
]; ];
for (const re of patterns) { for (const re of patterns) {
re.lastIndex = 0; re.lastIndex = 0;
@ -267,17 +247,16 @@ function parseActiveSessionsFromHermesStats(text) {
} }
return 0; return 0;
} }
async function readActiveSessions() { async function readActiveSessions() {
try { try {
const r = await execFileUtf8("hermes", ["sessions", "stats"]); const r = await execFileUtf8("hermes", ["sessions", "stats"]);
if (r.errCode === "ENOENT") return 0; if (r.errCode === "ENOENT") return 0;
return parseActiveSessionsFromHermesStats(`${r.stdout}\n${r.stderr}`); return parseActiveSessionsFromHermesStats(`${r.stdout}
${r.stderr}`);
} catch { } catch {
return 0; return 0;
} }
} }
async function countDirectChildren(mainPid) { async function countDirectChildren(mainPid) {
if (mainPid <= 0) return 0; if (mainPid <= 0) return 0;
try { try {
@ -286,25 +265,19 @@ async function countDirectChildren(mainPid) {
"-o", "-o",
"pid", "pid",
"--ppid", "--ppid",
String(mainPid), String(mainPid)
]); ]);
if (r.errCode === "ENOENT") return 0; if (r.errCode === "ENOENT") return 0;
const lines = r.stdout const lines = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
return lines.length; return lines.length;
} catch { } catch {
return 0; return 0;
} }
} }
async function compute(db, _peers) {
export async function compute(db, _peers) {
const ts = Date.now(); const ts = Date.now();
let mainPid = 0; let mainPid = 0;
let systemdActiveRunning = false; let systemdActiveRunning = false;
try { try {
const st = await readSystemdState(); const st = await readSystemdState();
mainPid = st.mainPid; mainPid = st.mainPid;
@ -313,14 +286,12 @@ export async function compute(db, _peers) {
mainPid = 0; mainPid = 0;
systemdActiveRunning = false; systemdActiveRunning = false;
} }
let psOk = false; let psOk = false;
try { try {
psOk = await processExists(mainPid); psOk = await processExists(mainPid);
} catch { } catch {
psOk = false; psOk = false;
} }
let rssBytes = 0; let rssBytes = 0;
let cpuPercent = 0; let cpuPercent = 0;
let uptimeSec = 0; let uptimeSec = 0;
@ -336,17 +307,13 @@ export async function compute(db, _peers) {
uptimeSec = 0; uptimeSec = 0;
} }
} }
const alive = systemdActiveRunning && mainPid > 0 && psOk ? 1 : 0;
const alive =
systemdActiveRunning && mainPid > 0 && psOk ? 1 : 0;
let activeSessions = 0; let activeSessions = 0;
try { try {
activeSessions = await readActiveSessions(); activeSessions = await readActiveSessions();
} catch { } catch {
activeSessions = 0; activeSessions = 0;
} }
let childProcessCount = 0; let childProcessCount = 0;
if (alive && mainPid > 0) { if (alive && mainPid > 0) {
try { try {
@ -355,7 +322,6 @@ export async function compute(db, _peers) {
childProcessCount = 0; childProcessCount = 0;
} }
} }
let httpOk = 0; let httpOk = 0;
let httpStatusCode = 0; let httpStatusCode = 0;
let httpLatencyMs = 0; let httpLatencyMs = 0;
@ -372,9 +338,7 @@ export async function compute(db, _peers) {
httpLatencyMs = 0; httpLatencyMs = 0;
httpError = "probe_failed"; httpError = "probe_failed";
} }
const storedMainPid = mainPid > 0 ? mainPid : 0; const storedMainPid = mainPid > 0 ? mainPid : 0;
const row = { const row = {
ts, ts,
alive, alive,
@ -387,11 +351,9 @@ export async function compute(db, _peers) {
httpOk, httpOk,
httpStatusCode, httpStatusCode,
httpLatencyMs, httpLatencyMs,
httpError, httpError
}; };
await db.insert(hermesGatewayHealth).values(row); await db.insert(hermesGatewayHealth).values(row);
return { return {
ts: row.ts, ts: row.ts,
alive: row.alive, alive: row.alive,
@ -404,6 +366,9 @@ export async function compute(db, _peers) {
httpOk: row.httpOk, httpOk: row.httpOk,
httpStatusCode: row.httpStatusCode, httpStatusCode: row.httpStatusCode,
httpLatencyMs: row.httpLatencyMs, httpLatencyMs: row.httpLatencyMs,
httpError: row.httpError, httpError: row.httpError
}; };
} }
export {
compute
};

View File

@ -0,0 +1,17 @@
{
"name": "sense-hermes-gateway-health",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}

View File

@ -0,0 +1,310 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@ -0,0 +1,424 @@
import { execFile } from "node:child_process";
import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { hermesGatewayHealth } from "./schema.ts";
/** Keep subprocess deadlines slightly under typical sense timeout (30s). */
const EXEC_TIMEOUT_MS = 25_000;
/** HTTP probe stays below EXEC_TIMEOUT_MS and sense timeout (30s). */
const HTTP_TIMEOUT_MS = Math.min(23_000, EXEC_TIMEOUT_MS - 2000);
const HTTP_ERROR_MAX_LEN = 256;
function gatewayProbeUrl(): string {
const u =
process.env.HERMES_GATEWAY_HEALTH_URL ??
process.env.NERVE_HERMES_GATEWAY_URL ??
"";
return String(u).trim();
}
function truncateHttpError(err: unknown): string {
const raw =
err && typeof err === "object" && "code" in err && (err as { code: unknown }).code
? String((err as { code: unknown }).code)
: String((err as { message?: unknown } | null)?.message ?? err ?? "error");
const s = raw.trim() || "error";
return s.length > HTTP_ERROR_MAX_LEN ? s.slice(0, HTTP_ERROR_MAX_LEN) : s;
}
interface HttpProbeResult {
httpOk: number;
httpStatusCode: number;
httpLatencyMs: number;
httpError: string;
}
/**
* GET the gateway URL; success = HTTP 200399.
* URL must be set via HERMES_GATEWAY_HEALTH_URL or NERVE_HERMES_GATEWAY_URL.
*/
async function probeGatewayHttp(url: string): Promise<HttpProbeResult> {
if (!url) {
return {
httpOk: 0,
httpStatusCode: 0,
httpLatencyMs: 0,
httpError: "missing_url",
};
}
const t0 = Date.now();
try {
const signal = AbortSignal.timeout(HTTP_TIMEOUT_MS);
const res = await fetch(url, {
method: "GET",
signal,
redirect: "follow",
});
const httpLatencyMs = Date.now() - t0;
const code = res.status;
const ok = code >= 200 && code < 400;
return {
httpOk: ok ? 1 : 0,
httpStatusCode: code,
httpLatencyMs,
httpError: ok ? "" : truncateHttpError({ message: `HTTP ${code}` }),
};
} catch (err) {
const httpLatencyMs = Date.now() - t0;
return {
httpOk: 0,
httpStatusCode: 0,
httpLatencyMs,
httpError: truncateHttpError(err),
};
}
}
/**
* When `ps` lacks `etimes` (wall-clock seconds since start), parse `etime`
* ([[dd-]hh:]mm:ss) into seconds. See ps(1) `etime` field description.
*/
function etimeToSeconds(etime: string): number {
let s = String(etime).trim();
if (!s) return 0;
let days = 0;
if (s.includes("-")) {
const idx = s.indexOf("-");
const d = Number.parseInt(s.slice(0, idx), 10);
days = Number.isFinite(d) ? d : 0;
s = s.slice(idx + 1);
}
const parts = s.split(":").map((x) => Number.parseInt(String(x).trim(), 10));
if (parts.some((n) => !Number.isFinite(n))) return 0;
if (parts.length === 3) {
return Math.trunc(days * 86_400 + parts[0] * 3600 + parts[1] * 60 + parts[2]);
}
if (parts.length === 2) {
return Math.trunc(days * 86_400 + parts[0] * 60 + parts[1]);
}
if (parts.length === 1) {
return Math.trunc(days * 86_400 + parts[0]);
}
return 0;
}
interface ExecResult {
exitCode: number;
errCode: string | undefined;
stdout: string;
stderr: string;
}
function execFileUtf8(file: string, args: string[], opts: Record<string, unknown> = {}): Promise<ExecResult> {
return new Promise((resolve) => {
execFile(
file,
args,
{
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
timeout: EXEC_TIMEOUT_MS,
...opts,
} as Parameters<typeof execFile>[2],
(err, stdout, stderr) => {
const exitCode =
err && typeof (err as NodeJS.ErrnoException).status === "number"
? (err as NodeJS.ErrnoException & { status: number }).status
: err ? -1 : 0;
resolve({
exitCode,
errCode: (err as NodeJS.ErrnoException | null)?.code,
stdout: String(stdout ?? ""),
stderr: String(stderr ?? ""),
});
},
);
});
}
function parseMainPidFromStatus(text: string): number {
const m = text.match(/Main PID:\s*(\d+)/i);
return m ? Math.trunc(Number.parseInt(m[1], 10)) || 0 : 0;
}
function parseActiveLineFromStatus(text: string): { active: boolean; subRunning: boolean } {
for (const line of text.split("\n")) {
if (/^\s*Active:/i.test(line)) {
const m = line.match(/Active:\s*(\S+)\s*\(([^)]*)\)/i);
if (m) {
return {
active: m[1].toLowerCase() === "active",
subRunning: m[2].toLowerCase().includes("running"),
};
}
}
}
return { active: false, subRunning: false };
}
function parseSystemctlShow(text: string): { mainPid: number; active: boolean; subRunning: boolean } {
let mainPid = 0;
let active = false;
let subRunning = false;
for (const line of text.split("\n")) {
const t = line.trim();
if (t.startsWith("MainPID=")) {
mainPid = Math.trunc(Number.parseInt(t.slice("MainPID=".length), 10)) || 0;
} else if (t.startsWith("ActiveState=")) {
active = t.slice("ActiveState=".length).trim().toLowerCase() === "active";
} else if (t.startsWith("SubState=")) {
subRunning = t.slice("SubState=".length).trim().toLowerCase() === "running";
}
}
return { mainPid, active, subRunning };
}
async function readSystemdState(): Promise<{ mainPid: number; systemdActiveRunning: boolean }> {
const status = await execFileUtf8("systemctl", [
"--user",
"--no-pager",
"status",
"hermes-gateway",
]);
const combined = `${status.stdout}\n${status.stderr}`.trim();
let mainPid = parseMainPidFromStatus(combined);
let { active, subRunning } = parseActiveLineFromStatus(combined);
const needShow = mainPid <= 0 || !active || !subRunning;
if (needShow) {
const show = await execFileUtf8("systemctl", [
"--user",
"--no-pager",
"show",
"hermes-gateway",
"-p",
"MainPID",
"-p",
"ActiveState",
"-p",
"SubState",
]);
const showText = `${show.stdout}\n${show.stderr}`;
const s = parseSystemctlShow(showText);
if (mainPid <= 0 && s.mainPid > 0) mainPid = s.mainPid;
if (!active) active = s.active;
if (!subRunning) subRunning = s.subRunning;
}
return { mainPid, systemdActiveRunning: active && subRunning };
}
async function processExists(mainPid: number): Promise<boolean> {
if (mainPid <= 0) return false;
const r = await execFileUtf8("ps", ["-p", String(mainPid), "-o", "pid="]);
if (r.errCode === "ENOENT") return false;
return r.stdout.trim().length > 0;
}
interface PsMetrics {
rssBytes: number;
cpuPercent: number;
uptimeSec: number;
}
async function readPsMetrics(mainPid: number): Promise<PsMetrics> {
if (mainPid <= 0) {
return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 };
}
let r = await execFileUtf8("ps", [
"-p",
String(mainPid),
"-o",
"rss=,%cpu=,etimes=",
]);
let line = r.stdout.trim().replace(/\s+/g, " ");
if (r.errCode === "ENOENT" || !line) {
return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 };
}
let parts = line.split(" ").filter(Boolean);
if (parts.length < 3) {
r = await execFileUtf8("ps", [
"-p",
String(mainPid),
"-o",
"rss=,%cpu=,etime=",
]);
line = r.stdout.trim().replace(/\s+/g, " ");
parts = line.split(" ").filter(Boolean);
if (parts.length < 3) {
return { rssBytes: 0, cpuPercent: 0, uptimeSec: 0 };
}
const rssKiB = Number(parts[0]);
const cpu = Number(parts[1]);
const uptimeSec = etimeToSeconds(parts.slice(2).join(" "));
const rssBytes = Number.isFinite(rssKiB) ? Math.trunc(rssKiB * 1024) : 0;
const cpuPercent = Number.isFinite(cpu) ? Math.round(cpu * 100) / 100 : 0;
return { rssBytes, cpuPercent, uptimeSec };
}
const rssKiB = Number(parts[0]);
const cpu = Number(parts[1]);
const etimes = Number(parts[2]);
const rssBytes = Number.isFinite(rssKiB) ? Math.trunc(rssKiB * 1024) : 0;
const cpuPercent = Number.isFinite(cpu) ? Math.round(cpu * 100) / 100 : 0;
const uptimeSec = Number.isFinite(etimes) ? Math.trunc(etimes) : 0;
return { rssBytes, cpuPercent, uptimeSec };
}
function parseActiveSessionsFromHermesStats(text: string): number {
const src = String(text);
const patterns = [
/^\s*Active\s+sessions?:\s*(\d+)/gim,
/^\s*active\s+sessions?:\s*(\d+)/gim,
/^\s*Total\s+sessions?:\s*(\d+)/gim,
];
for (const re of patterns) {
re.lastIndex = 0;
const m = re.exec(src);
if (m) {
const n = Math.trunc(Number.parseInt(m[1], 10));
return Number.isFinite(n) ? n : 0;
}
}
return 0;
}
async function readActiveSessions(): Promise<number> {
try {
const r = await execFileUtf8("hermes", ["sessions", "stats"]);
if (r.errCode === "ENOENT") return 0;
return parseActiveSessionsFromHermesStats(`${r.stdout}\n${r.stderr}`);
} catch {
return 0;
}
}
async function countDirectChildren(mainPid: number): Promise<number> {
if (mainPid <= 0) return 0;
try {
const r = await execFileUtf8("ps", [
"--no-headers",
"-o",
"pid",
"--ppid",
String(mainPid),
]);
if (r.errCode === "ENOENT") return 0;
const lines = r.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
return lines.length;
} catch {
return 0;
}
}
export async function compute(db: LibSQLDatabase, _peers: unknown) {
const ts = Date.now();
let mainPid = 0;
let systemdActiveRunning = false;
try {
const st = await readSystemdState();
mainPid = st.mainPid;
systemdActiveRunning = st.systemdActiveRunning;
} catch {
mainPid = 0;
systemdActiveRunning = false;
}
let psOk = false;
try {
psOk = await processExists(mainPid);
} catch {
psOk = false;
}
let rssBytes = 0;
let cpuPercent = 0;
let uptimeSec = 0;
if (psOk) {
try {
const m = await readPsMetrics(mainPid);
rssBytes = m.rssBytes;
cpuPercent = m.cpuPercent;
uptimeSec = m.uptimeSec;
} catch {
rssBytes = 0;
cpuPercent = 0;
uptimeSec = 0;
}
}
const alive = systemdActiveRunning && mainPid > 0 && psOk ? 1 : 0;
let activeSessions = 0;
try {
activeSessions = await readActiveSessions();
} catch {
activeSessions = 0;
}
let childProcessCount = 0;
if (alive && mainPid > 0) {
try {
childProcessCount = await countDirectChildren(mainPid);
} catch {
childProcessCount = 0;
}
}
let httpOk = 0;
let httpStatusCode = 0;
let httpLatencyMs = 0;
let httpError = "";
try {
const h = await probeGatewayHttp(gatewayProbeUrl());
httpOk = h.httpOk;
httpStatusCode = h.httpStatusCode;
httpLatencyMs = h.httpLatencyMs;
httpError = h.httpError;
} catch {
httpOk = 0;
httpStatusCode = 0;
httpLatencyMs = 0;
httpError = "probe_failed";
}
const storedMainPid = mainPid > 0 ? mainPid : 0;
const row = {
ts,
alive,
mainPid: storedMainPid,
rssBytes: alive ? rssBytes : 0,
cpuPercent: alive ? cpuPercent : 0,
uptimeSec: alive ? uptimeSec : 0,
activeSessions,
childProcessCount: alive ? childProcessCount : 0,
httpOk,
httpStatusCode,
httpLatencyMs,
httpError,
};
await db.insert(hermesGatewayHealth).values(row);
return {
ts: row.ts,
alive: row.alive,
mainPid: row.mainPid,
rssBytes: row.rssBytes,
cpuPercent: row.cpuPercent,
uptimeSec: row.uptimeSec,
activeSessions: row.activeSessions,
childProcessCount: row.childProcessCount,
httpOk: row.httpOk,
httpStatusCode: row.httpStatusCode,
httpLatencyMs: row.httpLatencyMs,
httpError: row.httpError,
};
}

View File

@ -1,25 +1,31 @@
// src/index.ts
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { hermesSessionMessageStats } from "./schema.ts";
const MEASUREMENT_WINDOW_MS = 900_000; // src/schema.ts
const MEASUREMENT_WINDOW_SECONDS = 900; import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
var hermesSessionMessageStats = sqliteTable("hermes_session_message_stats", {
id: integer("id").primaryKey({ autoIncrement: true }),
ts: integer("ts").notNull(),
totalUserMessages: integer("total_user_messages").notNull(),
totalAssistantMessages: integer("total_assistant_messages").notNull(),
totalToolMessages: integer("total_tool_messages").notNull(),
totalMessages: integer("total_messages").notNull(),
activeSessions: integer("active_sessions").notNull(),
measurementWindowSeconds: integer("measurement_window_seconds").notNull()
});
/** // src/index.ts
* @param {string} filePath var MEASUREMENT_WINDOW_MS = 9e5;
* @param {number} cutoffMs var MEASUREMENT_WINDOW_SECONDS = 900;
* @param {number} nowMs
* @returns {Promise<{ user: number; assistant: number; tool: number; fileHadActivity: boolean }>}
*/
async function aggregateJsonlFile(filePath, cutoffMs, nowMs) { async function aggregateJsonlFile(filePath, cutoffMs, nowMs) {
let user = 0; let user = 0;
let assistant = 0; let assistant = 0;
let tool = 0; let tool = 0;
let fileHadActivity = false; let fileHadActivity = false;
const input = createReadStream(filePath, { encoding: "utf8" }); const input = createReadStream(filePath, { encoding: "utf8" });
const rl = createInterface({ input, crlfDelay: Infinity }); const rl = createInterface({ input, crlfDelay: Infinity });
try { try {
@ -32,13 +38,13 @@ async function aggregateJsonlFile(filePath, cutoffMs, nowMs) {
} catch { } catch {
continue; continue;
} }
if (typeof obj.role !== "string" || typeof obj.timestamp !== "string") { if (typeof obj !== "object" || obj === null || typeof obj.role !== "string" || typeof obj.timestamp !== "string") {
continue; continue;
} }
const t = Date.parse(obj.timestamp); const record = obj;
const t = Date.parse(record.timestamp);
if (!Number.isFinite(t) || t < cutoffMs || t > nowMs) continue; if (!Number.isFinite(t) || t < cutoffMs || t > nowMs) continue;
const roleNorm = record.role.trim().toLowerCase();
const roleNorm = obj.role.trim().toLowerCase();
if (roleNorm === "user") { if (roleNorm === "user") {
user++; user++;
fileHadActivity = true; fileHadActivity = true;
@ -53,27 +59,21 @@ async function aggregateJsonlFile(filePath, cutoffMs, nowMs) {
} finally { } finally {
rl.close(); rl.close();
} }
return { user, assistant, tool, fileHadActivity }; return { user, assistant, tool, fileHadActivity };
} }
async function compute(db, _peers) {
export async function compute(db, _peers) {
const nowMs = Date.now(); const nowMs = Date.now();
const cutoffMs = nowMs - MEASUREMENT_WINDOW_MS; const cutoffMs = nowMs - MEASUREMENT_WINDOW_MS;
const ts = nowMs; const ts = nowMs;
let totalUserMessages = 0; let totalUserMessages = 0;
let totalAssistantMessages = 0; let totalAssistantMessages = 0;
let totalToolMessages = 0; let totalToolMessages = 0;
let activeSessions = 0; let activeSessions = 0;
const sessionsDir = join(homedir(), ".hermes", "sessions"); const sessionsDir = join(homedir(), ".hermes", "sessions");
let files = []; let files = [];
try { try {
const entries = await readdir(sessionsDir, { withFileTypes: true }); const entries = await readdir(sessionsDir, { withFileTypes: true });
files = entries files = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join(sessionsDir, e.name));
.filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
.map((e) => join(sessionsDir, e.name));
} catch (err) { } catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
files = []; files = [];
@ -81,22 +81,18 @@ export async function compute(db, _peers) {
throw err; throw err;
} }
} }
for (const filePath of files) { for (const filePath of files) {
const { user, assistant, tool, fileHadActivity } = await aggregateJsonlFile( const { user, assistant, tool, fileHadActivity } = await aggregateJsonlFile(
filePath, filePath,
cutoffMs, cutoffMs,
nowMs, nowMs
); );
totalUserMessages += user; totalUserMessages += user;
totalAssistantMessages += assistant; totalAssistantMessages += assistant;
totalToolMessages += tool; totalToolMessages += tool;
if (fileHadActivity) activeSessions++; if (fileHadActivity) activeSessions++;
} }
const totalMessages = totalUserMessages + totalAssistantMessages + totalToolMessages;
const totalMessages =
totalUserMessages + totalAssistantMessages + totalToolMessages;
const row = { const row = {
ts, ts,
totalUserMessages, totalUserMessages,
@ -104,11 +100,9 @@ export async function compute(db, _peers) {
totalToolMessages, totalToolMessages,
totalMessages, totalMessages,
activeSessions, activeSessions,
measurementWindowSeconds: MEASUREMENT_WINDOW_SECONDS, measurementWindowSeconds: MEASUREMENT_WINDOW_SECONDS
}; };
await db.insert(hermesSessionMessageStats).values(row); await db.insert(hermesSessionMessageStats).values(row);
return { return {
ts: row.ts, ts: row.ts,
totalUserMessages: row.totalUserMessages, totalUserMessages: row.totalUserMessages,
@ -116,6 +110,9 @@ export async function compute(db, _peers) {
totalToolMessages: row.totalToolMessages, totalToolMessages: row.totalToolMessages,
totalMessages: row.totalMessages, totalMessages: row.totalMessages,
activeSessions: row.activeSessions, activeSessions: row.activeSessions,
measurementWindowSeconds: row.measurementWindowSeconds, measurementWindowSeconds: row.measurementWindowSeconds
}; };
} }
export {
compute
};

View File

@ -0,0 +1,17 @@
{
"name": "sense-hermes-session-message-stats",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}

View File

@ -0,0 +1,310 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@ -0,0 +1,128 @@
import { createReadStream } from "node:fs";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { hermesSessionMessageStats } from "./schema.ts";
const MEASUREMENT_WINDOW_MS = 900_000;
const MEASUREMENT_WINDOW_SECONDS = 900;
interface MessageCounts {
user: number;
assistant: number;
tool: number;
fileHadActivity: boolean;
}
async function aggregateJsonlFile(filePath: string, cutoffMs: number, nowMs: number): Promise<MessageCounts> {
let user = 0;
let assistant = 0;
let tool = 0;
let fileHadActivity = false;
const input = createReadStream(filePath, { encoding: "utf8" });
const rl = createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
let obj: unknown;
try {
obj = JSON.parse(trimmed);
} catch {
continue;
}
if (
typeof obj !== "object" || obj === null ||
typeof (obj as Record<string, unknown>).role !== "string" ||
typeof (obj as Record<string, unknown>).timestamp !== "string"
) {
continue;
}
const record = obj as { role: string; timestamp: string };
const t = Date.parse(record.timestamp);
if (!Number.isFinite(t) || t < cutoffMs || t > nowMs) continue;
const roleNorm = record.role.trim().toLowerCase();
if (roleNorm === "user") {
user++;
fileHadActivity = true;
} else if (roleNorm === "assistant") {
assistant++;
fileHadActivity = true;
} else if (roleNorm === "tool") {
tool++;
fileHadActivity = true;
}
}
} finally {
rl.close();
}
return { user, assistant, tool, fileHadActivity };
}
export async function compute(db: LibSQLDatabase, _peers: unknown) {
const nowMs = Date.now();
const cutoffMs = nowMs - MEASUREMENT_WINDOW_MS;
const ts = nowMs;
let totalUserMessages = 0;
let totalAssistantMessages = 0;
let totalToolMessages = 0;
let activeSessions = 0;
const sessionsDir = join(homedir(), ".hermes", "sessions");
let files: string[] = [];
try {
const entries = await readdir(sessionsDir, { withFileTypes: true });
files = entries
.filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
.map((e) => join(sessionsDir, e.name));
} catch (err) {
if (err && typeof err === "object" && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") {
files = [];
} else {
throw err;
}
}
for (const filePath of files) {
const { user, assistant, tool, fileHadActivity } = await aggregateJsonlFile(
filePath,
cutoffMs,
nowMs,
);
totalUserMessages += user;
totalAssistantMessages += assistant;
totalToolMessages += tool;
if (fileHadActivity) activeSessions++;
}
const totalMessages =
totalUserMessages + totalAssistantMessages + totalToolMessages;
const row = {
ts,
totalUserMessages,
totalAssistantMessages,
totalToolMessages,
totalMessages,
activeSessions,
measurementWindowSeconds: MEASUREMENT_WINDOW_SECONDS,
};
await db.insert(hermesSessionMessageStats).values(row);
return {
ts: row.ts,
totalUserMessages: row.totalUserMessages,
totalAssistantMessages: row.totalAssistantMessages,
totalToolMessages: row.totalToolMessages,
totalMessages: row.totalMessages,
activeSessions: row.activeSessions,
measurementWindowSeconds: row.measurementWindowSeconds,
};
}

View File

@ -1,13 +1,35 @@
// src/index.ts
import { loadavg, totalmem, freemem, uptime } from "node:os"; import { loadavg, totalmem, freemem, uptime } from "node:os";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { snapshots } from "./schema.ts";
const SOCKSTAT_PATH = "/proc/net/sockstat"; // src/schema.ts
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
var snapshots = sqliteTable("snapshots", {
ts: integer("ts").primaryKey(),
cpuLoad1m: real("cpu_load_1m").notNull(),
cpuLoad5m: real("cpu_load_5m").notNull(),
cpuLoad15m: real("cpu_load_15m").notNull(),
memTotalMB: integer("mem_total_mb").notNull(),
memUsedMB: integer("mem_used_mb").notNull(),
memUsedPct: real("mem_used_pct").notNull(),
diskTotalGB: real("disk_total_gb").notNull(),
diskUsedGB: real("disk_used_gb").notNull(),
diskUsedPct: real("disk_used_pct").notNull(),
uptimeSec: integer("uptime_sec").notNull(),
// TCP socket stats (merged from linux-tcp-socket-stats)
socketsUsed: integer("sockets_used"),
tcpInuse: integer("tcp_inuse"),
tcpOrphan: integer("tcp_orphan"),
tcpTw: integer("tcp_tw"),
tcpAlloc: integer("tcp_alloc"),
tcpMemPages: integer("tcp_mem_pages")
});
// src/index.ts
var SOCKSTAT_PATH = "/proc/net/sockstat";
function parseSockstat(content) { function parseSockstat(content) {
let socketsUsed = 0, tcpInuse = 0, tcpOrphan = 0, tcpTw = 0, tcpAlloc = 0, tcpMemPages = 0; let socketsUsed = 0, tcpInuse = 0, tcpOrphan = 0, tcpTw = 0, tcpAlloc = 0, tcpMemPages = 0;
for (const line of content.split("\n")) { for (const line of content.split("\n")) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.startsWith("sockets:")) { if (trimmed.startsWith("sockets:")) {
@ -29,20 +51,16 @@ function parseSockstat(content) {
tcpMemPages = map.mem ?? 0; tcpMemPages = map.mem ?? 0;
} }
} }
return { socketsUsed, tcpInuse, tcpOrphan, tcpTw, tcpAlloc, tcpMemPages }; return { socketsUsed, tcpInuse, tcpOrphan, tcpTw, tcpAlloc, tcpMemPages };
} }
async function compute(db, _peers) {
export async function compute(db, _peers) {
const [load1, load5, load15] = loadavg(); const [load1, load5, load15] = loadavg();
const memTotal = totalmem(); const memTotal = totalmem();
const memFree = freemem(); const memFree = freemem();
const memUsed = memTotal - memFree; const memUsed = memTotal - memFree;
const memTotalMB = Math.round(memTotal / 1024 / 1024); const memTotalMB = Math.round(memTotal / 1024 / 1024);
const memUsedMB = Math.round(memUsed / 1024 / 1024); const memUsedMB = Math.round(memUsed / 1024 / 1024);
const memUsedPct = Math.round((memUsed / memTotal) * 10000) / 100; const memUsedPct = Math.round(memUsed / memTotal * 1e4) / 100;
let diskTotalGB = 0, diskUsedGB = 0, diskUsedPct = 0; let diskTotalGB = 0, diskUsedGB = 0, diskUsedPct = 0;
try { try {
const df = execSync("df -B1 / | tail -1", { encoding: "utf-8" }).trim(); const df = execSync("df -B1 / | tail -1", { encoding: "utf-8" }).trim();
@ -51,37 +69,44 @@ export async function compute(db, _peers) {
const used = Number(parts[2]); const used = Number(parts[2]);
diskTotalGB = Math.round(total / 1024 / 1024 / 1024 * 100) / 100; diskTotalGB = Math.round(total / 1024 / 1024 / 1024 * 100) / 100;
diskUsedGB = Math.round(used / 1024 / 1024 / 1024 * 100) / 100; diskUsedGB = Math.round(used / 1024 / 1024 / 1024 * 100) / 100;
diskUsedPct = total > 0 ? Math.round((used / total) * 10000) / 100 : 0; diskUsedPct = total > 0 ? Math.round(used / total * 1e4) / 100 : 0;
} catch {} } catch {
}
// TCP socket stats
let tcp = { socketsUsed: 0, tcpInuse: 0, tcpOrphan: 0, tcpTw: 0, tcpAlloc: 0, tcpMemPages: 0 }; let tcp = { socketsUsed: 0, tcpInuse: 0, tcpOrphan: 0, tcpTw: 0, tcpAlloc: 0, tcpMemPages: 0 };
try { try {
const content = await readFile(SOCKSTAT_PATH, "utf8"); const content = await readFile(SOCKSTAT_PATH, "utf8");
tcp = parseSockstat(content); tcp = parseSockstat(content);
} catch {} } catch {
}
const ts = Date.now(); const ts = Date.now();
const uptimeSec = Math.round(uptime()); const uptimeSec = Math.round(uptime());
await db.insert(snapshots).values({ await db.insert(snapshots).values({
ts, cpuLoad1m: load1, cpuLoad5m: load5, cpuLoad15m: load15, ts,
memTotalMB, memUsedMB, memUsedPct, cpuLoad1m: load1,
diskTotalGB, diskUsedGB, diskUsedPct, cpuLoad5m: load5,
cpuLoad15m: load15,
memTotalMB,
memUsedMB,
memUsedPct,
diskTotalGB,
diskUsedGB,
diskUsedPct,
uptimeSec, uptimeSec,
socketsUsed: tcp.socketsUsed, socketsUsed: tcp.socketsUsed,
tcpInuse: tcp.tcpInuse, tcpInuse: tcp.tcpInuse,
tcpOrphan: tcp.tcpOrphan, tcpOrphan: tcp.tcpOrphan,
tcpTw: tcp.tcpTw, tcpTw: tcp.tcpTw,
tcpAlloc: tcp.tcpAlloc, tcpAlloc: tcp.tcpAlloc,
tcpMemPages: tcp.tcpMemPages, tcpMemPages: tcp.tcpMemPages
}); });
return { return {
cpu: { load1m: load1, load5m: load5, load15m: load15 }, cpu: { load1m: load1, load5m: load5, load15m: load15 },
memory: { totalMB: memTotalMB, usedMB: memUsedMB, usedPct: memUsedPct }, memory: { totalMB: memTotalMB, usedMB: memUsedMB, usedPct: memUsedPct },
disk: { totalGB: diskTotalGB, usedGB: diskUsedGB, usedPct: diskUsedPct }, disk: { totalGB: diskTotalGB, usedGB: diskUsedGB, usedPct: diskUsedPct },
tcp: { socketsUsed: tcp.socketsUsed, inuse: tcp.tcpInuse, orphan: tcp.tcpOrphan, tw: tcp.tcpTw, alloc: tcp.tcpAlloc, memPages: tcp.tcpMemPages }, tcp: { socketsUsed: tcp.socketsUsed, inuse: tcp.tcpInuse, orphan: tcp.tcpOrphan, tw: tcp.tcpTw, alloc: tcp.tcpAlloc, memPages: tcp.tcpMemPages },
uptimeSec, uptimeSec
}; };
} }
export {
compute
};

View File

@ -0,0 +1,17 @@
{
"name": "sense-linux-system-health",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}

310
senses/linux-system-health/pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,310 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@ -0,0 +1,96 @@
import { loadavg, totalmem, freemem, uptime } from "node:os";
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { snapshots } from "./schema.ts";
const SOCKSTAT_PATH = "/proc/net/sockstat";
interface SockstatResult {
socketsUsed: number;
tcpInuse: number;
tcpOrphan: number;
tcpTw: number;
tcpAlloc: number;
tcpMemPages: number;
}
function parseSockstat(content: string): SockstatResult {
let socketsUsed = 0, tcpInuse = 0, tcpOrphan = 0, tcpTw = 0, tcpAlloc = 0, tcpMemPages = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("sockets:")) {
const parts = trimmed.split(/\s+/);
const idx = parts.indexOf("used");
if (idx !== -1 && idx + 1 < parts.length) {
socketsUsed = Number.parseInt(parts[idx + 1], 10) || 0;
}
} else if (trimmed.startsWith("TCP:")) {
const parts = trimmed.split(/\s+/);
const map: Record<string, number> = {};
for (let i = 1; i + 1 < parts.length; i += 2) {
map[parts[i]] = Number.parseInt(parts[i + 1], 10) || 0;
}
tcpInuse = map.inuse ?? 0;
tcpOrphan = map.orphan ?? 0;
tcpTw = map.tw ?? 0;
tcpAlloc = map.alloc ?? 0;
tcpMemPages = map.mem ?? 0;
}
}
return { socketsUsed, tcpInuse, tcpOrphan, tcpTw, tcpAlloc, tcpMemPages };
}
export async function compute(db: LibSQLDatabase, _peers: unknown) {
const [load1, load5, load15] = loadavg();
const memTotal = totalmem();
const memFree = freemem();
const memUsed = memTotal - memFree;
const memTotalMB = Math.round(memTotal / 1024 / 1024);
const memUsedMB = Math.round(memUsed / 1024 / 1024);
const memUsedPct = Math.round((memUsed / memTotal) * 10000) / 100;
let diskTotalGB = 0, diskUsedGB = 0, diskUsedPct = 0;
try {
const df = execSync("df -B1 / | tail -1", { encoding: "utf-8" }).trim();
const parts = df.split(/\s+/);
const total = Number(parts[1]);
const used = Number(parts[2]);
diskTotalGB = Math.round(total / 1024 / 1024 / 1024 * 100) / 100;
diskUsedGB = Math.round(used / 1024 / 1024 / 1024 * 100) / 100;
diskUsedPct = total > 0 ? Math.round((used / total) * 10000) / 100 : 0;
} catch {}
let tcp: SockstatResult = { socketsUsed: 0, tcpInuse: 0, tcpOrphan: 0, tcpTw: 0, tcpAlloc: 0, tcpMemPages: 0 };
try {
const content = await readFile(SOCKSTAT_PATH, "utf8");
tcp = parseSockstat(content);
} catch {}
const ts = Date.now();
const uptimeSec = Math.round(uptime());
await db.insert(snapshots).values({
ts, cpuLoad1m: load1, cpuLoad5m: load5, cpuLoad15m: load15,
memTotalMB, memUsedMB, memUsedPct,
diskTotalGB, diskUsedGB, diskUsedPct,
uptimeSec,
socketsUsed: tcp.socketsUsed,
tcpInuse: tcp.tcpInuse,
tcpOrphan: tcp.tcpOrphan,
tcpTw: tcp.tcpTw,
tcpAlloc: tcp.tcpAlloc,
tcpMemPages: tcp.tcpMemPages,
});
return {
cpu: { load1m: load1, load5m: load5, load15m: load15 },
memory: { totalMB: memTotalMB, usedMB: memUsedMB, usedPct: memUsedPct },
disk: { totalGB: diskTotalGB, usedGB: diskUsedGB, usedPct: diskUsedPct },
tcp: { socketsUsed: tcp.socketsUsed, inuse: tcp.tcpInuse, orphan: tcp.tcpOrphan, tw: tcp.tcpTw, alloc: tcp.tcpAlloc, memPages: tcp.tcpMemPages },
uptimeSec,
};
}

View File

@ -1,10 +1,19 @@
import { workerProcessMetrics } from "./schema.ts"; // src/schema.ts
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
var workerProcessMetrics = sqliteTable("worker_process_metrics", {
ts: integer("ts").primaryKey(),
pid: integer("pid").notNull(),
uptimeSec: real("uptime_sec").notNull(),
heapUsedMB: real("heap_used_mb").notNull(),
rssMB: real("rss_mb").notNull(),
externalMB: real("external_mb").notNull()
});
// src/index.ts
function round2(n) { function round2(n) {
return Math.round(n * 100) / 100; return Math.round(n * 100) / 100;
} }
async function compute(db, _peers) {
export async function compute(db, _peers) {
const ts = Date.now(); const ts = Date.now();
const pid = process.pid; const pid = process.pid;
const uptimeSec = process.uptime(); const uptimeSec = process.uptime();
@ -12,24 +21,24 @@ export async function compute(db, _peers) {
const heapUsedMB = round2(m.heapUsed / 1024 / 1024); const heapUsedMB = round2(m.heapUsed / 1024 / 1024);
const rssMB = round2(m.rss / 1024 / 1024); const rssMB = round2(m.rss / 1024 / 1024);
const externalMB = round2(m.external / 1024 / 1024); const externalMB = round2(m.external / 1024 / 1024);
const row = { const row = {
ts, ts,
pid, pid,
uptimeSec, uptimeSec,
heapUsedMB, heapUsedMB,
rssMB, rssMB,
externalMB, externalMB
}; };
await db.insert(workerProcessMetrics).values(row); await db.insert(workerProcessMetrics).values(row);
return { return {
ts: row.ts, ts: row.ts,
pid: row.pid, pid: row.pid,
uptimeSec: row.uptimeSec, uptimeSec: row.uptimeSec,
heapUsedMB: row.heapUsedMB, heapUsedMB: row.heapUsedMB,
rssMB: row.rssMB, rssMB: row.rssMB,
externalMB: row.externalMB, externalMB: row.externalMB
}; };
} }
export {
compute
};

View File

@ -0,0 +1,17 @@
{
"name": "sense-worker-process-metrics",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}

View File

@ -0,0 +1,310 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@ -0,0 +1,36 @@
import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { workerProcessMetrics } from "./schema.ts";
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
export async function compute(db: LibSQLDatabase, _peers: unknown) {
const ts = Date.now();
const pid = process.pid;
const uptimeSec = process.uptime();
const m = process.memoryUsage();
const heapUsedMB = round2(m.heapUsed / 1024 / 1024);
const rssMB = round2(m.rss / 1024 / 1024);
const externalMB = round2(m.external / 1024 / 1024);
const row = {
ts,
pid,
uptimeSec,
heapUsedMB,
rssMB,
externalMB,
};
await db.insert(workerProcessMetrics).values(row);
return {
ts: row.ts,
pid: row.pid,
uptimeSec: row.uptimeSec,
heapUsedMB: row.heapUsedMB,
rssMB: row.rssMB,
externalMB: row.externalMB,
};
}

View File

@ -3,5 +3,41 @@ export function coderPrompt({ threadId }: { threadId: string }): string {
Read the nerve-dev skill for sense file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\` Read the nerve-dev skill for sense file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Implement the sense following the patterns from existing senses and the skill guide. Implement the sense following the patterns from existing senses and the skill guide.
Create all required files and update nerve.yaml.`;
File structure for each sense:
- \`senses/<name>/src/index.ts\` — TypeScript source with proper types; import schema as \`./schema.ts\`
- \`senses/<name>/src/schema.ts\` — Drizzle schema (TypeScript)
- \`senses/<name>/migrations/\` — Drizzle migration files (at sense root, not inside src/)
- \`senses/<name>/package.json\` — with esbuild build script (see below)
- \`senses/<name>/index.js\` — bundled output generated by \`pnpm build\` (do NOT edit by hand)
package.json template for each sense:
\`\`\`json
{
"name": "sense-<name>",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
\`\`\`
After creating all files, run inside the sense directory:
\`\`\`
pnpm install --no-cache && pnpm build
\`\`\`
This generates the bundled \`index.js\` at the sense root that the daemon loads.
Then update nerve.yaml and run any required migrations.`;
} }