@mrclrchtr/supi-insights 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +47 -0
- package/src/aggregator.ts +245 -0
- package/src/cache.ts +94 -0
- package/src/extractor.ts +189 -0
- package/src/generator.ts +395 -0
- package/src/html.ts +481 -0
- package/src/index.ts +1 -0
- package/src/insights.ts +416 -0
- package/src/parser.ts +373 -0
- package/src/report.css +411 -0
- package/src/report.js +35 -0
- package/src/scanner.ts +13 -0
- package/src/types.ts +114 -0
- package/src/utils.ts +265 -0
package/src/insights.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// supi-insights — PI usage insights extension.
|
|
2
|
+
//
|
|
3
|
+
// Scans historical PI sessions, extracts structured metadata and LLM facets,
|
|
4
|
+
// generates narrative insights, and produces a shareable HTML report.
|
|
5
|
+
|
|
6
|
+
// biome-ignore lint: factory + pipeline in one file keeps phase ordering explicit.
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionCommandContext,
|
|
12
|
+
SessionInfo,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { loadSupiConfig, registerConfigSettings } from "@mrclrchtr/supi-core";
|
|
16
|
+
import { aggregateData } from "./aggregator.ts";
|
|
17
|
+
import {
|
|
18
|
+
loadCachedFacets,
|
|
19
|
+
loadCachedMeta,
|
|
20
|
+
makeCacheKey,
|
|
21
|
+
saveCachedFacets,
|
|
22
|
+
saveCachedMeta,
|
|
23
|
+
} from "./cache.ts";
|
|
24
|
+
import { extractFacets } from "./extractor.ts";
|
|
25
|
+
import { generateInsights } from "./generator.ts";
|
|
26
|
+
import { generateHtmlReport } from "./html.ts";
|
|
27
|
+
import {
|
|
28
|
+
extractSessionMeta,
|
|
29
|
+
formatTranscriptForFacets,
|
|
30
|
+
hasValidDates,
|
|
31
|
+
parseSessionFile,
|
|
32
|
+
} from "./parser.ts";
|
|
33
|
+
import { scanAllSessions } from "./scanner.ts";
|
|
34
|
+
import type { AggregatedData, InsightResults, SessionFacets, SessionMeta } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
const REPORT_TYPE = "supi-insights-report";
|
|
37
|
+
const MAX_SESSIONS_TO_ANALYZE = 200;
|
|
38
|
+
const MAX_FACET_EXTRACTIONS = 50;
|
|
39
|
+
const META_BATCH_SIZE = 50;
|
|
40
|
+
const LOAD_BATCH_SIZE = 10;
|
|
41
|
+
const FACET_CONCURRENCY = 50;
|
|
42
|
+
|
|
43
|
+
type ParsedSessionEntries = Awaited<ReturnType<typeof parseSessionFile>>;
|
|
44
|
+
|
|
45
|
+
type SessionRecord = {
|
|
46
|
+
session: SessionInfo;
|
|
47
|
+
cacheKey: string;
|
|
48
|
+
meta: SessionMeta;
|
|
49
|
+
entries?: ParsedSessionEntries;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── Config & Settings ─────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface InsightsConfig {
|
|
55
|
+
enabled: boolean;
|
|
56
|
+
maxSessions: number;
|
|
57
|
+
maxFacets: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getConfig(cwd: string): InsightsConfig {
|
|
61
|
+
const defaults = {
|
|
62
|
+
enabled: true,
|
|
63
|
+
maxSessions: MAX_SESSIONS_TO_ANALYZE,
|
|
64
|
+
maxFacets: MAX_FACET_EXTRACTIONS,
|
|
65
|
+
};
|
|
66
|
+
const section = loadSupiConfig<typeof defaults>("insights", cwd, defaults);
|
|
67
|
+
return {
|
|
68
|
+
enabled: section.enabled !== false,
|
|
69
|
+
maxSessions:
|
|
70
|
+
typeof section.maxSessions === "number" ? section.maxSessions : MAX_SESSIONS_TO_ANALYZE,
|
|
71
|
+
maxFacets: typeof section.maxFacets === "number" ? section.maxFacets : MAX_FACET_EXTRACTIONS,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Extension Factory ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export default function insightsExtension(pi: ExtensionAPI) {
|
|
78
|
+
// Register config-backed settings for /supi-settings
|
|
79
|
+
registerConfigSettings<InsightsConfig>({
|
|
80
|
+
id: "insights",
|
|
81
|
+
label: "Insights",
|
|
82
|
+
section: "insights",
|
|
83
|
+
defaults: {
|
|
84
|
+
enabled: true,
|
|
85
|
+
maxSessions: MAX_SESSIONS_TO_ANALYZE,
|
|
86
|
+
maxFacets: MAX_FACET_EXTRACTIONS,
|
|
87
|
+
},
|
|
88
|
+
buildItems: (settings, _scope, _cwd) => [
|
|
89
|
+
{
|
|
90
|
+
id: "enabled",
|
|
91
|
+
label: "Enable insights",
|
|
92
|
+
currentValue: settings.enabled ? "on" : "off",
|
|
93
|
+
values: ["on", "off"],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "maxSessions",
|
|
97
|
+
label: "Max sessions to analyze",
|
|
98
|
+
currentValue: String(settings.maxSessions),
|
|
99
|
+
values: ["50", "100", "200", "500"],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "maxFacets",
|
|
103
|
+
label: "Max facet extractions",
|
|
104
|
+
currentValue: String(settings.maxFacets),
|
|
105
|
+
values: ["20", "50", "100"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
// biome-ignore lint/complexity/useMaxParams: registerConfigSettings defines this callback shape.
|
|
109
|
+
persistChange: (_scope, _cwd, settingId, value, helpers) => {
|
|
110
|
+
const numSettings = new Set(["maxSessions", "maxFacets"]);
|
|
111
|
+
const finalValue = numSettings.has(settingId) ? Number(value) : value === "on";
|
|
112
|
+
helpers.set(settingId, finalValue);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── /supi-insights command ──────────────────────────────────
|
|
117
|
+
|
|
118
|
+
pi.registerCommand("supi-insights", {
|
|
119
|
+
description: "Generate a report analyzing your PI sessions",
|
|
120
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command handler coordinates UI and report generation flow.
|
|
121
|
+
handler: async (_args, ctx) => {
|
|
122
|
+
const config = getConfig(ctx.cwd);
|
|
123
|
+
if (!config.enabled) {
|
|
124
|
+
ctx.ui.notify("Insights are disabled. Enable via /supi-settings.", "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
ctx.ui.setWorkingMessage("Analyzing sessions...");
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const report = await generateReport(ctx, config);
|
|
132
|
+
ctx.ui.setWorkingMessage();
|
|
133
|
+
|
|
134
|
+
if (!report) {
|
|
135
|
+
ctx.ui.notify("No sessions found to analyze.", "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Save HTML report
|
|
140
|
+
const reportDir = join(getAgentDir(), "supi", "insights");
|
|
141
|
+
await mkdir(reportDir, { recursive: true });
|
|
142
|
+
const htmlPath = join(reportDir, `report-${Date.now()}.html`);
|
|
143
|
+
await writeFile(htmlPath, report.html, { encoding: "utf-8", mode: 0o600 });
|
|
144
|
+
|
|
145
|
+
// Send custom message with summary
|
|
146
|
+
const failureNote =
|
|
147
|
+
report.data.facetExtractionFailed > 0
|
|
148
|
+
? `${report.data.facetExtractionFailed} facet extractions failed`
|
|
149
|
+
: report.data.insightSectionsFailed.length > 0
|
|
150
|
+
? `${report.data.insightSectionsFailed.length} insight sections unavailable`
|
|
151
|
+
: undefined;
|
|
152
|
+
const stats = [
|
|
153
|
+
`${report.data.totalSessions} sessions`,
|
|
154
|
+
`${report.data.totalMessages.toLocaleString()} messages`,
|
|
155
|
+
`${Math.round(report.data.totalDurationHours)}h`,
|
|
156
|
+
`${report.data.gitCommits} commits`,
|
|
157
|
+
...(failureNote ? [`⚠ ${failureNote}`] : []),
|
|
158
|
+
].join(" · ");
|
|
159
|
+
|
|
160
|
+
const header = `# PI Insights\n\n${stats}\n${report.data.dateRange.start} to ${report.data.dateRange.end}\n`;
|
|
161
|
+
|
|
162
|
+
const atAGlance = report.insights.atAGlance as
|
|
163
|
+
| { whatsWorking?: string; quickWins?: string }
|
|
164
|
+
| undefined;
|
|
165
|
+
|
|
166
|
+
const summaryText = atAGlance
|
|
167
|
+
? `## At a Glance\n\n${atAGlance.whatsWorking ? `**What's working:** ${atAGlance.whatsWorking}` : ""}\n\n${atAGlance.quickWins ? `**Quick wins:** ${atAGlance.quickWins}` : ""}`
|
|
168
|
+
: "_No narrative insights generated_";
|
|
169
|
+
|
|
170
|
+
const userSummary = `${header}\n${summaryText}\n\nYour full report is ready: file://${htmlPath}`;
|
|
171
|
+
|
|
172
|
+
pi.sendMessage({
|
|
173
|
+
customType: REPORT_TYPE,
|
|
174
|
+
content: `${stats} | ${report.data.dateRange.start} to ${report.data.dateRange.end}`,
|
|
175
|
+
display: true,
|
|
176
|
+
details: {
|
|
177
|
+
htmlPath,
|
|
178
|
+
summary: userSummary,
|
|
179
|
+
data: report.data,
|
|
180
|
+
insights: report.insights,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
ctx.ui.notify(`Insights report saved: ${htmlPath}`, "info");
|
|
185
|
+
} catch (err) {
|
|
186
|
+
ctx.ui.setWorkingMessage();
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
ctx.ui.notify(`Insights generation failed: ${message}`, "error");
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Report Generation ─────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: report generation orchestrates sequential scan/cache/facet/render phases.
|
|
197
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: keeping the pipeline together makes phase ordering explicit.
|
|
198
|
+
async function generateReport(
|
|
199
|
+
ctx: ExtensionCommandContext,
|
|
200
|
+
config: InsightsConfig,
|
|
201
|
+
): Promise<{ data: AggregatedData; insights: InsightResults; html: string } | null> {
|
|
202
|
+
// Phase 1: Scan all sessions
|
|
203
|
+
const allSessions = await scanAllSessions((loaded, total) => {
|
|
204
|
+
ctx.ui.setStatus("supi-insights", `Scanning sessions... ${loaded}/${total}`);
|
|
205
|
+
});
|
|
206
|
+
ctx.ui.setStatus("supi-insights", undefined);
|
|
207
|
+
|
|
208
|
+
if (allSessions.length === 0) return null;
|
|
209
|
+
|
|
210
|
+
const totalSessionsScanned = allSessions.length;
|
|
211
|
+
|
|
212
|
+
// Phase 2: Load cached metas, parse uncached sessions. Keep the branch-specific
|
|
213
|
+
// path/cache key attached to each meta so later facet work uses the same branch.
|
|
214
|
+
let records: SessionRecord[] = [];
|
|
215
|
+
const uncachedSessions: SessionInfo[] = [];
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < allSessions.length; i += META_BATCH_SIZE) {
|
|
218
|
+
const batch = allSessions.slice(i, i + META_BATCH_SIZE);
|
|
219
|
+
const results = await Promise.all(
|
|
220
|
+
batch.map(async (session) => {
|
|
221
|
+
const cacheKey = makeCacheKey(session.id, session.path, session.modified.getTime());
|
|
222
|
+
return {
|
|
223
|
+
session,
|
|
224
|
+
cacheKey,
|
|
225
|
+
cached: await loadCachedMeta(cacheKey),
|
|
226
|
+
};
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
for (const { session, cacheKey, cached } of results) {
|
|
230
|
+
if (cached) {
|
|
231
|
+
records.push({ session, cacheKey, meta: cached });
|
|
232
|
+
} else if (uncachedSessions.length < config.maxSessions) {
|
|
233
|
+
uncachedSessions.push(session);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Parse uncached sessions in batches.
|
|
239
|
+
for (let i = 0; i < uncachedSessions.length; i += LOAD_BATCH_SIZE) {
|
|
240
|
+
const batch = uncachedSessions.slice(i, i + LOAD_BATCH_SIZE);
|
|
241
|
+
const batchResults = await Promise.all(
|
|
242
|
+
batch.map(async (session) => {
|
|
243
|
+
try {
|
|
244
|
+
const entries = await parseSessionFile(session.path);
|
|
245
|
+
return { session, entries, ok: true as const };
|
|
246
|
+
} catch {
|
|
247
|
+
return {
|
|
248
|
+
session,
|
|
249
|
+
entries: [] as ParsedSessionEntries,
|
|
250
|
+
ok: false as const,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const metasToSave: { meta: SessionMeta; cacheKey: string }[] = [];
|
|
257
|
+
for (const { session, entries, ok } of batchResults) {
|
|
258
|
+
if (!ok || !hasValidDates(entries)) continue;
|
|
259
|
+
const meta = extractSessionMeta(entries, session.id, session.cwd);
|
|
260
|
+
const cacheKey = makeCacheKey(session.id, session.path, session.modified.getTime());
|
|
261
|
+
records.push({ session, cacheKey, meta, entries });
|
|
262
|
+
metasToSave.push({ meta, cacheKey });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await Promise.all(metasToSave.map(({ meta, cacheKey }) => saveCachedMeta(cacheKey, meta)));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Deduplicate by session id while preserving the winning branch's path/cache key.
|
|
269
|
+
const bestBySession = new Map<string, SessionRecord>();
|
|
270
|
+
for (const record of records) {
|
|
271
|
+
const existing = bestBySession.get(record.meta.sessionId);
|
|
272
|
+
if (!existing || isBetterBranch(record, existing)) {
|
|
273
|
+
bestBySession.set(record.meta.sessionId, record);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
records = [...bestBySession.values()];
|
|
277
|
+
|
|
278
|
+
// Sort by start time descending, then apply maxSessions cap consistently to cached + uncached metas.
|
|
279
|
+
records.sort((a, b) => b.meta.startTime.localeCompare(a.meta.startTime));
|
|
280
|
+
records = records.slice(0, config.maxSessions);
|
|
281
|
+
|
|
282
|
+
// Filter substantive sessions.
|
|
283
|
+
const substantiveRecords = records.filter(
|
|
284
|
+
({ meta }) => meta.userMessageCount >= 2 && meta.durationMinutes >= 1,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Phase 3: Facet extraction.
|
|
288
|
+
const facets = new Map<string, SessionFacets>();
|
|
289
|
+
const toExtract: Array<{ record: SessionRecord; entries: ParsedSessionEntries }> = [];
|
|
290
|
+
|
|
291
|
+
for (let i = 0; i < substantiveRecords.length; i += LOAD_BATCH_SIZE) {
|
|
292
|
+
const batch = substantiveRecords.slice(i, i + LOAD_BATCH_SIZE);
|
|
293
|
+
const cachedFacetResults = await Promise.all(
|
|
294
|
+
batch.map(async (record) => ({
|
|
295
|
+
record,
|
|
296
|
+
cached: await loadCachedFacets(record.cacheKey),
|
|
297
|
+
})),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
for (const { record, cached } of cachedFacetResults) {
|
|
301
|
+
if (cached) {
|
|
302
|
+
facets.set(record.meta.sessionId, cached);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (toExtract.length >= config.maxFacets) continue;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const entries = record.entries ?? (await parseSessionFile(record.session.path));
|
|
309
|
+
if (!hasValidDates(entries)) continue;
|
|
310
|
+
record.entries = entries;
|
|
311
|
+
toExtract.push({ record, entries });
|
|
312
|
+
} catch {
|
|
313
|
+
// Session may have been deleted or become unreadable since listAll().
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Extract facets in parallel batches, tracking attempts and failures.
|
|
319
|
+
let facetExtractionAttempted = 0;
|
|
320
|
+
let facetExtractionFailed = 0;
|
|
321
|
+
ctx.ui.setStatus("supi-insights", `Extracting facets... 0/${toExtract.length}`);
|
|
322
|
+
for (let i = 0; i < toExtract.length; i += FACET_CONCURRENCY) {
|
|
323
|
+
const batch = toExtract.slice(i, i + FACET_CONCURRENCY);
|
|
324
|
+
const results = await Promise.all(
|
|
325
|
+
batch.map(async ({ record, entries }) => {
|
|
326
|
+
const transcript = formatTranscriptForFacets(entries, record.meta);
|
|
327
|
+
facetExtractionAttempted++;
|
|
328
|
+
return {
|
|
329
|
+
record,
|
|
330
|
+
facets: await extractFacets(transcript, record.meta.sessionId, ctx),
|
|
331
|
+
};
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const facetsToSave: { facets: SessionFacets; cacheKey: string }[] = [];
|
|
336
|
+
for (const result of results) {
|
|
337
|
+
const newFacets = result.facets;
|
|
338
|
+
if (newFacets) {
|
|
339
|
+
facets.set(result.record.meta.sessionId, newFacets);
|
|
340
|
+
facetsToSave.push({ facets: newFacets, cacheKey: result.record.cacheKey });
|
|
341
|
+
} else {
|
|
342
|
+
facetExtractionFailed++;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await Promise.all(
|
|
346
|
+
facetsToSave.map(({ facets, cacheKey }) => saveCachedFacets(cacheKey, facets)),
|
|
347
|
+
);
|
|
348
|
+
ctx.ui.setStatus(
|
|
349
|
+
"supi-insights",
|
|
350
|
+
`Extracting facets... ${Math.min(i + FACET_CONCURRENCY, toExtract.length)}/${toExtract.length}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
ctx.ui.setStatus("supi-insights", undefined);
|
|
354
|
+
|
|
355
|
+
// Filter out warmup-only sessions.
|
|
356
|
+
const isMinimalSession = (sessionId: string): boolean => {
|
|
357
|
+
const sessionFacets = facets.get(sessionId);
|
|
358
|
+
if (!sessionFacets) return false;
|
|
359
|
+
const cats = Object.entries(sessionFacets.goalCategories).filter(([, count]) => count > 0);
|
|
360
|
+
return cats.length === 1 && cats[0]?.[0] === "warmup_minimal";
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const finalSessions = substantiveRecords
|
|
364
|
+
.map((record) => record.meta)
|
|
365
|
+
.filter((session) => !isMinimalSession(session.sessionId));
|
|
366
|
+
const finalFacets = new Map<string, SessionFacets>();
|
|
367
|
+
for (const [sessionId, f] of facets) {
|
|
368
|
+
if (!isMinimalSession(sessionId)) {
|
|
369
|
+
finalFacets.set(sessionId, f);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Phase 4: Aggregate
|
|
374
|
+
const aggregated = aggregateData(finalSessions, finalFacets);
|
|
375
|
+
aggregated.totalSessionsScanned = totalSessionsScanned;
|
|
376
|
+
aggregated.facetExtractionAttempted = facetExtractionAttempted;
|
|
377
|
+
aggregated.facetExtractionFailed = facetExtractionFailed;
|
|
378
|
+
|
|
379
|
+
// Phase 5: Generate insights
|
|
380
|
+
const insights = await generateInsights(aggregated, finalFacets, ctx);
|
|
381
|
+
|
|
382
|
+
// Track which insight sections failed to generate.
|
|
383
|
+
const insightSectionsFailed: string[] = [];
|
|
384
|
+
for (const sectionName of [
|
|
385
|
+
"projectAreas",
|
|
386
|
+
"interactionStyle",
|
|
387
|
+
"whatWorks",
|
|
388
|
+
"frictionAnalysis",
|
|
389
|
+
"suggestions",
|
|
390
|
+
"onTheHorizon",
|
|
391
|
+
"funEnding",
|
|
392
|
+
"atAGlance",
|
|
393
|
+
] as const) {
|
|
394
|
+
if (!insights[sectionName]) {
|
|
395
|
+
insightSectionsFailed.push(sectionName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
aggregated.insightSectionsFailed = insightSectionsFailed;
|
|
399
|
+
|
|
400
|
+
// Phase 6: Render HTML
|
|
401
|
+
const htmlReport = generateHtmlReport(aggregated, insights);
|
|
402
|
+
|
|
403
|
+
return { data: aggregated, insights, html: htmlReport };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isBetterBranch(candidate: SessionRecord, current: SessionRecord): boolean {
|
|
407
|
+
const a = candidate.meta;
|
|
408
|
+
const b = current.meta;
|
|
409
|
+
if (a.userMessageCount !== b.userMessageCount) {
|
|
410
|
+
return a.userMessageCount > b.userMessageCount;
|
|
411
|
+
}
|
|
412
|
+
if (a.durationMinutes !== b.durationMinutes) {
|
|
413
|
+
return a.durationMinutes > b.durationMinutes;
|
|
414
|
+
}
|
|
415
|
+
return candidate.session.modified.getTime() > current.session.modified.getTime();
|
|
416
|
+
}
|