import Database from 'better-sqlite3' import { mkdirSync } from 'fs' import { dirname } from 'path' import type { SessionMeta } from '../../types/session.js' export interface ListFilters { days?: number source?: string project?: string tool?: string skill?: string q?: string limit?: number offset?: number } export interface PatternRecord { id?: number detected_at: string week_iso: string pattern_key: string title: string description: string severity: 'info' | 'warning' | 'action' metric_value: number | null sample_session_ids: string[] affected_count: number consecutive_weeks: number } export interface WorklogCommentRecord { id: number discussion_id: number created_at: string staff_id: number | null title: string | null task_ref: string | null duration_sec: number | null work_items: string[] files_modified: string[] problems: { problema: string; solucao: string }[] patterns_text: string[] actions: { tipo: string; descricao: string; prioridade: string | null }[] raw_html: string imported_at: string } export interface WorklogFilters { discussion_id?: number task_ref?: string sinceIso?: string limit?: number offset?: number } export interface PatternFilters { week?: string severity?: 'info' | 'warning' | 'action' pattern_key?: string sinceIso?: string limit?: number offset?: number } export interface SessionsStats { days: number sessions: number by_day: { day: string; sessions: number }[] by_source: Record by_outcome: Record tool_top: { tool: string; count: number }[] skill_top: { skill: string; count: number }[] patterns_by_type: { pattern_type: string; count: number }[] gate_blocks: number | null avg_duration_sec: number | null last_indexed_at: string | null latest_pattern_week: string | null } export interface SessionsDb { upsertSession(meta: SessionMeta): void upsertMany(metas: SessionMeta[]): void listSessions(filters: ListFilters): SessionMeta[] countSessions(filters: ListFilters): number getSession(id: string): SessionMeta | null deleteByJsonlPath(path: string): void storeSessionEvents(sessionId: string, events: import('../../types/session.js').SessionEvent[]): void getSessionEvents(sessionId: string): import('../../types/session.js').SessionEvent[] upsertPattern(p: PatternRecord): void getPatternsByWeek(week: string): PatternRecord[] getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number listPatterns(filters: PatternFilters): PatternRecord[] countPatterns(filters: PatternFilters): number getStats(days: number): SessionsStats upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } hasWorklogComment(id: number): boolean listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] countWorklogComments(filters?: WorklogFilters): number rawDb(): Database.Database close(): void } const SCHEMA = ` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, source TEXT NOT NULL DEFAULT 'claude', project_path TEXT, project_slug TEXT, jsonl_path TEXT, model TEXT, started_at TEXT NOT NULL, ended_at TEXT, duration_sec INTEGER, event_count INTEGER NOT NULL, user_messages INTEGER NOT NULL, assistant_msgs INTEGER NOT NULL, tool_calls INTEGER NOT NULL, input_tokens INTEGER, output_tokens INTEGER, estimated_cost REAL, first_prompt TEXT, tools_used TEXT NOT NULL DEFAULT '[]', skills_invoked TEXT NOT NULL DEFAULT '[]', outcome TEXT NOT NULL, permission_mode TEXT, file_size INTEGER NOT NULL DEFAULT 0, indexed_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC); CREATE INDEX IF NOT EXISTS idx_source ON sessions(source); CREATE TABLE IF NOT EXISTS session_events ( session_id TEXT NOT NULL, event_index INTEGER NOT NULL, type TEXT NOT NULL, timestamp TEXT, text TEXT, tool_name TEXT, tool_input TEXT, tool_result TEXT, skill_invoked TEXT, hook_name TEXT, PRIMARY KEY (session_id, event_index) ) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS patterns ( id INTEGER PRIMARY KEY AUTOINCREMENT, detected_at TEXT NOT NULL, week_iso TEXT NOT NULL, pattern_key TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, severity TEXT NOT NULL, metric_value REAL, sample_session_ids TEXT NOT NULL, affected_count INTEGER NOT NULL, consecutive_weeks INTEGER NOT NULL DEFAULT 1, UNIQUE(week_iso, pattern_key) ); CREATE INDEX IF NOT EXISTS idx_patterns_week ON patterns(week_iso); CREATE INDEX IF NOT EXISTS idx_patterns_key ON patterns(pattern_key); CREATE TABLE IF NOT EXISTS worklog_comments ( id INTEGER PRIMARY KEY, discussion_id INTEGER NOT NULL, created_at TEXT NOT NULL, staff_id INTEGER, title TEXT, task_ref TEXT, duration_sec INTEGER, work_items TEXT NOT NULL, files_modified TEXT NOT NULL, problems_json TEXT NOT NULL, patterns_text TEXT NOT NULL, actions_json TEXT NOT NULL, raw_html TEXT NOT NULL, imported_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_wc_discussion ON worklog_comments(discussion_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref); ` function rowToMeta(row: Record): SessionMeta { return { session_id: row.session_id as string, source: (row.source as SessionMeta['source']) ?? 'claude', project_path: (row.project_path as string | null) ?? null, project_slug: (row.project_slug as string | null) ?? null, jsonl_path: (row.jsonl_path as string | null) ?? null, model: (row.model as string | null) ?? null, started_at: row.started_at as string, ended_at: (row.ended_at as string | null) ?? null, duration_sec: (row.duration_sec as number | null) ?? null, event_count: row.event_count as number, user_messages: row.user_messages as number, assistant_msgs: row.assistant_msgs as number, tool_calls: row.tool_calls as number, input_tokens: (row.input_tokens as number | null) ?? null, output_tokens: (row.output_tokens as number | null) ?? null, estimated_cost: (row.estimated_cost as number | null) ?? null, first_prompt: (row.first_prompt as string | null) ?? null, tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [], skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [], outcome: row.outcome as SessionMeta['outcome'], permission_mode: (row.permission_mode as string | null) ?? null, file_size: row.file_size as number, indexed_at: row.indexed_at as string, } } function rowToPattern(r: Record): PatternRecord { return { id: r.id as number, detected_at: r.detected_at as string, week_iso: r.week_iso as string, pattern_key: r.pattern_key as string, title: r.title as string, description: r.description as string, severity: r.severity as PatternRecord['severity'], metric_value: (r.metric_value as number | null) ?? null, sample_session_ids: JSON.parse(r.sample_session_ids as string), affected_count: r.affected_count as number, consecutive_weeks: r.consecutive_weeks as number, } } function buildPatternsWhere(f: PatternFilters): { sql: string; params: Record } { const parts: string[] = [] const params: Record = {} if (f.week) { parts.push('week_iso = @week') params.week = f.week } if (f.severity) { parts.push('severity = @severity') params.severity = f.severity } if (f.pattern_key) { parts.push('pattern_key = @pattern_key') params.pattern_key = f.pattern_key } if (f.sinceIso) { parts.push('detected_at >= @since') params.since = f.sinceIso } return { sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '', params, } } function buildWhere(f: ListFilters): { sql: string; params: Record } { const parts: string[] = [] const params: Record = {} if (f.days) { const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString() parts.push('started_at >= @cutoff') params.cutoff = cutoff } if (f.source) { parts.push('source = @source') params.source = f.source } if (f.project) { parts.push('project_slug = @project') params.project = f.project } if (f.tool) { parts.push("tools_used LIKE @toolLike") params.toolLike = `%"${f.tool}"%` } if (f.skill) { parts.push('skills_invoked LIKE @skillLike') params.skillLike = `%"${f.skill}"%` } if (f.q) { parts.push('first_prompt LIKE @q') params.q = `%${f.q}%` } return { sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '', params, } } export function openSessionsDb(dbPath: string): SessionsDb { mkdirSync(dirname(dbPath), { recursive: true }) const db = new Database(dbPath) db.pragma('journal_mode = WAL') db.pragma('synchronous = NORMAL') db.exec(SCHEMA) // Migração: adicionar colunas novas se não existirem const existingCols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[] const colNames = new Set(existingCols.map(c => c.name)) const migrations: [string, string][] = [ ['source', "TEXT NOT NULL DEFAULT 'claude'"], ['model', 'TEXT'], ['input_tokens', 'INTEGER'], ['output_tokens', 'INTEGER'], ['estimated_cost', 'REAL'], ] for (const [col, type] of migrations) { if (!colNames.has(col)) { db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`) } } if (!colNames.has('source')) { db.exec('CREATE INDEX IF NOT EXISTS idx_source ON sessions(source)') } const upsertStmt = db.prepare(` INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model, started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls, input_tokens, output_tokens, estimated_cost, first_prompt, tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at) VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model, @started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @input_tokens, @output_tokens, @estimated_cost, @first_prompt, @tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at) ON CONFLICT(session_id) DO UPDATE SET source = excluded.source, project_path = excluded.project_path, project_slug = excluded.project_slug, jsonl_path = excluded.jsonl_path, model = excluded.model, started_at = excluded.started_at, ended_at = excluded.ended_at, duration_sec = excluded.duration_sec, event_count = excluded.event_count, user_messages = excluded.user_messages, assistant_msgs = excluded.assistant_msgs, tool_calls = excluded.tool_calls, input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens, estimated_cost = excluded.estimated_cost, first_prompt = excluded.first_prompt, tools_used = excluded.tools_used, skills_invoked = excluded.skills_invoked, outcome = excluded.outcome, permission_mode = excluded.permission_mode, file_size = excluded.file_size, indexed_at = excluded.indexed_at `) const upsertManyTxn = db.transaction((metas: SessionMeta[]) => { for (const meta of metas) { upsertStmt.run({ ...meta, tools_used: JSON.stringify(meta.tools_used), skills_invoked: JSON.stringify(meta.skills_invoked), }) } }) return { upsertSession(meta) { upsertStmt.run({ ...meta, tools_used: JSON.stringify(meta.tools_used), skills_invoked: JSON.stringify(meta.skills_invoked), }) }, upsertMany(metas) { upsertManyTxn(metas) }, listSessions(filters) { const { sql, params } = buildWhere(filters) const limit = filters.limit ?? 50 const offset = filters.offset ?? 0 const rows = db .prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`) .all({ ...params, limit, offset }) as Record[] return rows.map(rowToMeta) }, countSessions(filters) { const { sql, params } = buildWhere(filters) const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number } return row.c }, getSession(id) { const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record | undefined return row ? rowToMeta(row) : null }, deleteByJsonlPath(path) { db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path) }, upsertPattern(p: PatternRecord) { db.prepare(` INSERT INTO patterns (detected_at, week_iso, pattern_key, title, description, severity, metric_value, sample_session_ids, affected_count, consecutive_weeks) VALUES (@detected_at, @week_iso, @pattern_key, @title, @description, @severity, @metric_value, @sample_session_ids, @affected_count, @consecutive_weeks) ON CONFLICT(week_iso, pattern_key) DO UPDATE SET detected_at = excluded.detected_at, title = excluded.title, description = excluded.description, severity = excluded.severity, metric_value = excluded.metric_value, sample_session_ids = excluded.sample_session_ids, affected_count = excluded.affected_count, consecutive_weeks = excluded.consecutive_weeks `).run({ detected_at: p.detected_at, week_iso: p.week_iso, pattern_key: p.pattern_key, title: p.title, description: p.description, severity: p.severity, metric_value: p.metric_value, sample_session_ids: JSON.stringify(p.sample_session_ids), affected_count: p.affected_count, consecutive_weeks: p.consecutive_weeks, }) }, storeSessionEvents(sessionId, events) { const del = db.prepare('DELETE FROM session_events WHERE session_id = ?') const ins = db.prepare(` INSERT INTO session_events (session_id, event_index, type, timestamp, text, tool_name, tool_input, tool_result, skill_invoked, hook_name) VALUES (@session_id, @index, @type, @timestamp, @text, @tool_name, @tool_input, @tool_result, @skill_invoked, @hook_name) `) db.transaction(() => { del.run(sessionId) for (const e of events) { ins.run({ session_id: sessionId, index: e.index, type: e.type, timestamp: e.timestamp ?? null, text: e.text, tool_name: e.tool_name, tool_input: e.tool_input ? JSON.stringify(e.tool_input) : null, tool_result: typeof e.tool_result === 'string' ? e.tool_result.slice(0, 50000) : e.tool_result != null ? JSON.stringify(e.tool_result).slice(0, 50000) : null, skill_invoked: e.skill_invoked, hook_name: e.hook_name, }) } })() }, getSessionEvents(sessionId) { const rows = db.prepare('SELECT * FROM session_events WHERE session_id = ? ORDER BY event_index').all(sessionId) as Record[] return rows.map((r) => ({ index: r.event_index as number, type: r.type as import('../../types/session.js').SessionEventType, timestamp: (r.timestamp as string | null) ?? null, raw: {}, // not stored text: (r.text as string | null) ?? null, tool_name: (r.tool_name as string | null) ?? null, tool_input: r.tool_input ? JSON.parse(r.tool_input as string) as Record : null, tool_result: r.tool_result ?? null, skill_invoked: (r.skill_invoked as string | null) ?? null, hook_name: (r.hook_name as string | null) ?? null, })) }, getPatternsByWeek(week: string): PatternRecord[] { const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record[] return rows.map(rowToPattern) }, getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number { // Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu const rows = db.prepare('SELECT DISTINCT week_iso FROM patterns WHERE pattern_key = ? AND week_iso <= ? ORDER BY week_iso DESC').all(pattern_key, uptoWeek) as { week_iso: string }[] if (rows.length === 0) return 0 let count = 0 let cursor = uptoWeek for (const row of rows) { if (row.week_iso === cursor) { count++ cursor = prevWeekIso(cursor) } else { break } } return count }, listPatterns(filters: PatternFilters): PatternRecord[] { const { sql, params } = buildPatternsWhere(filters) const limit = filters.limit ?? 50 const offset = filters.offset ?? 0 const rows = db .prepare(`SELECT * FROM patterns ${sql} ORDER BY week_iso DESC, severity DESC, affected_count DESC LIMIT @limit OFFSET @offset`) .all({ ...params, limit, offset }) as Record[] return rows.map(rowToPattern) }, countPatterns(filters: PatternFilters): number { const { sql, params } = buildPatternsWhere(filters) const row = db.prepare(`SELECT COUNT(*) as c FROM patterns ${sql}`).get(params) as { c: number } return row.c }, getStats(days: number): SessionsStats { const cutoff = new Date(Date.now() - days * 86400_000).toISOString() const totals = db.prepare(` SELECT COUNT(*) as sessions, AVG(duration_sec) as avg_duration FROM sessions WHERE started_at >= ? `).get(cutoff) as { sessions: number; avg_duration: number | null } const byDay = db.prepare(` SELECT substr(started_at, 1, 10) as day, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY day ORDER BY day `).all(cutoff) as { day: string; sessions: number }[] const bySource: Record = {} for (const r of db.prepare('SELECT source, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY source').all(cutoff) as { source: string; c: number }[]) { bySource[r.source] = r.c } const byOutcome: Record = {} for (const r of db.prepare('SELECT outcome, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY outcome').all(cutoff) as { outcome: string; c: number }[]) { byOutcome[r.outcome] = r.c } const toolTop = db.prepare(` SELECT je.value as tool, COUNT(*) as count FROM sessions s, json_each(s.tools_used) je WHERE s.started_at >= ? GROUP BY je.value ORDER BY count DESC LIMIT 10 `).all(cutoff) as { tool: string; count: number }[] const skillTop = db.prepare(` SELECT je.value as skill, COUNT(*) as count FROM sessions s, json_each(s.skills_invoked) je WHERE s.started_at >= ? GROUP BY je.value ORDER BY count DESC LIMIT 10 `).all(cutoff) as { skill: string; count: number }[] // Tipo do padrão = prefixo de pattern_key antes de ':' (ex.: 'skill_error_rate:X' → 'skill_error_rate') const patternsByType = db.prepare(` SELECT CASE WHEN instr(pattern_key, ':') > 0 THEN substr(pattern_key, 1, instr(pattern_key, ':') - 1) ELSE pattern_key END as pattern_type, COUNT(*) as count FROM patterns WHERE detected_at >= ? GROUP BY pattern_type ORDER BY count DESC `).all(cutoff) as { pattern_type: string; count: number }[] // gate_events só existe quando F1 (arco C3-gates) entregar — null até lá let gateBlocks: number | null = null const hasGateEvents = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='gate_events'").get() if (hasGateEvents) { const r = db.prepare("SELECT COUNT(*) as c FROM gate_events WHERE decision = 'block' AND ts >= ?").get(cutoff) as { c: number } gateBlocks = r.c } const lastIndexed = db.prepare('SELECT MAX(indexed_at) as m FROM sessions').get() as { m: string | null } const latestWeek = db.prepare('SELECT MAX(week_iso) as m FROM patterns').get() as { m: string | null } return { days, sessions: totals.sessions, by_day: byDay, by_source: bySource, by_outcome: byOutcome, tool_top: toolTop, skill_top: skillTop, patterns_by_type: patternsByType, gate_blocks: gateBlocks, avg_duration_sec: totals.avg_duration != null ? Math.round(totals.avg_duration) : null, last_indexed_at: lastIndexed.m, latest_pattern_week: latestWeek.m, } }, upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } { const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id) const inserted = !existing db.prepare(` INSERT INTO worklog_comments (id, discussion_id, created_at, staff_id, title, task_ref, duration_sec, work_items, files_modified, problems_json, patterns_text, actions_json, raw_html, imported_at) VALUES (@id, @discussion_id, @created_at, @staff_id, @title, @task_ref, @duration_sec, @work_items, @files_modified, @problems_json, @patterns_text, @actions_json, @raw_html, @imported_at) ON CONFLICT(id) DO UPDATE SET discussion_id = excluded.discussion_id, created_at = excluded.created_at, staff_id = excluded.staff_id, title = excluded.title, task_ref = excluded.task_ref, duration_sec = excluded.duration_sec, work_items = excluded.work_items, files_modified = excluded.files_modified, problems_json = excluded.problems_json, patterns_text = excluded.patterns_text, actions_json = excluded.actions_json, raw_html = excluded.raw_html, imported_at = excluded.imported_at `).run({ id: c.id, discussion_id: c.discussion_id, created_at: c.created_at, staff_id: c.staff_id, title: c.title, task_ref: c.task_ref, duration_sec: c.duration_sec, work_items: JSON.stringify(c.work_items), files_modified: JSON.stringify(c.files_modified), problems_json: JSON.stringify(c.problems), patterns_text: JSON.stringify(c.patterns_text), actions_json: JSON.stringify(c.actions), raw_html: c.raw_html, imported_at: c.imported_at, }) return { inserted } }, hasWorklogComment(id: number): boolean { return !!db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(id) }, listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] { const parts: string[] = [] const params: Record = {} if (filters.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id } if (filters.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref } if (filters.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso } const where = parts.length ? 'WHERE ' + parts.join(' AND ') : '' const limit = filters.limit ?? 1000 const offset = filters.offset ?? 0 const rows = db.prepare(`SELECT * FROM worklog_comments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`) .all({ ...params, limit, offset }) as Record[] return rows.map((r) => ({ id: r.id as number, discussion_id: r.discussion_id as number, created_at: r.created_at as string, staff_id: (r.staff_id as number | null) ?? null, title: (r.title as string | null) ?? null, task_ref: (r.task_ref as string | null) ?? null, duration_sec: (r.duration_sec as number | null) ?? null, work_items: JSON.parse(r.work_items as string), files_modified: JSON.parse(r.files_modified as string), problems: JSON.parse(r.problems_json as string), patterns_text: JSON.parse(r.patterns_text as string), actions: JSON.parse(r.actions_json as string), raw_html: r.raw_html as string, imported_at: r.imported_at as string, })) }, countWorklogComments(filters?: WorklogFilters): number { const parts: string[] = [] const params: Record = {} if (filters?.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id } if (filters?.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref } if (filters?.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso } const where = parts.length ? 'WHERE ' + parts.join(' AND ') : '' const row = db.prepare(`SELECT COUNT(*) as c FROM worklog_comments ${where}`).get(params) as { c: number } return row.c }, rawDb(): Database.Database { return db }, close() { db.close() }, } } /** Calcula semana ISO anterior (YYYY-Www). */ export function prevWeekIso(week: string): string { const m = week.match(/^(\d{4})-W(\d{2})$/) if (!m) return week const year = parseInt(m[1], 10) const w = parseInt(m[2], 10) if (w > 1) return `${year}-W${String(w - 1).padStart(2, '0')}` // Semana 1 → última semana do ano anterior (52 ou 53) const prevYear = year - 1 const last = weeksInYear(prevYear) return `${prevYear}-W${String(last).padStart(2, '0')}` } function weeksInYear(year: number): number { // ISO: ano tem 53 semanas se 1 Jan é quinta ou (ano bissexto e 1 Jan é quarta) const jan1 = new Date(Date.UTC(year, 0, 1)).getUTCDay() const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 if (jan1 === 4 || (isLeap && jan1 === 3)) return 53 return 52 }