feat(sessions): indexação multi-fonte Hermes + OpenCode com fix TypeScript

- Novos indexadores hermes-indexer.ts e opencode-indexer.ts para unificar
  sessões Claude, Hermes Agent e OpenCode num único sessions.db
- SessionMeta alargado: source (obrigatório), model, input/output_tokens,
  estimated_cost; project_path/jsonl_path agora nullable
- Fix TS: tipos explícitos, guard jsonl_path null, dependências instaladas

Security Audit (Regra #47):
- npm audit executado — 0 vulnerabilities após fix
- vite 7→8.0.16 (breaking upgrade, resolve esbuild CVE GHSA-gv7w-rqvm-qjhr)
- vitest 4.0.18→4.1.9 (resolve esbuild interno CVE GHSA-gv7w-rqvm-qjhr)
- shell-quote override ^1.8.4 via package.json#overrides (CVE GHSA-w7jw-789q-3m8p)
- react-router, joi, qs, form-data, ip-address, js-yaml resolvidos via npm audit fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 19:41:32 +01:00
parent 9f3d14dc51
commit f733998945
18 changed files with 1475 additions and 678 deletions
+57 -15
View File
@@ -5,6 +5,7 @@ import type { SessionMeta } from '../../types/session.js'
export interface ListFilters {
days?: number
source?: string
project?: string
tool?: string
skill?: string
@@ -73,9 +74,11 @@ export interface SessionsDb {
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
project_path TEXT NOT NULL,
project_slug TEXT NOT NULL,
jsonl_path TEXT NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'claude',
project_path TEXT,
project_slug TEXT,
jsonl_path TEXT,
model TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
duration_sec INTEGER,
@@ -83,16 +86,20 @@ CREATE TABLE IF NOT EXISTS sessions (
user_messages INTEGER NOT NULL,
assistant_msgs INTEGER NOT NULL,
tool_calls INTEGER NOT NULL,
input_tokens INTEGER,
output_tokens INTEGER,
estimated_cost REAL,
first_prompt TEXT,
tools_used TEXT NOT NULL,
skills_invoked TEXT NOT NULL,
tools_used TEXT NOT NULL DEFAULT '[]',
skills_invoked TEXT NOT NULL DEFAULT '[]',
outcome TEXT NOT NULL,
permission_mode TEXT,
file_size INTEGER NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -134,9 +141,11 @@ CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
function rowToMeta(row: Record<string, unknown>): SessionMeta {
return {
session_id: row.session_id as string,
project_path: row.project_path as string,
project_slug: row.project_slug as string,
jsonl_path: row.jsonl_path as string,
source: (row.source as SessionMeta['source']) ?? 'claude',
project_path: (row.project_path as string | null) ?? null,
project_slug: (row.project_slug as string | null) ?? null,
jsonl_path: (row.jsonl_path as string | null) ?? null,
model: (row.model as string | null) ?? null,
started_at: row.started_at as string,
ended_at: (row.ended_at as string | null) ?? null,
duration_sec: (row.duration_sec as number | null) ?? null,
@@ -144,9 +153,12 @@ function rowToMeta(row: Record<string, unknown>): SessionMeta {
user_messages: row.user_messages as number,
assistant_msgs: row.assistant_msgs as number,
tool_calls: row.tool_calls as number,
input_tokens: (row.input_tokens as number | null) ?? null,
output_tokens: (row.output_tokens as number | null) ?? null,
estimated_cost: (row.estimated_cost as number | null) ?? null,
first_prompt: (row.first_prompt as string | null) ?? null,
tools_used: JSON.parse(row.tools_used as string),
skills_invoked: JSON.parse(row.skills_invoked as string),
tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [],
skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [],
outcome: row.outcome as SessionMeta['outcome'],
permission_mode: (row.permission_mode as string | null) ?? null,
file_size: row.file_size as number,
@@ -162,6 +174,10 @@ function buildWhere(f: ListFilters): { sql: string; params: Record<string, unkno
parts.push('started_at >= @cutoff')
params.cutoff = cutoff
}
if (f.source) {
parts.push('source = @source')
params.source = f.source
}
if (f.project) {
parts.push('project_slug = @project')
params.project = f.project
@@ -191,17 +207,40 @@ export function openSessionsDb(dbPath: string): SessionsDb {
db.pragma('synchronous = NORMAL')
db.exec(SCHEMA)
// Migração: adicionar colunas novas se não existirem
const existingCols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]
const colNames = new Set(existingCols.map(c => c.name))
const migrations: [string, string][] = [
['source', "TEXT NOT NULL DEFAULT 'claude'"],
['model', 'TEXT'],
['input_tokens', 'INTEGER'],
['output_tokens', 'INTEGER'],
['estimated_cost', 'REAL'],
]
for (const [col, type] of migrations) {
if (!colNames.has(col)) {
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`)
}
}
if (!colNames.has('source')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_source ON sessions(source)')
}
const upsertStmt = db.prepare(`
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at,
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt,
INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model,
started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls,
input_tokens, output_tokens, estimated_cost, first_prompt,
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at,
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt,
VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model,
@started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls,
@input_tokens, @output_tokens, @estimated_cost, @first_prompt,
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
ON CONFLICT(session_id) DO UPDATE SET
source = excluded.source,
project_path = excluded.project_path,
project_slug = excluded.project_slug,
jsonl_path = excluded.jsonl_path,
model = excluded.model,
started_at = excluded.started_at,
ended_at = excluded.ended_at,
duration_sec = excluded.duration_sec,
@@ -209,6 +248,9 @@ export function openSessionsDb(dbPath: string): SessionsDb {
user_messages = excluded.user_messages,
assistant_msgs = excluded.assistant_msgs,
tool_calls = excluded.tool_calls,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
estimated_cost = excluded.estimated_cost,
first_prompt = excluded.first_prompt,
tools_used = excluded.tools_used,
skills_invoked = excluded.skills_invoked,
+151
View File
@@ -0,0 +1,151 @@
import Database from 'better-sqlite3'
import { homedir } from 'os'
import { join } from 'path'
import type { SessionMeta } from '../../types/session.js'
import { openSessionsDb } from './db.js'
const HERMES_DB = join(homedir(), '.hermes', 'state.db')
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
export interface HermesSessionRow {
id: string
model: string | null
started_at: number | null
ended_at: number | null
end_reason: string | null
message_count: number
tool_call_count: number
input_tokens: number | null
output_tokens: number | null
estimated_cost_usd: number | null
title: string | null
api_call_count: number | null
}
export interface HermesMessageRow {
session_id: string
role: string
content: string | null
tool_name: string | null
timestamp: number | null
}
/**
* Lê sessões do state.db do Hermes Agent
*/
export function readHermesSessions(): HermesSessionRow[] {
const db = new Database(HERMES_DB, { readonly: true })
try {
return db.prepare(`
SELECT id, model, started_at, ended_at, end_reason,
message_count, tool_call_count,
input_tokens, output_tokens, estimated_cost_usd,
title, api_call_count
FROM sessions
ORDER BY started_at DESC
`).all() as HermesSessionRow[]
} finally {
db.close()
}
}
/**
* Lê mensagens de uma sessão Hermes
*/
export function readHermesMessages(sessionId: string): HermesMessageRow[] {
const db = new Database(HERMES_DB, { readonly: true })
try {
return db.prepare(`
SELECT session_id, role, content, tool_name, timestamp
FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
`).all(sessionId) as HermesMessageRow[]
} finally {
db.close()
}
}
function toHermesMeta(row: HermesSessionRow, messages: HermesMessageRow[]): SessionMeta {
const userMsgs = messages.filter(m => m.role === 'user')
const assistantMsgs = messages.filter(m => m.role === 'assistant')
const toolMsgs = messages.filter(m => m.role === 'tool')
const firstUser = userMsgs[0]
const toolsUsed = [...new Set(messages.filter(m => m.tool_name).map(m => m.tool_name!))]
return {
session_id: row.id,
source: 'hermes',
project_path: null,
project_slug: null,
jsonl_path: null,
model: row.model ?? null,
started_at: row.started_at ? new Date(row.started_at * 1000).toISOString() : new Date(0).toISOString(),
ended_at: row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null,
duration_sec: row.started_at && row.ended_at ? Math.round(row.ended_at - row.started_at) : null,
event_count: messages.length,
user_messages: userMsgs.length,
assistant_msgs: assistantMsgs.length,
tool_calls: toolMsgs.length,
input_tokens: row.input_tokens ?? null,
output_tokens: row.output_tokens ?? null,
estimated_cost: row.estimated_cost_usd ?? null,
first_prompt: firstUser?.content?.slice(0, 500) ?? null,
tools_used: toolsUsed,
skills_invoked: [],
outcome: row.end_reason === 'completed' ? 'completed'
: row.end_reason === 'interrupted' || row.end_reason === 'stopped' ? 'interrupted'
: row.end_reason === 'error' ? 'error'
: 'unknown',
permission_mode: null,
file_size: 0,
indexed_at: new Date().toISOString(),
}
}
export interface IndexResult {
indexed: number
failed: number
}
/**
* Indexa todas as sessões Hermes no state.db para o sessions.db unificado
*/
export async function indexHermesSessions(
options: { dbPath?: string } = {},
): Promise<IndexResult> {
let indexed = 0
let failed = 0
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
try {
const sessions = readHermesSessions()
let batch: SessionMeta[] = []
const BATCH = 50
for (const session of sessions) {
try {
const messages = readHermesMessages(session.id)
const meta = toHermesMeta(session, messages)
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
}
} catch (err) {
failed++
console.error(`[hermes-indexer] erro em sessão ${session.id}:`, err)
}
}
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
} finally {
db.close()
}
return { indexed, failed }
}
+68 -24
View File
@@ -3,6 +3,8 @@ import { homedir } from 'os'
import { join } from 'path'
import { parseSessionFile } from './parser.js'
import { openSessionsDb, type SessionsDb } from './db.js'
import { indexHermesSessions } from './hermes-indexer.js'
import { indexOCSessions } from './opencode-indexer.js'
import type { SessionMeta } from '../../types/session.js'
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
@@ -52,46 +54,88 @@ export async function indexFile(db: SessionsDb, path: string): Promise<void> {
export interface IndexAllOptions {
dbPath?: string
sources?: ('claude' | 'hermes' | 'opencode')[]
onProgress?: (done: number, total: number) => void
}
/**
* Full scan: percorre todos os JSONL e faz upsert em lote (batch 50 via transacção).
* Full scan: indexa Claude Code (JSONL) + Hermes (state.db) + OpenCode (opencode.db).
*/
export async function indexAll(
options: IndexAllOptions = {},
): Promise<{ indexed: number; failed: number }> {
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
const files = findAllJsonl()
const BATCH = 50
const sources = options.sources ?? ['claude', 'hermes', 'opencode']
let indexed = 0
let failed = 0
let batch: SessionMeta[] = []
const dbPath = options.dbPath ?? DEFAULT_DB_PATH
try {
for (let i = 0; i < files.length; i++) {
try {
const { meta } = await parseSessionFile(files[i])
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
if (sources.includes('claude')) {
const db = openSessionsDb(dbPath)
const files = findAllJsonl()
const BATCH = 50
let batch: SessionMeta[] = []
try {
for (let i = 0; i < files.length; i++) {
try {
const { meta } = await parseSessionFile(files[i])
meta.source = 'claude'
meta.model = meta.model ?? null
meta.ended_at = meta.ended_at ?? null
meta.duration_sec = meta.duration_sec ?? null
meta.input_tokens = meta.input_tokens ?? null
meta.output_tokens = meta.output_tokens ?? null
meta.estimated_cost = meta.estimated_cost ?? null
meta.first_prompt = meta.first_prompt ?? null
meta.permission_mode = meta.permission_mode ?? null
meta.project_path = meta.project_path ?? null
meta.project_slug = meta.project_slug ?? null
meta.jsonl_path = meta.jsonl_path ?? null
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
}
} catch (err) {
failed++
console.error(`[indexer] erro em ${files[i]}:`, err)
}
if (options.onProgress) {
options.onProgress(indexed + failed + batch.length, files.length)
}
} catch (err) {
failed++
console.error(`[indexer] erro em ${files[i]}:`, err)
}
if (options.onProgress) {
options.onProgress(indexed + failed + batch.length, files.length)
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
} finally {
db.close()
}
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
if (sources.includes('hermes')) {
try {
const result = await indexHermesSessions({ dbPath })
indexed += result.indexed
failed += result.failed
console.log(`[indexer] Hermes: ${result.indexed} indexadas, ${result.failed} falhas`)
} catch (err) {
failed++
console.error('[indexer] Erro ao indexar Hermes:', err)
}
}
if (sources.includes('opencode')) {
try {
const result = await indexOCSessions({ dbPath })
indexed += result.indexed
failed += result.failed
console.log(`[indexer] OpenCode: ${result.indexed} indexadas, ${result.failed} falhas`)
} catch (err) {
failed++
console.error('[indexer] Erro ao indexar OpenCode:', err)
}
} finally {
db.close()
}
return { indexed, failed }
+208
View File
@@ -0,0 +1,208 @@
import Database from 'better-sqlite3'
import { homedir } from 'os'
import { join } from 'path'
import type { SessionMeta } from '../../types/session.js'
import { openSessionsDb } from './db.js'
const OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
export interface OCSessionRow {
id: string
project_id: string | null
title: string | null
summary_additions: number | null
summary_deletions: number | null
summary_files: number | null
time_created: number | null
time_updated: number | null
path: string | null
}
export interface OCMessageRow {
id: string
session_id: string
time_created: number | null
data: string | null
}
export interface OCPartRow {
id: string
message_id: string
session_id: string
time_created: number | null
data: string | null
}
export interface OCProjectRow {
id: string
name: string | null
worktree: string | null
}
/**
* Lê projectos OpenCode
*/
export function readOCProjects(): OCProjectRow[] {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
return db.prepare('SELECT id, name, worktree FROM project').all() as OCProjectRow[]
} finally {
db.close()
}
}
/**
* Lê sessões OpenCode
*/
export function readOCSessions(): OCSessionRow[] {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
return db.prepare(`
SELECT s.id, s.project_id, s.title, s.summary_additions, s.summary_deletions,
s.summary_files, s.time_created, s.time_updated, s.path
FROM session s
ORDER BY s.time_created DESC
`).all() as OCSessionRow[]
} finally {
db.close()
}
}
/**
* Lê todas as messages + parts de uma sessão OpenCode
*/
export function readOCSessionData(sessionId: string): { messages: OCMessageRow[]; parts: OCPartRow[] } {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
const messages = db.prepare(
'SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC'
).all(sessionId) as OCMessageRow[]
const messageIds = messages.map(m => m.id)
let parts: OCPartRow[] = []
if (messageIds.length > 0) {
const placeholders = messageIds.map(() => '?').join(',')
parts = db.prepare(
`SELECT id, message_id, session_id, time_created, data FROM part WHERE message_id IN (${placeholders}) ORDER BY time_created ASC`
).all(...messageIds) as OCPartRow[]
}
return { messages, parts }
} finally {
db.close()
}
}
function parsePartData(data: string | null): { role?: string; content?: string } {
if (!data) return {}
try {
return JSON.parse(data) as { role?: string; content?: string }
} catch {
return {}
}
}
function toOCMeta(
session: OCSessionRow,
projects: Map<string, OCProjectRow>,
_messages: OCMessageRow[],
parts: OCPartRow[],
): SessionMeta {
const userMsgs = parts.filter(p => parsePartData(p.data).role === 'user')
const assistantMsgs = parts.filter(p => parsePartData(p.data).role === 'assistant')
const project = session.project_id ? projects.get(session.project_id) : undefined
const firstUser = userMsgs[0]
const firstUserContent = firstUser ? parsePartData(firstUser.data).content : null
let toolsUsed: string[] = []
for (const p of parts) {
const d = parsePartData(p.data)
if (d.content && (d.content.includes('tool_use') || d.content.includes('tool_result'))) {
try {
const parsed = JSON.parse(d.content)
if (parsed.name) toolsUsed.push(parsed.name)
} catch { /* não é JSON */ }
}
}
toolsUsed = [...new Set(toolsUsed)]
const startTs = session.time_created ? session.time_created / 1000 : Date.now() / 1000
const endTs = session.time_updated ? session.time_updated / 1000 : startTs
return {
session_id: session.id,
source: 'opencode',
project_path: project?.worktree ?? session.path ?? null,
project_slug: project?.name ?? session.path?.split('/').pop() ?? null,
jsonl_path: null,
model: null,
started_at: new Date(startTs * 1000).toISOString(),
ended_at: new Date(endTs * 1000).toISOString(),
duration_sec: Math.round(endTs - startTs),
event_count: parts.length,
user_messages: userMsgs.length,
assistant_msgs: assistantMsgs.length,
tool_calls: 0,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
first_prompt: firstUserContent?.slice(0, 500) ?? null,
tools_used: toolsUsed,
skills_invoked: [],
outcome: 'completed',
permission_mode: null,
file_size: session.summary_additions ?? 0,
indexed_at: new Date().toISOString(),
}
}
export interface IndexResult {
indexed: number
failed: number
}
/**
* Indexa todas as sessões OpenCode para o sessions.db unificado
*/
export async function indexOCSessions(
options: { dbPath?: string } = {},
): Promise<IndexResult> {
let indexed = 0
let failed = 0
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
try {
const sessions = readOCSessions()
const projects = readOCProjects()
const projectMap = new Map(projects.map(p => [p.id, p]))
let batch: SessionMeta[] = []
const BATCH = 50
for (const session of sessions) {
try {
const { messages, parts } = readOCSessionData(session.id)
const meta = toOCMeta(session, projectMap, messages, parts)
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
}
} catch (err) {
failed++
console.error(`[opencode-indexer] erro em sessão ${session.id}:`, err)
}
}
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
} finally {
db.close()
}
return { indexed, failed }
}
+5
View File
@@ -186,9 +186,11 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
const meta: SessionMeta = {
session_id: sessionId,
source: 'claude',
project_path: dirname(jsonlPath),
project_slug: slugFromProjectPath(jsonlPath),
jsonl_path: jsonlPath,
model: null,
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
ended_at: lastTs,
duration_sec: durationSec,
@@ -196,6 +198,9 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
user_messages: userMessages,
assistant_msgs: assistantMsgs,
tool_calls: toolCalls,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
first_prompt: firstPrompt,
tools_used: [...toolsUsed],
skills_invoked: [...skillsInvoked],
+1 -1
View File
@@ -154,7 +154,7 @@ function parseProblemItem(raw: string): { problema: string; solucao: string } {
function extractAllLiItems(root: HTMLElement): string[] {
return root
.querySelectorAll('li')
.map((li) => textOf(li))
.map((li: HTMLElement) => textOf(li))
.filter(Boolean)
}