@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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Data aggregator — combine session metadata and facets into aggregated statistics.
|
|
2
|
+
|
|
3
|
+
import type { AggregatedData, SessionFacets, SessionMeta } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: aggregation intentionally combines many independent report counters.
|
|
6
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: keeping the aggregation flow together makes metric interactions easier to audit.
|
|
7
|
+
export function aggregateData(
|
|
8
|
+
sessions: SessionMeta[],
|
|
9
|
+
facets: Map<string, SessionFacets>,
|
|
10
|
+
): AggregatedData {
|
|
11
|
+
const result: AggregatedData = {
|
|
12
|
+
totalSessions: sessions.length,
|
|
13
|
+
sessionsWithFacets: facets.size,
|
|
14
|
+
dateRange: { start: "", end: "" },
|
|
15
|
+
totalMessages: 0,
|
|
16
|
+
totalDurationHours: 0,
|
|
17
|
+
totalInputTokens: 0,
|
|
18
|
+
totalOutputTokens: 0,
|
|
19
|
+
toolCounts: {},
|
|
20
|
+
languages: {},
|
|
21
|
+
gitCommits: 0,
|
|
22
|
+
gitPushes: 0,
|
|
23
|
+
projects: {},
|
|
24
|
+
goalCategories: {},
|
|
25
|
+
outcomes: {},
|
|
26
|
+
satisfaction: {},
|
|
27
|
+
helpfulness: {},
|
|
28
|
+
sessionTypes: {},
|
|
29
|
+
friction: {},
|
|
30
|
+
success: {},
|
|
31
|
+
sessionSummaries: [],
|
|
32
|
+
totalInterruptions: 0,
|
|
33
|
+
totalToolErrors: 0,
|
|
34
|
+
toolErrorCategories: {},
|
|
35
|
+
userResponseTimes: [],
|
|
36
|
+
medianResponseTime: 0,
|
|
37
|
+
avgResponseTime: 0,
|
|
38
|
+
sessionsUsingTaskAgent: 0,
|
|
39
|
+
sessionsUsingMcp: 0,
|
|
40
|
+
sessionsUsingWebSearch: 0,
|
|
41
|
+
sessionsUsingWebFetch: 0,
|
|
42
|
+
totalLinesAdded: 0,
|
|
43
|
+
totalLinesRemoved: 0,
|
|
44
|
+
totalFilesModified: 0,
|
|
45
|
+
daysActive: 0,
|
|
46
|
+
messagesPerDay: 0,
|
|
47
|
+
messageHours: [],
|
|
48
|
+
multiClauding: {
|
|
49
|
+
overlapEvents: 0,
|
|
50
|
+
sessionsInvolved: 0,
|
|
51
|
+
userMessagesDuring: 0,
|
|
52
|
+
},
|
|
53
|
+
facetExtractionAttempted: 0,
|
|
54
|
+
facetExtractionFailed: 0,
|
|
55
|
+
insightSectionsFailed: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const dates: string[] = [];
|
|
59
|
+
const allResponseTimes: number[] = [];
|
|
60
|
+
const allMessageHours: number[] = [];
|
|
61
|
+
|
|
62
|
+
for (const session of sessions) {
|
|
63
|
+
dates.push(session.startTime);
|
|
64
|
+
result.totalMessages += session.userMessageCount;
|
|
65
|
+
result.totalDurationHours += session.durationMinutes / 60;
|
|
66
|
+
result.totalInputTokens += session.inputTokens;
|
|
67
|
+
result.totalOutputTokens += session.outputTokens;
|
|
68
|
+
result.gitCommits += session.gitCommits;
|
|
69
|
+
result.gitPushes += session.gitPushes;
|
|
70
|
+
|
|
71
|
+
result.totalInterruptions += session.userInterruptions;
|
|
72
|
+
result.totalToolErrors += session.toolErrors;
|
|
73
|
+
for (const [cat, count] of Object.entries(session.toolErrorCategories)) {
|
|
74
|
+
result.toolErrorCategories[cat] = (result.toolErrorCategories[cat] ?? 0) + count;
|
|
75
|
+
}
|
|
76
|
+
allResponseTimes.push(...session.userResponseTimes);
|
|
77
|
+
if (session.usesTaskAgent) result.sessionsUsingTaskAgent++;
|
|
78
|
+
if (session.usesMcp) result.sessionsUsingMcp++;
|
|
79
|
+
if (session.usesWebSearch) result.sessionsUsingWebSearch++;
|
|
80
|
+
if (session.usesWebFetch) result.sessionsUsingWebFetch++;
|
|
81
|
+
|
|
82
|
+
result.totalLinesAdded += session.linesAdded;
|
|
83
|
+
result.totalLinesRemoved += session.linesRemoved;
|
|
84
|
+
result.totalFilesModified += session.filesModified;
|
|
85
|
+
allMessageHours.push(...session.messageHours);
|
|
86
|
+
|
|
87
|
+
for (const [tool, count] of Object.entries(session.toolCounts)) {
|
|
88
|
+
result.toolCounts[tool] = (result.toolCounts[tool] ?? 0) + count;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [lang, count] of Object.entries(session.languages)) {
|
|
92
|
+
result.languages[lang] = (result.languages[lang] ?? 0) + count;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (session.projectPath) {
|
|
96
|
+
result.projects[session.projectPath] = (result.projects[session.projectPath] ?? 0) + 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sessionFacets = facets.get(session.sessionId);
|
|
100
|
+
if (sessionFacets) {
|
|
101
|
+
for (const [cat, count] of safeEntries(sessionFacets.goalCategories)) {
|
|
102
|
+
if (count > 0) {
|
|
103
|
+
result.goalCategories[cat] = (result.goalCategories[cat] ?? 0) + count;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
result.outcomes[sessionFacets.outcome] = (result.outcomes[sessionFacets.outcome] ?? 0) + 1;
|
|
108
|
+
|
|
109
|
+
for (const [level, count] of safeEntries(sessionFacets.userSatisfactionCounts)) {
|
|
110
|
+
if (count > 0) {
|
|
111
|
+
result.satisfaction[level] = (result.satisfaction[level] ?? 0) + count;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
result.helpfulness[sessionFacets.claudeHelpfulness] =
|
|
116
|
+
(result.helpfulness[sessionFacets.claudeHelpfulness] ?? 0) + 1;
|
|
117
|
+
|
|
118
|
+
result.sessionTypes[sessionFacets.sessionType] =
|
|
119
|
+
(result.sessionTypes[sessionFacets.sessionType] ?? 0) + 1;
|
|
120
|
+
|
|
121
|
+
for (const [type, count] of safeEntries(sessionFacets.frictionCounts)) {
|
|
122
|
+
if (count > 0) {
|
|
123
|
+
result.friction[type] = (result.friction[type] ?? 0) + count;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (sessionFacets.primarySuccess !== "none") {
|
|
128
|
+
result.success[sessionFacets.primarySuccess] =
|
|
129
|
+
(result.success[sessionFacets.primarySuccess] ?? 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (result.sessionSummaries.length < 50) {
|
|
134
|
+
result.sessionSummaries.push({
|
|
135
|
+
id: session.sessionId.slice(0, 8),
|
|
136
|
+
date: session.startTime.split("T")[0] ?? "",
|
|
137
|
+
summary: session.summary ?? session.firstPrompt.slice(0, 100),
|
|
138
|
+
goal: sessionFacets?.underlyingGoal,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
dates.sort((a, b) => a.localeCompare(b));
|
|
144
|
+
result.dateRange.start = dates[0]?.split("T")[0] ?? "";
|
|
145
|
+
result.dateRange.end = dates[dates.length - 1]?.split("T")[0] ?? "";
|
|
146
|
+
|
|
147
|
+
result.userResponseTimes = allResponseTimes;
|
|
148
|
+
if (allResponseTimes.length > 0) {
|
|
149
|
+
const sorted = [...allResponseTimes].sort((a, b) => a - b);
|
|
150
|
+
result.medianResponseTime = sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
151
|
+
result.avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const uniqueDays = new Set(dates.map((d) => d.split("T")[0]));
|
|
155
|
+
result.daysActive = uniqueDays.size;
|
|
156
|
+
result.messagesPerDay =
|
|
157
|
+
result.daysActive > 0 ? Math.round((result.totalMessages / result.daysActive) * 10) / 10 : 0;
|
|
158
|
+
|
|
159
|
+
result.messageHours = allMessageHours;
|
|
160
|
+
result.multiClauding = detectMultiClauding(sessions);
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sliding-window overlap detection is clearer in one pass.
|
|
166
|
+
function detectMultiClauding(
|
|
167
|
+
sessions: Array<{ sessionId: string; userMessageTimestamps: string[] }>,
|
|
168
|
+
): { overlapEvents: number; sessionsInvolved: number; userMessagesDuring: number } {
|
|
169
|
+
const OVERLAP_WINDOW_MS = 30 * 60000;
|
|
170
|
+
const allMessages: Array<{ ts: number; sessionId: string }> = [];
|
|
171
|
+
|
|
172
|
+
for (const session of sessions) {
|
|
173
|
+
for (const timestamp of session.userMessageTimestamps) {
|
|
174
|
+
try {
|
|
175
|
+
const ts = new Date(timestamp).getTime();
|
|
176
|
+
allMessages.push({ ts, sessionId: session.sessionId });
|
|
177
|
+
} catch {
|
|
178
|
+
// skip
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
allMessages.sort((a, b) => a.ts - b.ts);
|
|
184
|
+
|
|
185
|
+
const multiClaudeSessionPairs = new Set<string>();
|
|
186
|
+
const messagesDuringMulticlaude = new Set<string>();
|
|
187
|
+
|
|
188
|
+
let windowStart = 0;
|
|
189
|
+
const sessionLastIndex = new Map<string, number>();
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < allMessages.length; i++) {
|
|
192
|
+
const msg = allMessages[i];
|
|
193
|
+
if (!msg) continue;
|
|
194
|
+
|
|
195
|
+
let windowStartMessage = allMessages[windowStart];
|
|
196
|
+
while (
|
|
197
|
+
windowStart < i &&
|
|
198
|
+
windowStartMessage &&
|
|
199
|
+
msg.ts - windowStartMessage.ts > OVERLAP_WINDOW_MS
|
|
200
|
+
) {
|
|
201
|
+
if (sessionLastIndex.get(windowStartMessage.sessionId) === windowStart) {
|
|
202
|
+
sessionLastIndex.delete(windowStartMessage.sessionId);
|
|
203
|
+
}
|
|
204
|
+
windowStart++;
|
|
205
|
+
windowStartMessage = allMessages[windowStart];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const prevIndex = sessionLastIndex.get(msg.sessionId);
|
|
209
|
+
if (prevIndex !== undefined) {
|
|
210
|
+
const previous = allMessages[prevIndex];
|
|
211
|
+
for (let j = prevIndex + 1; j < i; j++) {
|
|
212
|
+
const between = allMessages[j];
|
|
213
|
+
if (between && previous && between.sessionId !== msg.sessionId) {
|
|
214
|
+
const pair = [msg.sessionId, between.sessionId]
|
|
215
|
+
.sort((a, b) => a.localeCompare(b))
|
|
216
|
+
.join(":");
|
|
217
|
+
multiClaudeSessionPairs.add(pair);
|
|
218
|
+
messagesDuringMulticlaude.add(`${previous.ts}:${msg.sessionId}`);
|
|
219
|
+
messagesDuringMulticlaude.add(`${between.ts}:${between.sessionId}`);
|
|
220
|
+
messagesDuringMulticlaude.add(`${msg.ts}:${msg.sessionId}`);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
sessionLastIndex.set(msg.sessionId, i);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sessionsWithOverlaps = new Set<string>();
|
|
230
|
+
for (const pair of multiClaudeSessionPairs) {
|
|
231
|
+
const [s1, s2] = pair.split(":");
|
|
232
|
+
if (s1) sessionsWithOverlaps.add(s1);
|
|
233
|
+
if (s2) sessionsWithOverlaps.add(s2);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
overlapEvents: multiClaudeSessionPairs.size,
|
|
238
|
+
sessionsInvolved: sessionsWithOverlaps.size,
|
|
239
|
+
userMessagesDuring: messagesDuringMulticlaude.size,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function safeEntries<V>(obj: Record<string, V> | undefined | null): [string, V][] {
|
|
244
|
+
return obj ? Object.entries(obj) : [];
|
|
245
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Cache management for supi-insights
|
|
2
|
+
// Stores extracted facets and session metadata to avoid re-processing.
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { SessionFacets, SessionMeta } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
function getInsightsDir(): string {
|
|
10
|
+
return join(getAgentDir(), "supi", "insights");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getFacetsDir(): string {
|
|
14
|
+
return join(getInsightsDir(), "facets");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getMetaDir(): string {
|
|
18
|
+
return join(getInsightsDir(), "meta");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a stable cache key from session id, file path, and version so branched/stale sessions do not collide. */
|
|
22
|
+
export function makeCacheKey(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
filePath: string,
|
|
25
|
+
version: number | string,
|
|
26
|
+
): string {
|
|
27
|
+
return `${sessionId}_${djb2(filePath)}_${djb2(String(version))}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function djb2(str: string): string {
|
|
31
|
+
let hash = 5381;
|
|
32
|
+
for (let i = 0; i < str.length; i++) {
|
|
33
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
return hash.toString(16).slice(0, 8);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadCachedFacets(cacheKey: string): Promise<SessionFacets | null> {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(join(getFacetsDir(), `${cacheKey}.json`), { encoding: "utf-8" });
|
|
41
|
+
const parsed = JSON.parse(content) as unknown;
|
|
42
|
+
if (!isValidSessionFacets(parsed)) return null;
|
|
43
|
+
return parsed;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function saveCachedFacets(cacheKey: string, facets: SessionFacets): Promise<void> {
|
|
50
|
+
const dir = getFacetsDir();
|
|
51
|
+
await mkdir(dir, { recursive: true });
|
|
52
|
+
await writeFile(join(dir, `${cacheKey}.json`), JSON.stringify(facets, null, 2), {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
mode: 0o600,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function loadCachedMeta(cacheKey: string): Promise<SessionMeta | null> {
|
|
59
|
+
try {
|
|
60
|
+
const content = await readFile(join(getMetaDir(), `${cacheKey}.json`), { encoding: "utf-8" });
|
|
61
|
+
return JSON.parse(content) as SessionMeta;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveCachedMeta(cacheKey: string, meta: SessionMeta): Promise<void> {
|
|
68
|
+
const dir = getMetaDir();
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
await writeFile(join(dir, `${cacheKey}.json`), JSON.stringify(meta, null, 2), {
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function ensureInsightsDir(): Promise<void> {
|
|
77
|
+
await mkdir(getInsightsDir(), { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isValidSessionFacets(obj: unknown): obj is SessionFacets {
|
|
81
|
+
if (!obj || typeof obj !== "object") return false;
|
|
82
|
+
const o = obj as Record<string, unknown>;
|
|
83
|
+
return (
|
|
84
|
+
typeof o.underlyingGoal === "string" &&
|
|
85
|
+
typeof o.outcome === "string" &&
|
|
86
|
+
typeof o.briefSummary === "string" &&
|
|
87
|
+
o.goalCategories !== null &&
|
|
88
|
+
typeof o.goalCategories === "object" &&
|
|
89
|
+
o.userSatisfactionCounts !== null &&
|
|
90
|
+
typeof o.userSatisfactionCounts === "object" &&
|
|
91
|
+
o.frictionCounts !== null &&
|
|
92
|
+
typeof o.frictionCounts === "object"
|
|
93
|
+
);
|
|
94
|
+
}
|
package/src/extractor.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// LLM facet extraction — analyze session transcripts and extract structured facets.
|
|
2
|
+
|
|
3
|
+
import { complete } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { SessionFacets } from "./types.ts";
|
|
6
|
+
import { withRetry } from "./utils.ts";
|
|
7
|
+
|
|
8
|
+
const FACET_EXTRACTION_PROMPT = `Analyze this PI coding agent session and extract structured facets.
|
|
9
|
+
|
|
10
|
+
CRITICAL GUIDELINES:
|
|
11
|
+
|
|
12
|
+
1. **goalCategories**: Count ONLY what the USER explicitly asked for.
|
|
13
|
+
- DO NOT count the agent's autonomous codebase exploration
|
|
14
|
+
- DO NOT count work the agent decided to do on its own
|
|
15
|
+
- ONLY count when user says "can you...", "please...", "I need...", "let's..."
|
|
16
|
+
|
|
17
|
+
2. **userSatisfactionCounts**: Base ONLY on explicit user signals.
|
|
18
|
+
- "Yay!", "great!", "perfect!" → happy
|
|
19
|
+
- "thanks", "looks good", "that works" → satisfied
|
|
20
|
+
- "ok, now let's..." (continuing without complaint) → likely_satisfied
|
|
21
|
+
- "that's not right", "try again" → dissatisfied
|
|
22
|
+
- "this is broken", "I give up" → frustrated
|
|
23
|
+
|
|
24
|
+
3. **frictionCounts**: Be specific about what went wrong.
|
|
25
|
+
- misunderstood_request: Agent interpreted incorrectly
|
|
26
|
+
- wrong_approach: Right goal, wrong solution method
|
|
27
|
+
- buggy_code: Code didn't work correctly
|
|
28
|
+
- user_rejected_action: User said no/stop to a tool call
|
|
29
|
+
- excessive_changes: Over-engineered or changed too much
|
|
30
|
+
|
|
31
|
+
4. If very short or just warmup, use warmup_minimal for goal_category
|
|
32
|
+
|
|
33
|
+
SESSION:
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a PI session transcript. Focus on:
|
|
37
|
+
1. What the user asked for
|
|
38
|
+
2. What the agent did (tools used, files modified)
|
|
39
|
+
3. Any friction or issues
|
|
40
|
+
4. The outcome
|
|
41
|
+
|
|
42
|
+
Keep it concise - 3-5 sentences. Preserve specific details like file names, error messages, and user feedback.
|
|
43
|
+
|
|
44
|
+
TRANSCRIPT CHUNK:
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export async function extractFacets(
|
|
48
|
+
transcript: string,
|
|
49
|
+
sessionId: string,
|
|
50
|
+
ctx: ExtensionContext,
|
|
51
|
+
): Promise<SessionFacets | null> {
|
|
52
|
+
// Resolve model: prefer active model, fall back to any available configured model
|
|
53
|
+
const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0];
|
|
54
|
+
if (!model) return null;
|
|
55
|
+
|
|
56
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
57
|
+
if (!auth.ok || !auth.apiKey) return null;
|
|
58
|
+
|
|
59
|
+
// For long transcripts, summarize in chunks first
|
|
60
|
+
const processedTranscript =
|
|
61
|
+
transcript.length > 30000 ? await summarizeTranscript(transcript, ctx) : transcript;
|
|
62
|
+
|
|
63
|
+
const jsonPrompt = `${FACET_EXTRACTION_PROMPT}${processedTranscript}
|
|
64
|
+
|
|
65
|
+
RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
|
|
66
|
+
{
|
|
67
|
+
"underlyingGoal": "What the user fundamentally wanted to achieve",
|
|
68
|
+
"goalCategories": {"category_name": count, ...},
|
|
69
|
+
"outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
|
|
70
|
+
"userSatisfactionCounts": {"level": count, ...},
|
|
71
|
+
"claudeHelpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
|
|
72
|
+
"sessionType": "single_task|multi_task|iterative_refinement|exploration|quick_question",
|
|
73
|
+
"frictionCounts": {"friction_type": count, ...},
|
|
74
|
+
"frictionDetail": "One sentence describing friction or empty",
|
|
75
|
+
"primarySuccess": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
|
|
76
|
+
"briefSummary": "One sentence: what user wanted and whether they got it"
|
|
77
|
+
}`;
|
|
78
|
+
|
|
79
|
+
// Attempt the LLM call with up to 2 retries
|
|
80
|
+
const response = await withRetry(
|
|
81
|
+
async () => {
|
|
82
|
+
const res = await complete(
|
|
83
|
+
model,
|
|
84
|
+
{
|
|
85
|
+
systemPrompt: "",
|
|
86
|
+
messages: [
|
|
87
|
+
{
|
|
88
|
+
role: "user",
|
|
89
|
+
content: [{ type: "text", text: jsonPrompt }],
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
apiKey: auth.apiKey,
|
|
96
|
+
headers: auth.headers,
|
|
97
|
+
signal: ctx.signal,
|
|
98
|
+
maxTokens: 4096,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
return res;
|
|
102
|
+
},
|
|
103
|
+
2,
|
|
104
|
+
1000,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (!response) return null;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const text = response.content
|
|
111
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
112
|
+
.map((c) => c.text)
|
|
113
|
+
.join("");
|
|
114
|
+
|
|
115
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
116
|
+
if (!jsonMatch) return null;
|
|
117
|
+
|
|
118
|
+
const parsed = JSON.parse(jsonMatch[0]) as unknown;
|
|
119
|
+
if (!isValidSessionFacets(parsed)) return null;
|
|
120
|
+
|
|
121
|
+
return { ...parsed, sessionId };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function summarizeTranscript(transcript: string, ctx: ExtensionContext): Promise<string> {
|
|
128
|
+
const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0];
|
|
129
|
+
if (!model) return transcript.slice(0, 30000);
|
|
130
|
+
|
|
131
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
132
|
+
if (!auth.ok || !auth.apiKey) return transcript.slice(0, 30000);
|
|
133
|
+
|
|
134
|
+
const CHUNK_SIZE = 25000;
|
|
135
|
+
const chunks: string[] = [];
|
|
136
|
+
for (let i = 0; i < transcript.length; i += CHUNK_SIZE) {
|
|
137
|
+
chunks.push(transcript.slice(i, i + CHUNK_SIZE));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const summaries = await Promise.all(
|
|
141
|
+
chunks.map(async (chunk) => {
|
|
142
|
+
try {
|
|
143
|
+
const response = await complete(
|
|
144
|
+
model,
|
|
145
|
+
{
|
|
146
|
+
systemPrompt: "",
|
|
147
|
+
messages: [
|
|
148
|
+
{
|
|
149
|
+
role: "user",
|
|
150
|
+
content: [{ type: "text", text: SUMMARIZE_CHUNK_PROMPT + chunk }],
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
apiKey: auth.apiKey,
|
|
157
|
+
headers: auth.headers,
|
|
158
|
+
signal: ctx.signal,
|
|
159
|
+
maxTokens: 500,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
return response.content
|
|
163
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
164
|
+
.map((c) => c.text)
|
|
165
|
+
.join("");
|
|
166
|
+
} catch {
|
|
167
|
+
return chunk.slice(0, 2000);
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return summaries.join("\n\n---\n\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isValidSessionFacets(obj: unknown): obj is Omit<SessionFacets, "sessionId"> {
|
|
176
|
+
if (!obj || typeof obj !== "object") return false;
|
|
177
|
+
const o = obj as Record<string, unknown>;
|
|
178
|
+
return (
|
|
179
|
+
typeof o.underlyingGoal === "string" &&
|
|
180
|
+
typeof o.outcome === "string" &&
|
|
181
|
+
typeof o.briefSummary === "string" &&
|
|
182
|
+
o.goalCategories !== null &&
|
|
183
|
+
typeof o.goalCategories === "object" &&
|
|
184
|
+
o.userSatisfactionCounts !== null &&
|
|
185
|
+
typeof o.userSatisfactionCounts === "object" &&
|
|
186
|
+
o.frictionCounts !== null &&
|
|
187
|
+
typeof o.frictionCounts === "object"
|
|
188
|
+
);
|
|
189
|
+
}
|