feat(observabilidade): parser JSONL com detecção de tool_calls e skills
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
||||
import { mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
function writeJsonl(lines: object[]): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-test-'))
|
||||
const path = join(dir, 'session-test.jsonl')
|
||||
writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'))
|
||||
return path
|
||||
}
|
||||
|
||||
describe('parseSessionFile', () => {
|
||||
it('extrai metadata básica de sessão mínima', async () => {
|
||||
const path = writeJsonl([
|
||||
{ type: 'permission-mode', permissionMode: 'default', sessionId: 's1' },
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'olá mundo' }] },
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-23T10:00:30Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'olá' }] },
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.session_id).toBe('s1')
|
||||
expect(result.meta.user_messages).toBe(1)
|
||||
expect(result.meta.assistant_msgs).toBe(1)
|
||||
expect(result.meta.tool_calls).toBe(0)
|
||||
expect(result.meta.first_prompt).toBe('olá mundo')
|
||||
expect(result.meta.permission_mode).toBe('default')
|
||||
expect(result.meta.outcome).toBe('completed')
|
||||
})
|
||||
|
||||
it('conta tool_calls e recolhe tools_used', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/x' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.tool_calls).toBe(2)
|
||||
expect(result.meta.tools_used).toEqual(expect.arrayContaining(['Bash', 'Read']))
|
||||
})
|
||||
|
||||
it('detecta skill invocation em system-reminder', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'system',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: { role: 'system', content: [{ type: 'text', text: 'Launching skill: superpowers:brainstorming' }] },
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
||||
})
|
||||
|
||||
it('ignora linhas JSON inválidas silenciosamente', async () => {
|
||||
const path = writeJsonl([
|
||||
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } },
|
||||
])
|
||||
const { writeFileSync } = await import('fs')
|
||||
writeFileSync(path, 'linha inválida\n' + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } }))
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.user_messages).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user