|
| 1 | +/** |
| 2 | + * Chronological Digest Generator |
| 3 | + * Produces compact markdown summaries of activity for today/yesterday/week periods. |
| 4 | + */ |
| 5 | + |
| 6 | +import type Database from 'better-sqlite3'; |
| 7 | + |
| 8 | +export type DigestPeriod = 'today' | 'yesterday' | 'week'; |
| 9 | + |
| 10 | +interface FrameRow { |
| 11 | + frame_id: string; |
| 12 | + name: string; |
| 13 | + type: string; |
| 14 | + state: string; |
| 15 | + created_at: number; |
| 16 | + closed_at: number | null; |
| 17 | + inputs: string; |
| 18 | + outputs: string; |
| 19 | +} |
| 20 | + |
| 21 | +interface AnchorRow { |
| 22 | + anchor_id: string; |
| 23 | + frame_id: string; |
| 24 | + type: string; |
| 25 | + text: string; |
| 26 | + priority: number; |
| 27 | + created_at: number; |
| 28 | +} |
| 29 | + |
| 30 | +interface EventRow { |
| 31 | + event_id: string; |
| 32 | + frame_id: string; |
| 33 | + event_type: string; |
| 34 | + payload: string; |
| 35 | + ts: number; |
| 36 | +} |
| 37 | + |
| 38 | +function getTimeRange(period: DigestPeriod): { |
| 39 | + start: number; |
| 40 | + end: number; |
| 41 | + label: string; |
| 42 | +} { |
| 43 | + const now = new Date(); |
| 44 | + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); |
| 45 | + |
| 46 | + switch (period) { |
| 47 | + case 'today': { |
| 48 | + return { |
| 49 | + start: Math.floor(todayStart.getTime() / 1000), |
| 50 | + end: Math.floor(now.getTime() / 1000), |
| 51 | + label: `Today — ${todayStart.toISOString().slice(0, 10)}`, |
| 52 | + }; |
| 53 | + } |
| 54 | + case 'yesterday': { |
| 55 | + const yesterdayStart = new Date(todayStart); |
| 56 | + yesterdayStart.setDate(yesterdayStart.getDate() - 1); |
| 57 | + return { |
| 58 | + start: Math.floor(yesterdayStart.getTime() / 1000), |
| 59 | + end: Math.floor(todayStart.getTime() / 1000), |
| 60 | + label: `Yesterday — ${yesterdayStart.toISOString().slice(0, 10)}`, |
| 61 | + }; |
| 62 | + } |
| 63 | + case 'week': { |
| 64 | + const weekStart = new Date(todayStart); |
| 65 | + weekStart.setDate(weekStart.getDate() - 7); |
| 66 | + return { |
| 67 | + start: Math.floor(weekStart.getTime() / 1000), |
| 68 | + end: Math.floor(now.getTime() / 1000), |
| 69 | + label: `Week — ${weekStart.toISOString().slice(0, 10)} to ${todayStart.toISOString().slice(0, 10)}`, |
| 70 | + }; |
| 71 | + } |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +function formatDate(epoch: number): string { |
| 76 | + return new Date(epoch * 1000).toISOString().slice(0, 10); |
| 77 | +} |
| 78 | + |
| 79 | +export function generateChronologicalDigest( |
| 80 | + db: Database.Database, |
| 81 | + period: DigestPeriod, |
| 82 | + projectId: string |
| 83 | +): string { |
| 84 | + const { start, end, label } = getTimeRange(period); |
| 85 | + |
| 86 | + // Query frames in the time window |
| 87 | + const frames = db |
| 88 | + .prepare( |
| 89 | + `SELECT frame_id, name, type, state, created_at, closed_at, inputs, outputs |
| 90 | + FROM frames |
| 91 | + WHERE project_id = ? AND created_at >= ? AND created_at < ? |
| 92 | + ORDER BY created_at ASC` |
| 93 | + ) |
| 94 | + .all(projectId, start, end) as FrameRow[]; |
| 95 | + |
| 96 | + if (frames.length === 0) { |
| 97 | + return `# ${label}\n\nNo activity recorded.\n`; |
| 98 | + } |
| 99 | + |
| 100 | + // Query anchors for these frames |
| 101 | + const frameIds = frames.map((f) => f.frame_id); |
| 102 | + const placeholders = frameIds.map(() => '?').join(','); |
| 103 | + const anchors = db |
| 104 | + .prepare( |
| 105 | + `SELECT anchor_id, frame_id, type, text, priority, created_at |
| 106 | + FROM anchors |
| 107 | + WHERE frame_id IN (${placeholders}) |
| 108 | + ORDER BY priority DESC, created_at ASC` |
| 109 | + ) |
| 110 | + .all(...frameIds) as AnchorRow[]; |
| 111 | + |
| 112 | + // Query events for file counts (tool_call events with file_path) |
| 113 | + const events = db |
| 114 | + .prepare( |
| 115 | + `SELECT event_id, frame_id, event_type, payload, ts |
| 116 | + FROM events |
| 117 | + WHERE frame_id IN (${placeholders}) AND event_type IN ('tool_call', 'decision') |
| 118 | + ORDER BY ts ASC` |
| 119 | + ) |
| 120 | + .all(...frameIds) as EventRow[]; |
| 121 | + |
| 122 | + // Group anchors and events by frame |
| 123 | + const anchorsByFrame = new Map<string, AnchorRow[]>(); |
| 124 | + for (const a of anchors) { |
| 125 | + const list = anchorsByFrame.get(a.frame_id) || []; |
| 126 | + list.push(a); |
| 127 | + anchorsByFrame.set(a.frame_id, list); |
| 128 | + } |
| 129 | + |
| 130 | + const eventsByFrame = new Map<string, EventRow[]>(); |
| 131 | + for (const e of events) { |
| 132 | + const list = eventsByFrame.get(e.frame_id) || []; |
| 133 | + list.push(e); |
| 134 | + eventsByFrame.set(e.frame_id, list); |
| 135 | + } |
| 136 | + |
| 137 | + // Group frames by date for week view |
| 138 | + const framesByDate = new Map<string, FrameRow[]>(); |
| 139 | + for (const f of frames) { |
| 140 | + const date = formatDate(f.created_at); |
| 141 | + const list = framesByDate.get(date) || []; |
| 142 | + list.push(f); |
| 143 | + framesByDate.set(date, list); |
| 144 | + } |
| 145 | + |
| 146 | + const lines: string[] = [`# ${label}\n`]; |
| 147 | + |
| 148 | + const renderFrame = (f: FrameRow) => { |
| 149 | + lines.push(`## ${f.name} (${f.type}, ${f.state})`); |
| 150 | + |
| 151 | + const frameAnchors = anchorsByFrame.get(f.frame_id) || []; |
| 152 | + const frameEvents = eventsByFrame.get(f.frame_id) || []; |
| 153 | + |
| 154 | + // Key decisions and constraints |
| 155 | + for (const a of frameAnchors.slice(0, 8)) { |
| 156 | + lines.push(`- ${a.type}: ${a.text}`); |
| 157 | + } |
| 158 | + |
| 159 | + // Count files from tool_call events |
| 160 | + const files = new Set<string>(); |
| 161 | + for (const e of frameEvents) { |
| 162 | + try { |
| 163 | + const payload = JSON.parse(e.payload); |
| 164 | + if (payload.arguments?.file_path) |
| 165 | + files.add(payload.arguments.file_path); |
| 166 | + if (payload.arguments?.path) files.add(payload.arguments.path); |
| 167 | + } catch { |
| 168 | + // ignore parse errors |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + if (files.size > 0) { |
| 173 | + lines.push(`- ${files.size} files touched`); |
| 174 | + } |
| 175 | + |
| 176 | + lines.push(''); |
| 177 | + }; |
| 178 | + |
| 179 | + if (period === 'week') { |
| 180 | + // Week: group by date |
| 181 | + for (const [date, dateFrames] of framesByDate) { |
| 182 | + lines.push(`### ${date}\n`); |
| 183 | + for (const f of dateFrames) { |
| 184 | + renderFrame(f); |
| 185 | + } |
| 186 | + } |
| 187 | + } else { |
| 188 | + // Today/yesterday: flat list |
| 189 | + for (const f of frames) { |
| 190 | + renderFrame(f); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + // Summary stats |
| 195 | + const completed = frames.filter((f) => f.state === 'completed').length; |
| 196 | + const active = frames.filter((f) => f.state === 'active').length; |
| 197 | + lines.push('---'); |
| 198 | + lines.push( |
| 199 | + `*${frames.length} frames total: ${completed} completed, ${active} active*` |
| 200 | + ); |
| 201 | + lines.push(`*Generated: ${new Date().toISOString()}*\n`); |
| 202 | + |
| 203 | + return lines.join('\n'); |
| 204 | +} |
0 commit comments