feat(observabilidade): parser JSONL com detecção de tool_calls e skills

This commit is contained in:
2026-04-23 00:46:58 +01:00
parent 17e5736a0a
commit 26b631bbd6
2 changed files with 261 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
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 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]
const raw = last.raw as { message?: { stop_reason?: string } }
if (last.type === 'assistant' && raw.message?.stop_reason && raw.message.stop_reason !== 'end_turn' && raw.message.stop_reason !== 'tool_use') {
if (raw.message.stop_reason === 'error') return 'error'
}
if (last.type === 'assistant') 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
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 skill = detectSkillInvoked(text)
if (skill) skillsInvoked.add(skill)
const hook = detectHook(text)
events.push({
index: idx++,
type,
timestamp,
raw,
text,
tool_name: toolName,
tool_input: toolInput,
tool_result: toolResult,
skill_invoked: skill,
hook_name: hook,
})
}
if (!sessionId) {
sessionId = basename(jsonlPath, '.jsonl')
}
const durationSec = firstTs && lastTs ? Math.max(0, Math.round((Date.parse(lastTs) - Date.parse(firstTs)) / 1000)) : null
const outcome = deriveOutcome(events)
const stats = statSync(jsonlPath)
const meta: SessionMeta = {
session_id: sessionId,
project_path: dirname(jsonlPath),
project_slug: slugFromProjectPath(jsonlPath),
jsonl_path: jsonlPath,
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,
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 }
}