Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</p>

<p align="center">
<img src="https://img.shields.io/badge/Node.js-18+-339933?style=flat&logo=node.js&logoColor=white" alt="Node.js" />
<img src="https://img.shields.io/badge/Node.js-22.13+-339933?style=flat&logo=node.js&logoColor=white" alt="Node.js" />
<a href="https://github.com/alloevil/AgentXRay/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/alloevil/AgentXRay/test.yml?style=flat&logo=githubactions&logoColor=white&label=tests" alt="Tests" /></a>
<a href="https://scorecard.dev/viewer/?uri=github.com/alloevil/AgentXRay"><img src="https://api.scorecard.dev/projects/github.com/alloevil/AgentXRay/badge" alt="OpenSSF Scorecard" /></a>
<a href="https://github.com/alloevil/AgentXRay/releases/latest"><img src="https://img.shields.io/github/v/release/alloevil/AgentXRay?style=flat&logo=github&color=blue" alt="Release" /></a>
Expand Down
28 changes: 19 additions & 9 deletions lib/platforms/hermes.js
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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) {
Expand Down
40 changes: 3 additions & 37 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
123 changes: 123 additions & 0 deletions test/hermes.test.js
Original file line number Diff line number Diff line change
@@ -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'));
});
});