* 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>
137 lines
3.6 KiB
TypeScript
137 lines
3.6 KiB
TypeScript
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
|
|
}
|