feat: add token usage tracking, context display, and dynamic context length (#132)

* fix: specify TS_NODE_PROJECT for dev:server script

ts-node/register resolves tsconfig from the entry file upward,
finding the root solution-style tsconfig.json (no compilerOptions).
This causes target to default to ES3, breaking MapIterator spread
syntax (TS2802). Set TS_NODE_PROJECT env var to point to the server
tsconfig which targets ES2024.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add token usage tracking, context display, and dynamic context length

- Intercept SSE proxy to capture run.completed events and persist token
  usage (input_tokens, output_tokens) per session to SQLite/JSON store
- Display context usage bar in ChatInput showing used/total/remaining tokens
- Resolve actual context length from Hermes models_dev_cache.json based
  on the active profile's default model (fallback 200K), with 5min in-memory cache
- Move sessions-db.ts to db/hermes/ for unified database layer
- Add usage store with SQLite + JSON fallback (auto-migration via ensureTable)
- Fix proxy SSE path regex to match rewritten upstream path
- Fix route ordering: /sessions/usage before /sessions/:id to avoid 404
- Fetch per-session usage on session enter instead of batch
- Add unit tests for usage-store, db index, and proxy SSE interception

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-22 16:14:50 +08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ce3bf5f3eb
commit 6f69c69802
26 changed files with 1203 additions and 144 deletions
+136
View File
@@ -0,0 +1,136 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { homedir } from 'os'
const DB_DIR = resolve(homedir(), '.hermes-web-ui')
const DB_PATH = resolve(DB_DIR, 'hermes-web-ui.db')
const JSON_PATH = resolve(DB_DIR, 'hermes-web-ui.json')
// --- SQLite availability check ---
const SQLITE_AVAILABLE = (() => {
const [major, minor] = process.versions.node.split('.').map(Number)
return major > 22 || (major === 22 && minor >= 5)
})()
export function isSqliteAvailable(): boolean {
return SQLITE_AVAILABLE
}
// --- SQLite backend ---
let _db: DatabaseSync | null = null
export function getDb(): DatabaseSync | null {
if (!SQLITE_AVAILABLE) return null
if (!_db) {
mkdirSync(DB_DIR, { recursive: true })
_db = new DatabaseSync(DB_PATH)
_db.exec('PRAGMA journal_mode=WAL')
_db.exec('PRAGMA foreign_keys=ON')
}
return _db
}
/**
* Ensure a table's schema matches the expected definition.
* - Creates the table if it does not exist
* - Adds missing columns (ALTER TABLE ADD COLUMN)
* - Drops extra columns (ALTER TABLE DROP COLUMN, SQLite 3.35+)
*
* No-op when SQLite is not available.
*/
export function ensureTable(tableName: string, schema: Record<string, string>): void {
const db = getDb()
if (!db) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
db.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
const rows = db.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{ name: string }>
const existingCols = new Set(rows.map(r => r.name))
const expectedCols = new Set(Object.keys(schema))
for (const col of expectedCols) {
if (!existingCols.has(col)) {
db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${col}" ${schema[col]}`)
}
}
for (const col of existingCols) {
if (!expectedCols.has(col)) {
db.exec(`ALTER TABLE "${tableName}" DROP COLUMN "${col}"`)
}
}
}
// --- JSON fallback backend ---
type JsonData = Record<string, Record<string, Record<string, any>>>
function readJsonStore(): JsonData {
if (!existsSync(JSON_PATH)) return {}
try {
return JSON.parse(readFileSync(JSON_PATH, 'utf-8'))
} catch {
return {}
}
}
function writeJsonStore(data: JsonData): void {
mkdirSync(DB_DIR, { recursive: true })
writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
/**
* Get a record from the JSON store.
* @param table Table name (namespace)
* @param key Primary key
*/
export function jsonGet(table: string, key: string): Record<string, any> | undefined {
const data = readJsonStore()
return data[table]?.[key]
}
/**
* Set a record in the JSON store.
* @param table Table name (namespace)
* @param key Primary key
* @param value Record data
*/
export function jsonSet(table: string, key: string, value: Record<string, any>): void {
const data = readJsonStore()
if (!data[table]) data[table] = {}
data[table][key] = value
writeJsonStore(data)
}
/**
* Get all records from a table in the JSON store.
*/
export function jsonGetAll(table: string): Record<string, Record<string, any>> {
const data = readJsonStore()
return data[table] || {}
}
/**
* Delete a record from the JSON store.
*/
export function jsonDelete(table: string, key: string): void {
const data = readJsonStore()
if (data[table]) {
delete data[table][key]
writeJsonStore(data)
}
}
/**
* Get the storage path for debugging.
*/
export function getStoragePath(): string {
return SQLITE_AVAILABLE ? DB_PATH : JSON_PATH
}