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 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 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 upsertPattern(p: PatternRecord): void getPatternsByWeek(week: string): PatternRecord[] getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number 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, project_path TEXT NOT NULL, project_slug TEXT NOT NULL, jsonl_path TEXT NOT NULL UNIQUE, 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, first_prompt TEXT, tools_used TEXT NOT NULL, skills_invoked TEXT NOT NULL, outcome TEXT NOT NULL, permission_mode TEXT, file_size INTEGER NOT NULL, 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 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, project_path: row.project_path as string, project_slug: row.project_slug as string, jsonl_path: row.jsonl_path as string, 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, first_prompt: (row.first_prompt as string | null) ?? null, tools_used: JSON.parse(row.tools_used as string), 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 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.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) const upsertStmt = db.prepare(` INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt, tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at) VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt, @tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at) ON CONFLICT(session_id) DO UPDATE SET project_path = excluded.project_path, project_slug = excluded.project_slug, jsonl_path = excluded.jsonl_path, 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, 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, }) }, 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((r) => ({ 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, })) }, 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 }, 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 }