import { lstat } from 'node:fs/promises'; import { DatabaseSync } from 'node:sqlite'; import { pathToFileURL } from 'node:url'; import os from 'node:os'; import path from 'node:path'; import { SourceError, UNKNOWN, clean, inRange, issueNumbers, messageKind, readSeats, t3Header } from './ledger.mjs'; // T3 keeps every thread message in one SQLite database. This reader opens that // file read-only and nothing else in ~/.t3. See // docs/plans/2026-09-26_ledger-t3-source.md for the rules below. export const UNMAPPED = 't3:unmapped'; const SKIP = 'use --no-t3 to skip T3'; const REQUIRED = { projection_projects: ['project_id', 'workspace_root', 'deleted_at'], projection_threads: ['thread_id', 'project_id', 'title', 'archived_at', 'deleted_at'], projection_thread_messages: ['message_id', 'thread_id', 'role', 'text', 'created_at'], }; const DIAGNOSTIC = { orchestration_events: ['stream_id', 'event_type', 'payload_json', 'metadata_json'] }; export const defaultT3Path = () => path.join(os.homedir(), '.t3', 'userdata', 'state.sqlite'); // Every named path must exist and must not be a symlink. Skipping one would be // a silent zero, so each problem refuses the report. async function checkPaths(dbPath, isDefault) { const dirs = isDefault ? [path.dirname(path.dirname(dbPath)), path.dirname(dbPath)] : [path.dirname(dbPath)]; for (const [target, wantDir] of [...dirs.map(d => [d, true]), [dbPath, false]]) { let stat; try { stat = await lstat(target); } catch { throw new SourceError(`T3 database unavailable: ${target} is missing or unreadable; ${SKIP}`); } if (stat.isSymbolicLink()) throw new SourceError(`T3 database refused: ${target} is a symlink; ${SKIP}`); if (wantDir ? !stat.isDirectory() : !stat.isFile()) { throw new SourceError(`T3 database refused: ${target} is not a ${wantDir ? 'directory' : 'regular file'}; ${SKIP}`); } } } function missingColumns(db, tables) { const missing = []; for (const [table, columns] of Object.entries(tables)) { const have = new Set(db.prepare('select name from pragma_table_info(?)').all(table).map(r => r.name)); if (!have.size) missing.push(table); else for (const column of columns) if (!have.has(column)) missing.push(`${table}.${column}`); } return missing; } // Seat for a thread title: the lower-cased title equals the seat or starts // with the seat and a space. Longest seat first, so the most specific wins. export function seatForTitle(title, seats) { const lower = title.toLowerCase(); return [...seats].sort((a, b) => b.length - a.length).find(s => lower === s || lower.startsWith(`${s} `)) ?? null; } // Origin per message id from thread.message-sent events. Any missing table, // column or unparseable event makes the diagnostic unknown; it decides nothing. function origins(db, projectId) { if (missingColumns(db, DIAGNOSTIC).length) return null; const byMessage = new Map(); const events = db.prepare(`select e.payload_json, e.metadata_json from orchestration_events e join projection_threads t on t.thread_id = e.stream_id where e.event_type = 'thread.message-sent' and t.project_id = ?`).all(projectId); for (const event of events) { let payload, metadata; try { payload = JSON.parse(event.payload_json); metadata = JSON.parse(event.metadata_json); } catch { return null; } if (typeof payload?.messageId !== 'string') return null; byMessage.set(payload.messageId, typeof metadata?.origin?.appVersion === 'string'); } return byMessage; } function query(db, root, range, seats) { const missing = missingColumns(db, REQUIRED); if (missing.length) throw new SourceError(`T3 schema changed: missing ${missing.join(', ')}`); // Compared in JavaScript so a declared collation can't loosen the match. const projects = db.prepare('select project_id, workspace_root from projection_projects where deleted_at is null').all() .filter(p => p.workspace_root === root); if (projects.length !== 1) { throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`); } const projectId = projects[0].project_id; const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 }; for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) { if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title'); if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; } if (t.deleted_at !== null) { excluded.deletedThreads++; continue; } threads.set(t.thread_id, { id: t.thread_id, title: t.title, archived: t.archived_at !== null, seat: seatForTitle(t.title, seats) }); } const rows = new Map([...seats, UNMAPPED].map(s => [s, { board: 0, agent: 0, human: 0 }])); const mentions = new Map(), human = []; const messages = db.prepare(`select m.message_id, m.thread_id, m.role, m.text, m.created_at from projection_thread_messages m join projection_threads t on t.thread_id = m.thread_id where t.project_id = ?`).all(projectId); for (const m of messages) { const thread = threads.get(m.thread_id); if (!thread) continue; const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`; if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`); if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`); if (typeof m.created_at !== 'string' || !Number.isFinite(Date.parse(m.created_at))) throw new SourceError(`${where} has an invalid created_at`); if (m.role !== 'user') continue; // A header addressed to its own thread must agree with the title mapping. const header = t3Header(m.text); if (header && header.toId === thread.id) { const to = header.to.toLowerCase(); if (thread.seat ? to !== thread.seat : seats.includes(to)) { throw new SourceError(`T3 header conflict: thread ${clean(thread.id)} "${clean(thread.title)}" maps to ${thread.seat ?? 'no seat'}, but a header addresses ${clean(header.to)}`); } } if (!inRange(m.created_at, range)) continue; const kind = messageKind(m.text), seat = thread.seat ?? UNMAPPED; rows.get(seat)[kind]++; if (kind === 'human') human.push(m.message_id); for (const number of issueNumbers(m.text)) { if (!mentions.has(number)) mentions.set(number, new Set()); mentions.get(number).add(seat); } } const byMessage = origins(db, projectId); const sentThroughApi = byMessage === null ? UNKNOWN : human.filter(id => byMessage.get(id) === false).length; const noEvent = byMessage === null ? UNKNOWN : human.filter(id => !byMessage.has(id)).length; const listed = seat => [...threads.values()].filter(t => (t.seat ?? UNMAPPED) === seat) .sort((a, b) => a.id.localeCompare(b.id)).map(({ id, title, archived }) => ({ id, title, archived })); const seatRows = seats.map(seat => ({ seat, ...rows.get(seat), threads: listed(seat) })).filter(r => r.threads.length); return { rows, mentions, excluded, seats: seatRows, unmapped: { ...rows.get(UNMAPPED), threads: listed(UNMAPPED) }, diagnostic: { humanSentThroughApi: sentThroughApi, humanWithoutEvent: noEvent } }; } // Reads one snapshot of T3's database. Returns the per-seat rows and issue // mentions the ledger merges with Pi, and the report's `t3` section. export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault = true } = {}) { dbPath = path.resolve(dbPath); await checkPaths(dbPath, isDefault); const seats = await readSeats(root); const url = pathToFileURL(dbPath); url.searchParams.set('mode', 'ro'); let db, result; try { db = new DatabaseSync(url, { readOnly: true, timeout: 5000 }); db.exec('BEGIN'); result = query(db, root, range, seats); db.exec('COMMIT'); } catch (error) { if (error instanceof SourceError) throw error; throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`); } finally { try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ } try { db?.close(); } catch { /* nothing was written */ } } const { rows, mentions, ...section } = result; return { rows, mentions, section: { read: true, database: { path: dbPath, default: isDefault }, ...section } }; }