@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/parser.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Session parser — extract metadata and transcripts from PI session entries.
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import type {
|
|
5
|
+
AssistantMessage,
|
|
6
|
+
Message,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
import type { FileEntry, SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { migrateSessionEntries, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getActiveBranchEntries } from "@mrclrchtr/supi-core";
|
|
13
|
+
import { diffLines } from "diff";
|
|
14
|
+
import type { SessionMeta } from "./types.ts";
|
|
15
|
+
import { countCharInString, getLanguageFromPath } from "./utils.ts";
|
|
16
|
+
|
|
17
|
+
// Local type shims for pi-coding-agent message types not re-exported from index
|
|
18
|
+
interface BashExecutionMessage {
|
|
19
|
+
role: "bashExecution";
|
|
20
|
+
command: string;
|
|
21
|
+
output: string;
|
|
22
|
+
exitCode: number | undefined;
|
|
23
|
+
cancelled: boolean;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CustomMessage {
|
|
29
|
+
role: "custom";
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string | Array<{ type: "text"; text: string }>;
|
|
32
|
+
display: boolean;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type AgentMessage = Message | BashExecutionMessage | CustomMessage;
|
|
37
|
+
|
|
38
|
+
// Tool names that indicate special features
|
|
39
|
+
const TASK_AGENT_TOOLS = new Set(["agent", "subagent"]);
|
|
40
|
+
const MCP_PREFIX = "mcp__";
|
|
41
|
+
const WEB_SEARCH_TOOL = "web_search";
|
|
42
|
+
const WEB_FETCH_TOOL = "web_fetch";
|
|
43
|
+
|
|
44
|
+
export async function parseSessionFile(path: string): Promise<FileEntry[]> {
|
|
45
|
+
const content = await readFile(path, { encoding: "utf-8" });
|
|
46
|
+
const entries = parseSessionEntries(content);
|
|
47
|
+
migrateSessionEntries(entries);
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: metadata extraction handles many independent session counters.
|
|
52
|
+
export function extractSessionMeta(
|
|
53
|
+
entries: FileEntry[],
|
|
54
|
+
sessionId: string,
|
|
55
|
+
projectPath: string,
|
|
56
|
+
): SessionMeta {
|
|
57
|
+
const header = entries.find((e): e is SessionHeader => e.type === "session");
|
|
58
|
+
const sessionEntries = getActiveBranchEntries(entries);
|
|
59
|
+
|
|
60
|
+
const startTime = header?.timestamp ?? new Date().toISOString();
|
|
61
|
+
const startDate = new Date(startTime);
|
|
62
|
+
|
|
63
|
+
// Find last entry timestamp for duration
|
|
64
|
+
let endDate = startDate;
|
|
65
|
+
for (const entry of sessionEntries) {
|
|
66
|
+
if (entry.timestamp) {
|
|
67
|
+
const d = new Date(entry.timestamp);
|
|
68
|
+
if (!Number.isNaN(d.getTime()) && d > endDate) {
|
|
69
|
+
endDate = d;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const durationMinutes = Math.round((endDate.getTime() - startDate.getTime()) / 1000 / 60);
|
|
75
|
+
|
|
76
|
+
const stats = extractToolStats(sessionEntries);
|
|
77
|
+
|
|
78
|
+
let userMessageCount = 0;
|
|
79
|
+
let assistantMessageCount = 0;
|
|
80
|
+
let firstPrompt = "";
|
|
81
|
+
|
|
82
|
+
for (const entry of sessionEntries) {
|
|
83
|
+
if (entry.type !== "message") continue;
|
|
84
|
+
const msg = entry.message as AgentMessage;
|
|
85
|
+
if (msg.role === "assistant") {
|
|
86
|
+
assistantMessageCount++;
|
|
87
|
+
} else if (msg.role === "user") {
|
|
88
|
+
const text = extractUserText(msg as UserMessage);
|
|
89
|
+
if (text.trim()) {
|
|
90
|
+
userMessageCount++;
|
|
91
|
+
if (!firstPrompt) firstPrompt = text.slice(0, 200);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
sessionId,
|
|
98
|
+
projectPath,
|
|
99
|
+
startTime,
|
|
100
|
+
durationMinutes: Math.max(0, durationMinutes),
|
|
101
|
+
userMessageCount,
|
|
102
|
+
assistantMessageCount,
|
|
103
|
+
toolCounts: stats.toolCounts,
|
|
104
|
+
languages: stats.languages,
|
|
105
|
+
gitCommits: stats.gitCommits,
|
|
106
|
+
gitPushes: stats.gitPushes,
|
|
107
|
+
inputTokens: stats.inputTokens,
|
|
108
|
+
outputTokens: stats.outputTokens,
|
|
109
|
+
firstPrompt,
|
|
110
|
+
userInterruptions: stats.userInterruptions,
|
|
111
|
+
userResponseTimes: stats.userResponseTimes,
|
|
112
|
+
toolErrors: stats.toolErrors,
|
|
113
|
+
toolErrorCategories: stats.toolErrorCategories,
|
|
114
|
+
usesTaskAgent: stats.usesTaskAgent,
|
|
115
|
+
usesMcp: stats.usesMcp,
|
|
116
|
+
usesWebSearch: stats.usesWebSearch,
|
|
117
|
+
usesWebFetch: stats.usesWebFetch,
|
|
118
|
+
linesAdded: stats.linesAdded,
|
|
119
|
+
linesRemoved: stats.linesRemoved,
|
|
120
|
+
filesModified: stats.filesModified.size,
|
|
121
|
+
messageHours: stats.messageHours,
|
|
122
|
+
userMessageTimestamps: stats.userMessageTimestamps,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tool stats are extracted in one session-entry pass for performance.
|
|
127
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: keeping related counters together avoids repeated tree walks.
|
|
128
|
+
function extractToolStats(entries: SessionEntry[]) {
|
|
129
|
+
const toolCounts: Record<string, number> = {};
|
|
130
|
+
const languages: Record<string, number> = {};
|
|
131
|
+
let gitCommits = 0;
|
|
132
|
+
let gitPushes = 0;
|
|
133
|
+
let inputTokens = 0;
|
|
134
|
+
let outputTokens = 0;
|
|
135
|
+
|
|
136
|
+
let userInterruptions = 0;
|
|
137
|
+
const userResponseTimes: number[] = [];
|
|
138
|
+
let toolErrors = 0;
|
|
139
|
+
const toolErrorCategories: Record<string, number> = {};
|
|
140
|
+
let usesTaskAgent = false;
|
|
141
|
+
let usesMcp = false;
|
|
142
|
+
let usesWebSearch = false;
|
|
143
|
+
let usesWebFetch = false;
|
|
144
|
+
|
|
145
|
+
let linesAdded = 0;
|
|
146
|
+
let linesRemoved = 0;
|
|
147
|
+
const filesModified = new Set<string>();
|
|
148
|
+
const messageHours: number[] = [];
|
|
149
|
+
const userMessageTimestamps: string[] = [];
|
|
150
|
+
let lastAssistantTimestamp: string | null = null;
|
|
151
|
+
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.type !== "message") continue;
|
|
154
|
+
const msg = entry.message as AgentMessage;
|
|
155
|
+
const msgTimestamp =
|
|
156
|
+
"timestamp" in msg && msg.timestamp ? new Date(msg.timestamp).toISOString() : entry.timestamp;
|
|
157
|
+
|
|
158
|
+
if (msg.role === "assistant") {
|
|
159
|
+
if (msgTimestamp) lastAssistantTimestamp = msgTimestamp;
|
|
160
|
+
const usage = (msg as AssistantMessage).usage;
|
|
161
|
+
if (usage) {
|
|
162
|
+
inputTokens += usage.input ?? 0;
|
|
163
|
+
outputTokens += usage.output ?? 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const content = msg.content;
|
|
167
|
+
if (Array.isArray(content)) {
|
|
168
|
+
for (const block of content) {
|
|
169
|
+
if (block.type === "toolCall" && "name" in block) {
|
|
170
|
+
const toolName = block.name as string;
|
|
171
|
+
toolCounts[toolName] = (toolCounts[toolName] ?? 0) + 1;
|
|
172
|
+
|
|
173
|
+
if (TASK_AGENT_TOOLS.has(toolName)) usesTaskAgent = true;
|
|
174
|
+
if (toolName.startsWith(MCP_PREFIX)) usesMcp = true;
|
|
175
|
+
if (toolName === WEB_SEARCH_TOOL) usesWebSearch = true;
|
|
176
|
+
if (toolName === WEB_FETCH_TOOL) usesWebFetch = true;
|
|
177
|
+
|
|
178
|
+
const input = block.arguments as Record<string, unknown> | undefined;
|
|
179
|
+
if (input) {
|
|
180
|
+
const filePath = (input.file_path as string) || (input.path as string) || "";
|
|
181
|
+
if (filePath) {
|
|
182
|
+
const lang = getLanguageFromPath(filePath);
|
|
183
|
+
if (lang) languages[lang] = (languages[lang] ?? 0) + 1;
|
|
184
|
+
if (toolName === "edit" || toolName === "write") {
|
|
185
|
+
filesModified.add(filePath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
toolName === "edit" &&
|
|
191
|
+
typeof input.old_string === "string" &&
|
|
192
|
+
typeof input.new_string === "string"
|
|
193
|
+
) {
|
|
194
|
+
for (const change of diffLines(input.old_string, input.new_string)) {
|
|
195
|
+
if (change.added) linesAdded += change.count || 0;
|
|
196
|
+
if (change.removed) linesRemoved += change.count || 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (toolName === "write" && typeof input.content === "string") {
|
|
201
|
+
linesAdded += countCharInString(input.content, "\n") + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const command = (input.command as string) || "";
|
|
205
|
+
if (command.includes("git commit")) gitCommits++;
|
|
206
|
+
if (command.includes("git push")) gitPushes++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (msg.role === "user") {
|
|
214
|
+
const isHuman = isHumanMessage(msg as UserMessage);
|
|
215
|
+
if (isHuman && msgTimestamp) {
|
|
216
|
+
try {
|
|
217
|
+
const msgDate = new Date(msgTimestamp);
|
|
218
|
+
messageHours.push(msgDate.getUTCHours());
|
|
219
|
+
userMessageTimestamps.push(msgTimestamp);
|
|
220
|
+
} catch {
|
|
221
|
+
// skip invalid timestamps
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (lastAssistantTimestamp) {
|
|
225
|
+
const assistantTime = new Date(lastAssistantTimestamp).getTime();
|
|
226
|
+
const userTime = new Date(msgTimestamp).getTime();
|
|
227
|
+
const responseTimeSec = (userTime - assistantTime) / 1000;
|
|
228
|
+
if (responseTimeSec > 2 && responseTimeSec < 3600) {
|
|
229
|
+
userResponseTimes.push(responseTimeSec);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check for interruptions in content
|
|
235
|
+
const text = extractUserText(msg as UserMessage);
|
|
236
|
+
if (text.includes("[Request interrupted by user")) {
|
|
237
|
+
userInterruptions++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (msg.role === "toolResult") {
|
|
242
|
+
const tr = msg as ToolResultMessage;
|
|
243
|
+
if (tr.isError) {
|
|
244
|
+
toolErrors++;
|
|
245
|
+
const text = extractBlockText(tr.content);
|
|
246
|
+
const category = categorizeToolError(text);
|
|
247
|
+
toolErrorCategories[category] = (toolErrorCategories[category] ?? 0) + 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
toolCounts,
|
|
254
|
+
languages,
|
|
255
|
+
gitCommits,
|
|
256
|
+
gitPushes,
|
|
257
|
+
inputTokens,
|
|
258
|
+
outputTokens,
|
|
259
|
+
userInterruptions,
|
|
260
|
+
userResponseTimes,
|
|
261
|
+
toolErrors,
|
|
262
|
+
toolErrorCategories,
|
|
263
|
+
usesTaskAgent,
|
|
264
|
+
usesMcp,
|
|
265
|
+
usesWebSearch,
|
|
266
|
+
usesWebFetch,
|
|
267
|
+
linesAdded,
|
|
268
|
+
linesRemoved,
|
|
269
|
+
filesModified,
|
|
270
|
+
messageHours,
|
|
271
|
+
userMessageTimestamps,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isHumanMessage(msg: UserMessage | CustomMessage): boolean {
|
|
276
|
+
if (msg.role === "custom") return false;
|
|
277
|
+
const content = msg.content;
|
|
278
|
+
if (typeof content === "string" && content.trim()) return true;
|
|
279
|
+
if (Array.isArray(content)) {
|
|
280
|
+
for (const block of content) {
|
|
281
|
+
if (block.type === "text" && "text" in block && block.text.trim()) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractUserText(msg: UserMessage | CustomMessage): string {
|
|
290
|
+
const content = msg.content;
|
|
291
|
+
if (typeof content === "string") return content;
|
|
292
|
+
if (Array.isArray(content)) {
|
|
293
|
+
return content
|
|
294
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
295
|
+
.map((c) => c.text)
|
|
296
|
+
.join("\n");
|
|
297
|
+
}
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function extractBlockText(content: unknown): string {
|
|
302
|
+
if (typeof content === "string") return content;
|
|
303
|
+
if (Array.isArray(content)) {
|
|
304
|
+
return content
|
|
305
|
+
.filter((c): c is { type: "text"; text: string } => c?.type === "text")
|
|
306
|
+
.map((c) => c.text)
|
|
307
|
+
.join(" ");
|
|
308
|
+
}
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function categorizeToolError(content: string): string {
|
|
313
|
+
const lower = content.toLowerCase();
|
|
314
|
+
if (lower.includes("exit code")) return "Command Failed";
|
|
315
|
+
if (lower.includes("rejected") || lower.includes("doesn't want")) return "User Rejected";
|
|
316
|
+
if (lower.includes("string to replace not found") || lower.includes("no changes"))
|
|
317
|
+
return "Edit Failed";
|
|
318
|
+
if (lower.includes("modified since read")) return "File Changed";
|
|
319
|
+
if (lower.includes("exceeds maximum") || lower.includes("too large")) return "File Too Large";
|
|
320
|
+
if (lower.includes("file not found") || lower.includes("does not exist")) return "File Not Found";
|
|
321
|
+
return "Other";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: transcript formatting mirrors PI message block shapes.
|
|
325
|
+
export function formatTranscriptForFacets(entries: FileEntry[], sessionMeta: SessionMeta): string {
|
|
326
|
+
const lines: string[] = [];
|
|
327
|
+
|
|
328
|
+
lines.push(`Session: ${sessionMeta.sessionId.slice(0, 8)}`);
|
|
329
|
+
lines.push(`Date: ${sessionMeta.startTime}`);
|
|
330
|
+
lines.push(`Project: ${sessionMeta.projectPath}`);
|
|
331
|
+
lines.push(`Duration: ${sessionMeta.durationMinutes} min`);
|
|
332
|
+
lines.push("");
|
|
333
|
+
|
|
334
|
+
const sessionEntries = getActiveBranchEntries(entries);
|
|
335
|
+
|
|
336
|
+
for (const entry of sessionEntries) {
|
|
337
|
+
if (entry.type !== "message") continue;
|
|
338
|
+
const msg = entry.message as AgentMessage;
|
|
339
|
+
|
|
340
|
+
if (msg.role === "user") {
|
|
341
|
+
const text = extractUserText(msg as UserMessage);
|
|
342
|
+
if (text.trim()) {
|
|
343
|
+
lines.push(`[User]: ${text.slice(0, 500)}`);
|
|
344
|
+
}
|
|
345
|
+
} else if (msg.role === "assistant") {
|
|
346
|
+
const content = msg.content;
|
|
347
|
+
if (Array.isArray(content)) {
|
|
348
|
+
for (const block of content) {
|
|
349
|
+
if (block.type === "text" && "text" in block) {
|
|
350
|
+
lines.push(`[Assistant]: ${(block.text as string).slice(0, 300)}`);
|
|
351
|
+
} else if (block.type === "toolCall" && "name" in block) {
|
|
352
|
+
lines.push(`[Tool: ${block.name}]`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function hasValidDates(entries: FileEntry[]): boolean {
|
|
363
|
+
const header = entries.find((e) => e.type === "session");
|
|
364
|
+
if (!header?.timestamp) return false;
|
|
365
|
+
const start = new Date(header.timestamp);
|
|
366
|
+
if (Number.isNaN(start.getTime())) return false;
|
|
367
|
+
|
|
368
|
+
for (const entry of getActiveBranchEntries(entries)) {
|
|
369
|
+
const d = new Date(entry.timestamp);
|
|
370
|
+
if (!Number.isNaN(d.getTime())) return true;
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
}
|