- routes/sessions.ts: createSessionsAnalysisRouter com GET /patterns (filtros week/severity/key/days, paginado, Zod) e GET /stats (agregados: sessões totais e por dia, by_source, by_outcome, tool_top, skill_top, patterns_by_type, gate_blocks, avg_duration_sec, last_indexed_at, latest_pattern_week); montado em /api/sessions (C4) e /api (alias F4.1) - services/sessions/db.ts: listPatterns/countPatterns/getStats + rowToPattern/buildPatternsWhere; gate_blocks devolve null enquanto a tabela gate_events não existir (F1 arco C3-gates) - tests/sessions-analysis-route.test.ts: 15 testes (filtros, paginação, validação, gate_blocks condicional, não-colisão com /:id) - inclui camada session_events pendente de v2.7.3 (db.ts/indexer.ts — storeSessionEvents no indexAll; rota /:id lê da BD) já em execução, para a árvore commitada compilar coerente Validado contra BD real (11459 sessões, 160 padrões): curl 200 com JSON não-vazio em /api/patterns, /api/stats, /api/sessions/patterns?week=2026-W26, /api/sessions/stats?days=7.
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { readdirSync, statSync } from 'fs'
|
|
import { homedir } from 'os'
|
|
import { join } from 'path'
|
|
import { parseSessionFile } from './parser.js'
|
|
import { openSessionsDb, type SessionsDb } from './db.js'
|
|
import { indexHermesSessions } from './hermes-indexer.js'
|
|
import { indexOCSessions } from './opencode-indexer.js'
|
|
import type { SessionMeta } from '../../types/session.js'
|
|
|
|
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
|
|
export const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
|
|
|
|
/**
|
|
* Percorre a raiz de projectos Claude (profundidade 2) e devolve todos os .jsonl.
|
|
* Estrutura: ~/.claude/projects/<project-slug>/<session-uuid>.jsonl
|
|
*/
|
|
export function findAllJsonl(root: string = PROJECTS_ROOT): string[] {
|
|
const result: string[] = []
|
|
let entries: string[]
|
|
try {
|
|
entries = readdirSync(root)
|
|
} catch {
|
|
return result
|
|
}
|
|
for (const entry of entries) {
|
|
const projectDir = join(root, entry)
|
|
let st
|
|
try {
|
|
st = statSync(projectDir)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (!st.isDirectory()) continue
|
|
let files: string[]
|
|
try {
|
|
files = readdirSync(projectDir)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const f of files) {
|
|
if (f.endsWith('.jsonl')) result.push(join(projectDir, f))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Indexa um único ficheiro (parse + upsert). Uso individual — útil para o watcher (Task 8).
|
|
*/
|
|
export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
|
const { meta, events } = await parseSessionFile(path)
|
|
db.upsertSession(meta)
|
|
db.storeSessionEvents(meta.session_id, events)
|
|
}
|
|
|
|
export interface IndexAllOptions {
|
|
dbPath?: string
|
|
sources?: ('claude' | 'hermes' | 'opencode')[]
|
|
onProgress?: (done: number, total: number) => void
|
|
}
|
|
|
|
/**
|
|
* Full scan: indexa Claude Code (JSONL) + Hermes (state.db) + OpenCode (opencode.db).
|
|
*/
|
|
export async function indexAll(
|
|
options: IndexAllOptions = {},
|
|
): Promise<{ indexed: number; failed: number }> {
|
|
const sources = options.sources ?? ['claude', 'hermes', 'opencode']
|
|
let indexed = 0
|
|
let failed = 0
|
|
const dbPath = options.dbPath ?? DEFAULT_DB_PATH
|
|
|
|
if (sources.includes('claude')) {
|
|
const db = openSessionsDb(dbPath)
|
|
const files = findAllJsonl()
|
|
const BATCH = 50
|
|
let batch: SessionMeta[] = []
|
|
|
|
try {
|
|
for (let i = 0; i < files.length; i++) {
|
|
try {
|
|
const { meta, events } = await parseSessionFile(files[i])
|
|
meta.source = 'claude'
|
|
meta.model = meta.model ?? null
|
|
meta.ended_at = meta.ended_at ?? null
|
|
meta.duration_sec = meta.duration_sec ?? null
|
|
meta.input_tokens = meta.input_tokens ?? null
|
|
meta.output_tokens = meta.output_tokens ?? null
|
|
meta.estimated_cost = meta.estimated_cost ?? null
|
|
meta.first_prompt = meta.first_prompt ?? null
|
|
meta.permission_mode = meta.permission_mode ?? null
|
|
meta.project_path = meta.project_path ?? null
|
|
meta.project_slug = meta.project_slug ?? null
|
|
meta.jsonl_path = meta.jsonl_path ?? null
|
|
// Store events per-session (delete+insert)
|
|
db.storeSessionEvents(meta.session_id, events)
|
|
batch.push(meta)
|
|
if (batch.length >= BATCH) {
|
|
db.upsertMany(batch)
|
|
indexed += batch.length
|
|
batch = []
|
|
}
|
|
} catch (err) {
|
|
failed++
|
|
console.error(`[indexer] erro em ${files[i]}:`, err)
|
|
}
|
|
if (options.onProgress) {
|
|
options.onProgress(indexed + failed + batch.length, files.length)
|
|
}
|
|
}
|
|
if (batch.length > 0) {
|
|
db.upsertMany(batch)
|
|
indexed += batch.length
|
|
}
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
if (sources.includes('hermes')) {
|
|
try {
|
|
const result = await indexHermesSessions({ dbPath })
|
|
indexed += result.indexed
|
|
failed += result.failed
|
|
console.log(`[indexer] Hermes: ${result.indexed} indexadas, ${result.failed} falhas`)
|
|
} catch (err) {
|
|
failed++
|
|
console.error('[indexer] Erro ao indexar Hermes:', err)
|
|
}
|
|
}
|
|
|
|
if (sources.includes('opencode')) {
|
|
try {
|
|
const result = await indexOCSessions({ dbPath })
|
|
indexed += result.indexed
|
|
failed += result.failed
|
|
console.log(`[indexer] OpenCode: ${result.indexed} indexadas, ${result.failed} falhas`)
|
|
} catch (err) {
|
|
failed++
|
|
console.error('[indexer] Erro ao indexar OpenCode:', err)
|
|
}
|
|
}
|
|
|
|
return { indexed, failed }
|
|
}
|