@chigichan24/crune 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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +155 -0
  3. package/bin/crune.js +2 -0
  4. package/dist-cli/__tests__/cli.test.js +63 -0
  5. package/dist-cli/__tests__/clustering.test.js +200 -0
  6. package/dist-cli/__tests__/community.test.js +115 -0
  7. package/dist-cli/__tests__/edges.test.js +130 -0
  8. package/dist-cli/__tests__/feature-extraction.test.js +66 -0
  9. package/dist-cli/__tests__/fixtures.js +192 -0
  10. package/dist-cli/__tests__/orchestrator.test.js +253 -0
  11. package/dist-cli/__tests__/session-parser.test.js +335 -0
  12. package/dist-cli/__tests__/session-summarizer.test.js +117 -0
  13. package/dist-cli/__tests__/skill-server.test.js +191 -0
  14. package/dist-cli/__tests__/svd.test.js +112 -0
  15. package/dist-cli/__tests__/tfidf.test.js +88 -0
  16. package/dist-cli/__tests__/tokenizer.test.js +125 -0
  17. package/dist-cli/__tests__/topic-nodes.test.js +184 -0
  18. package/dist-cli/analyze-sessions.js +476 -0
  19. package/dist-cli/cli.js +215 -0
  20. package/dist-cli/knowledge-graph/clustering.js +174 -0
  21. package/dist-cli/knowledge-graph/community.js +220 -0
  22. package/dist-cli/knowledge-graph/constants.js +58 -0
  23. package/dist-cli/knowledge-graph/edges.js +193 -0
  24. package/dist-cli/knowledge-graph/feature-extraction.js +124 -0
  25. package/dist-cli/knowledge-graph/index.js +235 -0
  26. package/dist-cli/knowledge-graph/reusability.js +51 -0
  27. package/dist-cli/knowledge-graph/similarity.js +13 -0
  28. package/dist-cli/knowledge-graph/skill-generator.js +203 -0
  29. package/dist-cli/knowledge-graph/svd.js +195 -0
  30. package/dist-cli/knowledge-graph/tfidf.js +54 -0
  31. package/dist-cli/knowledge-graph/tokenizer.js +66 -0
  32. package/dist-cli/knowledge-graph/tool-pattern.js +173 -0
  33. package/dist-cli/knowledge-graph/topic-nodes.js +199 -0
  34. package/dist-cli/knowledge-graph/types.js +4 -0
  35. package/dist-cli/knowledge-graph-builder.js +27 -0
  36. package/dist-cli/session-parser.js +360 -0
  37. package/dist-cli/session-summarizer.js +133 -0
  38. package/dist-cli/skill-server.js +62 -0
  39. package/dist-cli/skill-synthesizer.js +189 -0
  40. package/package.json +47 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Topic node construction from session clusters.
