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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user