From 249378438226a4faaaa7ea14209641a76527c70a Mon Sep 17 00:00:00 2001 From: alloevil <12680612+alloevil@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:31:59 +0800 Subject: [PATCH] feat: read Hermes state.db with node:sqlite, drop better-sqlite3 better-sqlite3 is a native addon: npm install fails on machines without a compiler toolchain / prebuilt binary (Windows, Alpine, new Node majors). node:sqlite is built into Node >= 22.13 and covers everything the Hermes reader uses (read-only open, prepare/all/get). engines.node bumped to match. New test/hermes.test.js seeds a real state.db in the temp HOME and covers list / detail / 404 / LIKE-fallback search through the HTTP API; Hermes had no integration coverage before. --- README.md | 2 +- lib/platforms/hermes.js | 28 ++++++--- package-lock.json | 40 +------------ package.json | 5 +- test/hermes.test.js | 123 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 50 deletions(-) create mode 100644 test/hermes.test.js diff --git a/README.md b/README.md index 55c1d8a..b3d1da4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@
-
+
diff --git a/lib/platforms/hermes.js b/lib/platforms/hermes.js
index d774c77..baf6c37 100644
--- a/lib/platforms/hermes.js
+++ b/lib/platforms/hermes.js
@@ -1,6 +1,14 @@
const fs = require('fs');
const path = require('path');
-const Database = require('better-sqlite3');
+// node:sqlite still emits an ExperimentalWarning on 22.x; it is the only warning
+// we swallow, and stderr is the operator's console for this server.
+const originalEmitWarning = process.emitWarning;
+process.emitWarning = (warning, ...rest) => {
+ const text = typeof warning === 'string' ? warning : warning?.message || '';
+ if (text.includes('SQLite is an experimental feature')) return;
+ return originalEmitWarning.call(process, warning, ...rest);
+};
+const { DatabaseSync } = require('node:sqlite');
const { HERMES_DIR } = require('../config');
const { makeMessage } = require('./shared');
@@ -9,24 +17,26 @@ function getHermesDbPath(dir) {
return path.join(base, 'state.db');
}
+// node:sqlite (Node >= 22.13): no native build step, so `npm i` never fails on a
+// missing compiler toolchain. Read-only open throws if the file is missing, which
+// is the fileMustExist behaviour the callers rely on. Hermes writes in WAL mode;
+// a read-only connection follows the writer's journal mode, so no pragma needed.
+// Differences from better-sqlite3 that callers here already tolerate:
+// `.get()` yields undefined (not null) for no row; rows are null-prototype objects.
function openHermesDb(dir) {
const dbPath = getHermesDbPath(dir);
if (!fs.existsSync(dbPath)) return null;
try {
- const db = new Database(dbPath, { readonly: true, fileMustExist: true });
- db.pragma('journal_mode = WAL');
- return db;
+ return new DatabaseSync(dbPath, { readOnly: true });
} catch {
return null;
}
}
-// Open the db for the SSE tail endpoint: no fileMustExist, errors propagate
-// to the caller (the watch route reports them as SSE error events).
+// Open the db for the SSE tail endpoint: errors propagate to the caller
+// (the watch route reports them as SSE error events).
function openHermesDbForWatch(dbPath) {
- const db = new Database(dbPath, { readonly: true });
- db.pragma('journal_mode = WAL');
- return db;
+ return new DatabaseSync(dbPath, { readOnly: true });
}
function unixToIso(ts) {
diff --git a/package-lock.json b/package-lock.json
index cedc0d9..e1210fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,14 @@
{
"name": "@alloevil/agent-xray",
- "version": "1.15.0",
+ "version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@alloevil/agent-xray",
- "version": "1.15.0",
+ "version": "1.16.0",
"license": "MIT",
"dependencies": {
- "better-sqlite3": "^13.0.3",
"express": "^4.21.2"
},
"bin": {
@@ -20,7 +19,7 @@
"@biomejs/biome": "^2.5.7"
},
"engines": {
- "node": ">=18.17"
+ "node": ">=22.13"
}
},
"node_modules/@biomejs/biome": {
@@ -92,9 +91,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -112,9 +108,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -132,9 +125,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -152,9 +142,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -217,18 +204,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
- "node_modules/better-sqlite3": {
- "version": "13.0.3",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
- "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
- "license": "MIT",
- "dependencies": {
- "node-addon-api": "^8.0.0"
- },
- "engines": {
- "node": ">=22"
- }
- },
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -724,15 +699,6 @@
"node": ">= 0.6"
}
},
- "node_modules/node-addon-api": {
- "version": "8.9.2",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
- "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
- "license": "MIT",
- "engines": {
- "node": "^18 || ^20 || >= 21"
- }
- },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index d816ca4..b6bc57a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@alloevil/agent-xray",
- "version": "1.15.0",
+ "version": "1.16.0",
"description": "Web dashboard for viewing AI agent session logs — supports OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness, and Gemini CLI",
"main": "server.js",
"bin": {
@@ -51,11 +51,10 @@
},
"license": "MIT",
"dependencies": {
- "better-sqlite3": "^13.0.3",
"express": "^4.21.2"
},
"engines": {
- "node": ">=18.17"
+ "node": ">=22.13"
},
"devDependencies": {
"@biomejs/biome": "^2.5.7"
diff --git a/test/hermes.test.js b/test/hermes.test.js
new file mode 100644
index 0000000..f655e6a
--- /dev/null
+++ b/test/hermes.test.js
@@ -0,0 +1,123 @@
+// Hermes is the one SQLite-backed platform. The reader uses node:sqlite (built in
+// since Node 22.13) instead of a native addon, so the test builds a real state.db
+// in the throwaway HOME and drives the public HTTP surface against it: list,
+// detail with normalized roles, and search (LIKE fallback — no FTS table here).
+
+const { describe, it, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const fsp = require('node:fs/promises');
+const path = require('node:path');
+const { DatabaseSync } = require('node:sqlite');
+const { startServer, getJson } = require('./helpers.js');
+
+const S1 = 'hermes-session-0001';
+const S2 = 'hermes-session-0002';
+const T0 = 1737000000; // 2025-01-16T04:00:00Z
+
+function seedHermesDb(home) {
+ const dir = path.join(home, '.hermes');
+ return fsp.mkdir(dir, { recursive: true }).then(() => {
+ const db = new DatabaseSync(path.join(dir, 'state.db'));
+ db.exec(`
+ CREATE TABLE sessions (
+ id TEXT PRIMARY KEY, source TEXT, user_id TEXT, model TEXT, title TEXT,
+ started_at INTEGER, ended_at INTEGER, message_count INTEGER, tool_call_count INTEGER,
+ input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER,
+ reasoning_tokens INTEGER, estimated_cost_usd REAL, actual_cost_usd REAL, parent_session_id TEXT
+ );
+ CREATE TABLE messages (
+ id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, tool_calls TEXT,
+ tool_call_id TEXT, tool_name TEXT, timestamp INTEGER, token_count INTEGER, reasoning TEXT
+ );
+ `);
+ const ins = db.prepare(
+ 'INSERT INTO sessions (id, source, model, title, started_at, ended_at, message_count, tool_call_count, input_tokens, output_tokens, estimated_cost_usd) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
+ );
+ ins.run(S1, 'cli', 'hermes-4', 'first', T0, T0 + 60, 4, 1, 120, 40, 0.0031);
+ ins.run(S2, 'cli', 'hermes-4', 'second', T0 + 3600, null, 1, 0, 10, 0, null);
+ const msg = db.prepare(
+ 'INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, tool_name, timestamp, token_count, reasoning) VALUES (?,?,?,?,?,?,?,?,?)'
+ );
+ msg.run(S1, 'user', 'fixture: hermes-needle please list files', null, null, null, T0 + 1, null, null);
+ msg.run(
+ S1,
+ 'assistant',
+ 'Listing.',
+ JSON.stringify([{ id: 'call-h-1', function: { name: 'terminal', arguments: '{"command":"ls"}' } }]),
+ null,
+ null,
+ T0 + 2,
+ 37,
+ 'need to run ls'
+ );
+ msg.run(S1, 'tool', 'a.txt\nb.txt', null, 'call-h-1', 'terminal', T0 + 3, null, null);
+ msg.run(S1, 'assistant', 'Two files.', null, null, null, T0 + 4, 9, null);
+ msg.run(S2, 'user', 'unrelated question', null, null, null, T0 + 3601, null, null);
+ db.close();
+ });
+}
+
+describe('sessions: hermes (node:sqlite)', () => {
+ let srv;
+ before(async () => {
+ // The reader opens state.db per request, so seeding after boot is fine.
+ srv = await startServer();
+ await seedHermesDb(srv.home);
+ });
+ after(async () => {
+ await srv.stop();
+ });
+
+ it('lists sessions by last activity with per-session stats', async () => {
+ const sessions = await getJson(srv.base, '/api/hermes/sessions');
+ assert.deepEqual(
+ sessions.map((s) => s.id),
+ [S2, S1]
+ );
+ const s1 = sessions[1];
+ assert.equal(s1.timestamp, new Date(T0 * 1000).toISOString());
+ assert.equal(s1.userCount, 1);
+ assert.equal(s1.assistantCount, 2);
+ assert.equal(s1.toolResultCount, 1);
+ assert.equal(s1.toolCallCount, 1);
+ assert.deepEqual(s1.topTools, [{ name: 'terminal', count: 1 }]);
+ assert.ok(s1.firstUserMessage.startsWith('fixture: hermes-needle'));
+ assert.equal(s1.status, 'archived');
+ assert.equal(sessions[0].status, 'active');
+ assert.equal(s1.estimatedCost, 0.0031);
+ });
+
+ it('serves a session detail with normalized roles and reasoning', async () => {
+ const { session, messages } = await getJson(srv.base, `/api/hermes/sessions/${S1}`);
+ assert.equal(session.id, S1);
+ assert.equal(session.model, 'hermes-4');
+ assert.equal(session.inputTokens, 120);
+ assert.deepEqual(
+ messages.map((m) => m.role),
+ ['user', 'assistant', 'toolResult', 'assistant']
+ );
+ // Hermes stores tool_calls as JSON on the assistant row; they surface as toolCall content blocks
+ const call = messages[1].content.find((c) => c.type === 'toolCall');
+ assert.equal(call.name, 'terminal');
+ assert.equal(call.id, 'call-h-1');
+ assert.deepEqual(call.arguments, { command: 'ls' });
+ assert.equal(messages[1].reasoning, 'need to run ls');
+ assert.deepEqual(messages[1].usage, { total_tokens: 37 });
+ assert.equal(messages[2].toolCallId, 'call-h-1');
+ assert.equal(messages[2].toolName, 'terminal');
+ });
+
+ it('404s an unknown session id', async () => {
+ const res = await fetch(`${srv.base}/api/hermes/sessions/does-not-exist`);
+ assert.equal(res.status, 404);
+ });
+
+ it('search falls back to LIKE when there is no FTS table', async () => {
+ const results = await getJson(srv.base, '/api/search?q=hermes-needle&platform=hermes');
+ assert.equal(results.length, 1);
+ assert.equal(results[0].sessionId, S1);
+ assert.equal(results[0].platform, 'hermes');
+ assert.equal(results[0].matches[0].role, 'user');
+ assert.ok(results[0].matches[0].snippet.includes('hermes-needle'));
+ });
+});