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
+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 }
}