3
+ */
4
+ import { ACTION_VERBS_EN, ACTION_VERBS_JA } from "./constants.js";
5
+ import { cosineSimilarity } from "./similarity.js";
6
+ export function extractDominantAction(prompts) {
7
+ const actionCounts = new Map();
8
+ for (const prompt of prompts) {
9
+ const lower = prompt.toLowerCase();
10
+ // English verbs
11
+ const words = lower.split(/\s+/);
12
+ for (const w of words) {
13
+ const clean = w.replace(/[^a-z]/g, "");
14
+ if (ACTION_VERBS_EN.has(clean)) {
15
+ actionCounts.set(clean, (actionCounts.get(clean) || 0) + 1);
16
+ }
17
+ }
18
+ // Japanese verbs
19
+ for (const [pattern, verb] of ACTION_VERBS_JA) {
20
+ if (pattern.test(prompt)) {
21
+ actionCounts.set(verb, (actionCounts.get(verb) || 0) + 1);
22
+ }
23
+ }
24
+ }
25
+ if (actionCounts.size === 0)
26
+ return "work on";
27
+ return [...actionCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
28
+ }
29
+ export function selectRepresentativePrompts(memberSessions, clusterCentroid, tfidfResult, maxCount = 3) {
30
+ const scored = [];
31
+ for (const s of memberSessions) {
32
+ const sessionVec = tfidfResult.vectors.get(s.sessionId);
33
+ if (!sessionVec)
34
+ continue;
35
+ const sim = cosineSimilarity(sessionVec, clusterCentroid);
36
+ for (const turn of s.turns) {
37
+ if (turn.userPrompt && turn.userPrompt.length > 10) {
38
+ scored.push({ prompt: turn.userPrompt, score: sim });
39
+ }
40
+ }
41
+ }
42
+ scored.sort((a, b) => b.score - a.score);
43
+ // Deduplicate similar prompts
44
+ const selected = [];
45
+ for (const { prompt } of scored) {
46
+ const trimmed = prompt.length > 150 ? prompt.slice(0, 150) + "..." : prompt;
47
+ if (!selected.some((s) => s === trimmed)) {
48
+ selected.push(trimmed);
49
+ if (selected.length >= maxCount)
50
+ break;
51
+ }
52
+ }
53
+ return selected;
54
+ }
55
+ export function generateSuggestedPrompt(memberSessions, keywords, toolIdf) {
56
+ // Collect all user prompts
57
+ const allPrompts = memberSessions.flatMap((s) => s.turns.map((t) => t.userPrompt).filter(Boolean));
58
+ // Extract dominant action
59
+ const action = extractDominantAction(allPrompts);
60
+ // Domain keywords (top 3)
61
+ const domain = keywords.slice(0, 3).join("/");
62
+ // Top tools by Tool-IDF weighted usage in this cluster
63
+ const clusterToolCounts = new Map();
64
+ for (const s of memberSessions) {
65
+ for (const [tool, count] of Object.entries(s.meta.toolBreakdown)) {
66
+ clusterToolCounts.set(tool, (clusterToolCounts.get(tool) || 0) + count);
67
+ }
68
+ }
69
+ const toolScores = [...clusterToolCounts.entries()].map(([tool, count]) => ({
70
+ tool,
71
+ score: Math.log(1 + count) * (toolIdf.toolIdfWeights.get(tool) || 1),
72
+ }));
73
+ toolScores.sort((a, b) => b.score - a.score);
74
+ const topTools = toolScores.slice(0, 3).map((t) => t.tool);
75
+ return `${action} ${domain} — tools: ${topTools.join(", ")}`;
76
+ }
77
+ export function computeToolSignature(memberSessions, toolIdf) {
78
+ const clusterToolCounts = new Map();
79
+ for (const s of memberSessions) {
80
+ for (const [tool, count] of Object.entries(s.meta.toolBreakdown)) {
81
+ clusterToolCounts.set(tool, (clusterToolCounts.get(tool) || 0) + count);
82
+ }
83
+ }
84
+ const scored = [...clusterToolCounts.entries()].map(([tool, count]) => ({
85
+ tool,
86
+ weight: Math.round(Math.log(1 + count) * (toolIdf.toolIdfWeights.get(tool) || 1) * 100) / 100,
87
+ }));
88
+ scored.sort((a, b) => b.weight - a.weight);
89
+ return scored.slice(0, 5);
90
+ }
91
+ export function classifyDominantRole(memberSessions) {
92
+ let totalUserTurns = 0;
93
+ let totalToolCalls = 0;
94
+ let totalSubagentCalls = 0;
95
+ for (const s of memberSessions) {
96
+ for (const turn of s.turns) {
97
+ if (turn.userPrompt)
98
+ totalUserTurns++;
99
+ totalToolCalls += turn.toolCalls.length;
100
+ totalSubagentCalls += turn.toolCalls.filter((tc) => tc.toolName === "Agent").length;
101
+ }
102
+ totalSubagentCalls += Object.keys(s.subagents).length;
103
+ }
104
+ const total = totalUserTurns + totalToolCalls + totalSubagentCalls || 1;
105
+ const subagentRatio = totalSubagentCalls / total;
106
+ const toolRatio = totalToolCalls / total;
107
+ if (subagentRatio > 0.15)
108
+ return "subagent-delegated";
109
+ if (toolRatio > 0.6)
110
+ return "tool-heavy";
111
+ return "user-driven";
112
+ }
113
+ export function buildTopicNodes(clusterMembers, sessions, tfidf, toolIdf) {
114
+ const topics = [];
115
+ for (let ci = 0; ci < clusterMembers.length; ci++) {
116
+ const members = clusterMembers[ci];
117
+ const memberSessions = members.map((idx) => sessions[idx]);
118
+ // Compute cluster centroid (TF-IDF text only, for keyword extraction)
119
+ const centroid = new Float64Array(tfidf.vocabulary.length);
120
+ for (const idx of members) {
121
+ const vec = tfidf.vectors.get(sessions[idx].sessionId);
122
+ if (vec) {
123
+ for (let k = 0; k < centroid.length; k++)
124
+ centroid[k] += vec[k];
125
+ }
126
+ }
127
+ for (let k = 0; k < centroid.length; k++)
128
+ centroid[k] /= members.length;
129
+ // Top-5 keywords from centroid
130
+ const scored = tfidf.vocabulary.map((term, idx) => ({
131
+ term,
132
+ score: centroid[idx],
133
+ }));
134
+ scored.sort((a, b) => b.score - a.score);
135
+ const keywords = scored.slice(0, 5).map((s) => s.term);
136
+ // Dominant project
137
+ const projectCounts = new Map();
138
+ for (const s of memberSessions) {
139
+ projectCounts.set(s.projectDisplayName, (projectCounts.get(s.projectDisplayName) || 0) + 1);
140
+ }
141
+ const sortedProjects = [...projectCounts.entries()].sort((a, b) => b[1] - a[1]);
142
+ const dominantProject = sortedProjects[0]?.[0] ?? "";
143
+ const allProjects = [...new Set(memberSessions.map((s) => s.projectDisplayName))];
144
+ // Label: top 2-3 keywords + project
145
+ const labelKeywords = keywords.slice(0, 3).join(", ");
146
+ const projectSuffix = allProjects.length > 1
147
+ ? `(${allProjects.length} projects)`
148
+ : `(${dominantProject.split("/").pop() || dominantProject})`;
149
+ const label = `${labelKeywords} ${projectSuffix}`;
150
+ // Aggregate metadata
151
+ const sessionIds = memberSessions.map((s) => s.sessionId);
152
+ const totalDuration = memberSessions.reduce((sum, s) => sum + s.meta.durationMinutes, 0);
153
+ const totalToolCalls = memberSessions.reduce((sum, s) => {
154
+ return (sum +
155
+ Object.values(s.meta.toolBreakdown).reduce((a, b) => a + b, 0));
156
+ }, 0);
157
+ const dates = memberSessions
158
+ .map((s) => s.meta.createdAt)
159
+ .filter(Boolean)
160
+ .sort();
161
+ // New fields: prompts, tool signature, role classification
162
+ // L2 normalize centroid for prompt selection
163
+ let centroidNorm = 0;
164
+ for (let k = 0; k < centroid.length; k++)
165
+ centroidNorm += centroid[k] * centroid[k];
166
+ centroidNorm = Math.sqrt(centroidNorm);
167
+ const normalizedCentroid = new Float64Array(centroid.length);
168
+ if (centroidNorm > 0) {
169
+ for (let k = 0; k < centroid.length; k++)
170
+ normalizedCentroid[k] = centroid[k] / centroidNorm;
171
+ }
172
+ const representativePrompts = selectRepresentativePrompts(memberSessions, normalizedCentroid, tfidf);
173
+ const suggestedPrompt = generateSuggestedPrompt(memberSessions, keywords, toolIdf);
174
+ const toolSignature = computeToolSignature(memberSessions, toolIdf);
175
+ const dominantRole = classifyDominantRole(memberSessions);
176
+ topics.push({
177
+ id: `topic-${String(ci + 1).padStart(3, "0")}`,
178
+ label,
179
+ keywords,
180
+ project: dominantProject,
181
+ projects: allProjects,
182
+ sessionIds,
183
+ sessionCount: members.length,
184
+ totalDurationMinutes: Math.round(totalDuration),
185
+ totalToolCalls,
186
+ firstSeen: dates[0] || "",
187
+ lastSeen: dates[dates.length - 1] || "",
188
+ betweennessCentrality: 0, // computed later
189
+ degreeCentrality: 0, // computed later
190
+ communityId: -1, // computed later
191
+ representativePrompts,
192
+ suggestedPrompt,
193
+ toolSignature,
194
+ dominantRole,
195
+ reusabilityScore: { overall: 0, frequency: 0, timeCost: 0, crossProjectScore: 0, recency: 0 }, // computed later
196
+ });
197
+ }
198
+ return topics;
199
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type definitions for semantic knowledge graph construction.
3
+ */
4
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * knowledge-graph-builder.ts
3
+ *
4
+ * Barrel re-export for backward compatibility.
5
+ * Implementation is in ./knowledge-graph/ submodules.
6
+ */
7
+ export {
8
+ // Main entry point
9
+ buildSemanticKnowledgeGraph,
10
+ // Tokenizer
11
+ tokenize, splitCamelCase, extractPathTokens, isNoiseToken,
12
+ // TF-IDF
13
+ buildTfidf,
14
+ // Feature extraction
15
+ buildToolIdf, buildStructuralVectors,
16
+ // SVD
17
+ buildCombinedMatrix, truncatedSvd, interpretLatentDimensions,
18
+ // Similarity
19
+ cosineSimilarity, cosineDistance,
20
+ // Clustering
21
+ agglomerativeClusteringFromDistMatrix, findElbowThreshold, clusterWithThresholdFromDistMatrix, splitOversizedClusters,
22
+ // Topic nodes
23
+ extractDominantAction, selectRepresentativePrompts, generateSuggestedPrompt, computeToolSignature, classifyDominantRole, buildTopicNodes,
24
+ // Edges
25
+ buildTopicEdges, classifyEdge, findSharedKeywords, findCommonPathPrefix,
26
+ // Community
27
+ louvainDetection, brandesBetweenness, } from "./knowledge-graph/index.js";
@@ -0,0 +1,360 @@
1
+ /**
2
+ * session-parser.ts
3
+ *
4
+ * Session discovery, JSONL parsing, turn building, and metadata extraction.
5
+ * Extracted from analyze-sessions.ts for testability and reuse.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as readline from "node:readline";
10
+ import * as os from "node:os";
11
+ // ─── Constants ──────────────────────────────────────────────────────────────
12
+ export const THINKING_LIMIT = 5000;
13
+ export const TOOL_RESULT_LIMIT = 2000;
14
+ export const WRITE_CONTENT_PREVIEW = 500;
15
+ export const FIRST_PROMPT_LIMIT = 200;
16
+ // ─── Session Discovery ─────────────────────────────────────────────────────
17
+ export function discoverSessions(sessionsDir) {
18
+ const sessions = [];
19
+ if (!fs.existsSync(sessionsDir)) {
20
+ console.error(`Sessions directory not found: ${sessionsDir}`);
21
+ return sessions;
22
+ }
23
+ const projectDirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
24
+ for (const entry of projectDirs) {
25
+ if (!entry.isDirectory())
26
+ continue;
27
+ const projectPath = path.join(sessionsDir, entry.name);
28
+ // Find .jsonl files directly in the project directory (not in subdirs)
29
+ const files = fs.readdirSync(projectPath, { withFileTypes: true });
30
+ for (const file of files) {
31
+ if (!file.isFile() || !file.name.endsWith(".jsonl"))
32
+ continue;
33
+ const sessionId = file.name.replace(".jsonl", "");
34
+ const filePath = path.join(projectPath, file.name);
35
+ // Look for subagent files
36
+ const subagentDir = path.join(projectPath, sessionId, "subagents");
37
+ const subagentFiles = [];
38
+ if (fs.existsSync(subagentDir)) {
39
+ const subFiles = fs.readdirSync(subagentDir, { withFileTypes: true });
40
+ for (const sf of subFiles) {
41
+ if (sf.isFile() && sf.name.endsWith(".jsonl")) {
42
+ subagentFiles.push(path.join(subagentDir, sf.name));
43
+ }
44
+ }
45
+ }
46
+ sessions.push({
47
+ filePath,
48
+ sessionId,
49
+ projectDir: entry.name,
50
+ projectDisplayName: inferProjectName(entry.name),
51
+ subagentFiles,
52
+ });
53
+ }
54
+ }
55
+ return sessions;
56
+ }
57
+ export function inferProjectName(dirName) {
58
+ // Directory names look like: -Users-kazuki-chigita-src-github-com-chigichan24-crune
59
+ // We want: chigichan24/crune (the last two meaningful segments)
60
+ const parts = dirName.split("-").filter(Boolean);
61
+ if (parts.length >= 2) {
62
+ // Try to find github-com pattern
63
+ const githubIdx = parts.indexOf("github");
64
+ if (githubIdx !== -1 && parts[githubIdx + 1] === "com" && parts.length > githubIdx + 3) {
65
+ return parts.slice(githubIdx + 2).join("/");
66
+ }
67
+ // Fallback: last two segments
68
+ return parts.slice(-2).join("/");
69
+ }
70
+ return dirName;
71
+ }
72
+ // ─── JSONL Parser + Turn Builder ────────────────────────────────────────────
73
+ export async function parseJsonlFile(filePath) {
74
+ const lines = [];
75
+ const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
76
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
77
+ for await (const line of rl) {
78
+ if (!line.trim())
79
+ continue;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ // Skip progress and queue-operation lines
83
+ if (parsed.type === "progress" || parsed.type === "queue-operation")
84
+ continue;
85
+ lines.push(parsed);
86
+ }
87
+ catch {
88
+ // Malformed JSON — skip and warn
89
+ console.error(` [WARN] Malformed JSON line in ${path.basename(filePath)}`);
90
+ }
91
+ }
92
+ return lines;
93
+ }
94
+ export function truncate(text, limit) {
95
+ if (text.length <= limit)
96
+ return text;
97
+ return text.slice(0, limit) + "\u2026";
98
+ }
99
+ export function isRealUserMessage(line) {
100
+ if (line.type !== "user")
101
+ return false;
102
+ if (line.isMeta)
103
+ return false;
104
+ const content = line.message?.content;
105
+ if (typeof content === "string") {
106
+ if (content.includes("<command-name>"))
107
+ return false;
108
+ if (content.includes("<local-command-caveat>"))
109
+ return false;
110
+ if (content.includes("<local-command-stdout>"))
111
+ return false;
112
+ if (content.trim().length === 0)
113
+ return false;
114
+ return true;
115
+ }
116
+ // Array content — check if it's a tool_result (which is NOT a new user turn)
117
+ if (Array.isArray(content)) {
118
+ const hasToolResult = content.some((block) => block.type === "tool_result");
119
+ if (hasToolResult)
120
+ return false;
121
+ // It's a real user message with structured content
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ export function isToolResultMessage(line) {
127
+ if (line.type !== "user")
128
+ return false;
129
+ const content = line.message?.content;
130
+ if (!Array.isArray(content))
131
+ return false;
132
+ return content.some((block) => block.type === "tool_result");
133
+ }
134
+ export function extractUserPrompt(line) {
135
+ const content = line.message?.content;
136
+ if (typeof content === "string")
137
+ return content;
138
+ if (Array.isArray(content)) {
139
+ const textBlocks = content.filter((b) => b.type === "text");
140
+ if (textBlocks.length > 0) {
141
+ return textBlocks.map((b) => b.text || "").join("\n");
142
+ }
143
+ }
144
+ return "";
145
+ }
146
+ export function buildTurns(lines) {
147
+ const turns = [];
148
+ let currentTurn = null;
149
+ let turnIndex = 0;
150
+ for (const line of lines) {
151
+ // Skip file-history-snapshot and system lines
152
+ if (line.type === "file-history-snapshot" || line.type === "system")
153
+ continue;
154
+ // Real user message -> start new turn
155
+ if (isRealUserMessage(line)) {
156
+ if (currentTurn) {
157
+ turns.push(currentTurn);
158
+ }
159
+ currentTurn = {
160
+ turnIndex: turnIndex++,
161
+ userPrompt: extractUserPrompt(line),
162
+ timestamp: line.timestamp || "",
163
+ assistantThinking: [],
164
+ assistantTexts: [],
165
+ toolCalls: [],
166
+ model: undefined,
167
+ };
168
+ continue;
169
+ }
170
+ // Tool result -> attach to current turn's matching tool call
171
+ if (isToolResultMessage(line) && currentTurn) {
172
+ const content = line.message?.content;
173
+ for (const block of content) {
174
+ if (block.type === "tool_result" && block.tool_use_id) {
175
+ const matchingTool = currentTurn.toolCalls.find((tc) => tc.toolUseId === block.tool_use_id);
176
+ if (matchingTool) {
177
+ const resultText = typeof block.content === "string"
178
+ ? block.content
179
+ : Array.isArray(block.content)
180
+ ? block.content
181
+ .map((c) => c.text || "")
182
+ .join("\n")
183
+ : JSON.stringify(block.content || "");
184
+ matchingTool.result = truncate(resultText, TOOL_RESULT_LIMIT);
185
+ }
186
+ }
187
+ }
188
+ continue;
189
+ }
190
+ // Assistant message -> add to current turn
191
+ if (line.type === "assistant" && currentTurn) {
192
+ const content = line.message?.content;
193
+ const model = line.message?.model;
194
+ if (model && !currentTurn.model) {
195
+ currentTurn.model = model;
196
+ }
197
+ if (Array.isArray(content)) {
198
+ for (const block of content) {
199
+ if (block.type === "thinking" && block.thinking) {
200
+ currentTurn.assistantThinking.push(truncate(block.thinking, THINKING_LIMIT));
201
+ }
202
+ else if (block.type === "text" && block.text) {
203
+ currentTurn.assistantTexts.push(block.text);
204
+ }
205
+ else if (block.type === "tool_use") {
206
+ const input = { ...block.input };
207
+ // Truncate Write.content
208
+ if (block.name === "Write" &&
209
+ typeof input.content === "string") {
210
+ const fullLen = input.content.length;
211
+ input.content =
212
+ truncate(input.content, WRITE_CONTENT_PREVIEW);
213
+ input.contentLength = fullLen;
214
+ }
215
+ currentTurn.toolCalls.push({
216
+ toolUseId: block.id || "",
217
+ toolName: block.name || "unknown",
218
+ input,
219
+ result: undefined,
220
+ });
221
+ }
222
+ }
223
+ }
224
+ continue;
225
+ }
226
+ }
227
+ // Push last turn
228
+ if (currentTurn) {
229
+ turns.push(currentTurn);
230
+ }
231
+ return turns;
232
+ }
233
+ // ─── Metadata Extraction ────────────────────────────────────────────────────
234
+ export function extractMetadata(sessionFile, lines, turns) {
235
+ let cwd = "";
236
+ let gitBranch = "";
237
+ let version = "";
238
+ let slug = "";
239
+ let permissionMode = "";
240
+ let createdAt = "";
241
+ let lastActiveAt = "";
242
+ const toolBreakdown = {};
243
+ const modelsUsed = {};
244
+ const filesEdited = new Set();
245
+ for (const line of lines) {
246
+ // Extract metadata from any line that has these fields
247
+ if (line.cwd && !cwd)
248
+ cwd = line.cwd;
249
+ if (line.gitBranch && !gitBranch)
250
+ gitBranch = line.gitBranch;
251
+ if (line.version && !version)
252
+ version = line.version;
253
+ if (line.slug && !slug)
254
+ slug = line.slug;
255
+ if (line.permissionMode && !permissionMode)
256
+ permissionMode = line.permissionMode;
257
+ // Track timestamps
258
+ if (line.timestamp) {
259
+ if (!createdAt || line.timestamp < createdAt)
260
+ createdAt = line.timestamp;
261
+ if (!lastActiveAt || line.timestamp > lastActiveAt)
262
+ lastActiveAt = line.timestamp;
263
+ }
264
+ // Extract model usage from assistant messages
265
+ if (line.type === "assistant" && line.message?.model) {
266
+ const model = line.message.model;
267
+ modelsUsed[model] = (modelsUsed[model] || 0) + 1;
268
+ }
269
+ }
270
+ // Extract tool breakdown and files edited from turns
271
+ for (const turn of turns) {
272
+ for (const tc of turn.toolCalls) {
273
+ toolBreakdown[tc.toolName] = (toolBreakdown[tc.toolName] || 0) + 1;
274
+ // Track files edited via Edit/Write
275
+ if (tc.toolName === "Edit" || tc.toolName === "Write") {
276
+ const fp = tc.input.file_path;
277
+ if (typeof fp === "string") {
278
+ filesEdited.add(fp);
279
+ }
280
+ }
281
+ }
282
+ }
283
+ const durationMinutes = createdAt && lastActiveAt
284
+ ? Math.round((new Date(lastActiveAt).getTime() - new Date(createdAt).getTime()) /
285
+ 60000)
286
+ : 0;
287
+ const firstPrompt = turns.length > 0 ? turns[0].userPrompt : "";
288
+ return {
289
+ sessionId: sessionFile.sessionId,
290
+ cwd,
291
+ gitBranch,
292
+ version,
293
+ slug,
294
+ createdAt,
295
+ lastActiveAt,
296
+ durationMinutes,
297
+ permissionMode,
298
+ toolBreakdown,
299
+ modelsUsed,
300
+ filesEdited: [...filesEdited],
301
+ subagentCount: sessionFile.subagentFiles.length,
302
+ turnCount: turns.length,
303
+ firstUserPrompt: truncate(firstPrompt, FIRST_PROMPT_LIMIT),
304
+ };
305
+ }
306
+ // ─── Subagent Linking ───────────────────────────────────────────────────────
307
+ export async function parseSubagents(subagentFiles) {
308
+ const subagents = {};
309
+ for (const filePath of subagentFiles) {
310
+ const basename = path.basename(filePath, ".jsonl");
311
+ // agent-a8edec15cbcf8cf42.jsonl -> agentId = a8edec15cbcf8cf42
312
+ const agentId = basename.replace("agent-", "");
313
+ // Read meta.json if exists
314
+ const metaPath = filePath.replace(".jsonl", ".meta.json");
315
+ let agentType = "unknown";
316
+ if (fs.existsSync(metaPath)) {
317
+ try {
318
+ const metaContent = fs.readFileSync(metaPath, "utf-8");
319
+ const meta = JSON.parse(metaContent);
320
+ agentType = meta.agentType || "unknown";
321
+ }
322
+ catch {
323
+ // ignore
324
+ }
325
+ }
326
+ const lines = await parseJsonlFile(filePath);
327
+ const turns = buildTurns(lines);
328
+ // Extract model from first assistant line
329
+ let model;
330
+ for (const line of lines) {
331
+ if (line.type === "assistant" && line.message?.model) {
332
+ model = line.message.model;
333
+ break;
334
+ }
335
+ }
336
+ subagents[agentId] = {
337
+ agentId,
338
+ agentType,
339
+ turns,
340
+ model,
341
+ };
342
+ }
343
+ return subagents;
344
+ }
345
+ // ─── Linked Plan ────────────────────────────────────────────────────────────
346
+ export function loadLinkedPlan(slug) {
347
+ if (!slug)
348
+ return null;
349
+ const planPath = path.join(os.homedir(), ".claude", "plans", `${slug}.md`);
350
+ if (fs.existsSync(planPath)) {
351
+ try {
352
+ const content = fs.readFileSync(planPath, "utf-8");
353
+ return { slug, content };
354
+ }
355
+ catch {
356
+ return null;
357
+ }
358
+ }
359
+ return null;
360
+ }