f733998945
- 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>
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
import { createReadStream, statSync } from 'fs'
|
|
import { createInterface } from 'readline'
|
|
import { basename, dirname } from 'path'
|
|
import type { ParseResult, SessionEvent, SessionEventType, SessionMeta, SessionOutcome } from '../../types/session.js'
|
|
|
|
function slugFromProjectPath(jsonlPath: string): string {
|
|
return basename(dirname(jsonlPath))
|
|
}
|
|
|
|
function detectSkillInvoked(text: string | null): string | null {
|
|
if (!text) return null
|
|
const m = text.match(/Launching skill:\s*([a-zA-Z0-9:_\-\/]+)/)
|
|
return m ? m[1] : null
|
|
}
|
|
|
|
function detectHook(text: string | null): string | null {
|
|
if (!text) return null
|
|
const m = text.match(/(?:Hook|hook)\s+["']?([a-zA-Z0-9_\-\.]+\.sh)["']?/)
|
|
return m ? m[1] : null
|
|
}
|
|
|
|
function extractResultText(r: unknown): string | null {
|
|
if (r == null) return null
|
|
if (typeof r === 'string') return r
|
|
if (Array.isArray(r)) {
|
|
const parts: string[] = []
|
|
for (const p of r) {
|
|
if (p && typeof p === 'object' && 'text' in p && typeof (p as { text: unknown }).text === 'string') {
|
|
parts.push((p as { text: string }).text)
|
|
}
|
|
}
|
|
return parts.length ? parts.join('\n') : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
function extractText(rawMsg: unknown): string | null {
|
|
if (!rawMsg || typeof rawMsg !== 'object') return null
|
|
const msg = rawMsg as { content?: unknown }
|
|
if (typeof msg.content === 'string') return msg.content
|
|
if (!Array.isArray(msg.content)) return null
|
|
const parts: string[] = []
|
|
for (const part of msg.content) {
|
|
if (part && typeof part === 'object' && 'type' in part) {
|
|
const p = part as { type: string; text?: string }
|
|
if (p.type === 'text' && typeof p.text === 'string') parts.push(p.text)
|
|
}
|
|
}
|
|
return parts.length ? parts.join('\n') : null
|
|
}
|
|
|
|
function classifyEventType(raw: Record<string, unknown>): SessionEventType {
|
|
const t = raw.type
|
|
if (typeof t !== 'string') return 'unknown'
|
|
if (t === 'user' || t === 'assistant' || t === 'system' || t === 'attachment' || t === 'permission-mode' || t === 'file-history-snapshot') {
|
|
return t as SessionEventType
|
|
}
|
|
return 'unknown'
|
|
}
|
|
|
|
function deriveOutcome(events: SessionEvent[]): SessionOutcome {
|
|
if (events.length === 0) return 'unknown'
|
|
const last = events[events.length - 1]
|
|
if (last.type !== 'assistant') return 'interrupted'
|
|
const raw = last.raw as { message?: { stop_reason?: string } }
|
|
const stopReason = raw.message?.stop_reason
|
|
if (stopReason === 'error') return 'error'
|
|
if (stopReason === 'end_turn' || stopReason === 'tool_use' || !stopReason) return 'completed'
|
|
return 'interrupted'
|
|
}
|
|
|
|
export async function parseSessionFile(jsonlPath: string): Promise<ParseResult> {
|
|
const stream = createReadStream(jsonlPath, { encoding: 'utf-8' })
|
|
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
|
|
|
const events: SessionEvent[] = []
|
|
let sessionId: string | null = null
|
|
let permissionMode: string | null = null
|
|
let firstPrompt: string | null = null
|
|
let userMessages = 0
|
|
let assistantMsgs = 0
|
|
let toolCalls = 0
|
|
const toolsUsed = new Set<string>()
|
|
const skillsInvoked = new Set<string>()
|
|
let firstTs: string | null = null
|
|
let lastTs: string | null = null
|
|
let idx = 0
|
|
|
|
try {
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue
|
|
let raw: Record<string, unknown>
|
|
try {
|
|
raw = JSON.parse(line)
|
|
} catch {
|
|
continue
|
|
}
|
|
const type = classifyEventType(raw)
|
|
const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null
|
|
if (timestamp) {
|
|
if (!firstTs) firstTs = timestamp
|
|
lastTs = timestamp
|
|
}
|
|
if (type === 'permission-mode' && typeof raw.permissionMode === 'string') {
|
|
permissionMode = raw.permissionMode
|
|
}
|
|
if (typeof raw.sessionId === 'string') sessionId = raw.sessionId
|
|
|
|
const text = extractText((raw as { message?: unknown }).message)
|
|
let toolName: string | null = null
|
|
let toolInput: Record<string, unknown> | null = null
|
|
let toolResult: unknown = null
|
|
|
|
if (type === 'assistant' && raw.message && typeof raw.message === 'object') {
|
|
const content = (raw.message as { content?: unknown[] }).content
|
|
if (Array.isArray(content)) {
|
|
for (const part of content) {
|
|
if (part && typeof part === 'object' && 'type' in part) {
|
|
const p = part as { type: string; name?: string; input?: Record<string, unknown> }
|
|
if (p.type === 'tool_use' && typeof p.name === 'string') {
|
|
toolCalls++
|
|
toolsUsed.add(p.name)
|
|
toolName = p.name
|
|
toolInput = p.input ?? null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assistantMsgs++
|
|
}
|
|
|
|
if (type === 'user') {
|
|
userMessages++
|
|
if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) {
|
|
firstPrompt = text.slice(0, 500)
|
|
}
|
|
if (raw.message && typeof raw.message === 'object') {
|
|
const content = (raw.message as { content?: unknown[] }).content
|
|
if (Array.isArray(content)) {
|
|
for (const part of content) {
|
|
if (part && typeof part === 'object' && 'type' in part) {
|
|
const p = part as { type: string; content?: unknown }
|
|
if (p.type === 'tool_result') toolResult = p.content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const resultText = extractResultText(toolResult)
|
|
const skill = detectSkillInvoked(text)
|
|
const skillFromResult = detectSkillInvoked(resultText)
|
|
const finalSkill = skill ?? skillFromResult
|
|
if (finalSkill) skillsInvoked.add(finalSkill)
|
|
const hook = detectHook(text) ?? detectHook(resultText)
|
|
|
|
events.push({
|
|
index: idx++,
|
|
type,
|
|
timestamp,
|
|
raw,
|
|
text,
|
|
tool_name: toolName,
|
|
tool_input: toolInput,
|
|
tool_result: toolResult,
|
|
skill_invoked: finalSkill,
|
|
hook_name: hook,
|
|
})
|
|
}
|
|
} finally {
|
|
rl.close()
|
|
stream.destroy()
|
|
}
|
|
|
|
if (!sessionId) {
|
|
sessionId = basename(jsonlPath, '.jsonl')
|
|
}
|
|
|
|
let durationSec: number | null = null
|
|
if (firstTs && lastTs) {
|
|
const diff = Date.parse(lastTs) - Date.parse(firstTs)
|
|
if (Number.isFinite(diff)) durationSec = Math.max(0, Math.round(diff / 1000))
|
|
}
|
|
const outcome = deriveOutcome(events)
|
|
const stats = statSync(jsonlPath)
|
|
|
|
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,
|
|
event_count: events.length,
|
|
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],
|
|
outcome,
|
|
permission_mode: permissionMode,
|
|
file_size: stats.size,
|
|
indexed_at: new Date().toISOString(),
|
|
}
|
|
|
|
return { meta, events }
|
|
}
|