From 26b631bbd6367848cd23a20a771fd7f405ba92e2 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Thu, 23 Apr 2026 00:46:58 +0100 Subject: [PATCH] =?UTF-8?q?feat(observabilidade):=20parser=20JSONL=20com?= =?UTF-8?q?=20detec=C3=A7=C3=A3o=20de=20tool=5Fcalls=20e=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/services/sessions/parser.ts | 182 ++++++++++++++++++++++++++++++ api/tests/sessions-parser.test.ts | 79 +++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 api/services/sessions/parser.ts create mode 100644 api/tests/sessions-parser.test.ts diff --git a/api/services/sessions/parser.ts b/api/services/sessions/parser.ts new file mode 100644 index 0000000..c53cab4 --- /dev/null +++ b/api/services/sessions/parser.ts @@ -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): 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 { + 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() + const skillsInvoked = new Set() + 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 + 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 | 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 } + 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 } +} diff --git a/api/tests/sessions-parser.test.ts b/api/tests/sessions-parser.test.ts new file mode 100644 index 0000000..5cd3f05 --- /dev/null +++ b/api/tests/sessions-parser.test.ts @@ -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) + }) +})