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 @@

- Node.js + Node.js Tests OpenSSF Scorecard Release 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')); + }); +});