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