@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.
- package/LICENSE +201 -0
- package/README.md +155 -0
- package/bin/crune.js +2 -0
- package/dist-cli/__tests__/cli.test.js +63 -0
- package/dist-cli/__tests__/clustering.test.js +200 -0
- package/dist-cli/__tests__/community.test.js +115 -0
- package/dist-cli/__tests__/edges.test.js +130 -0
- package/dist-cli/__tests__/feature-extraction.test.js +66 -0
- package/dist-cli/__tests__/fixtures.js +192 -0
- package/dist-cli/__tests__/orchestrator.test.js +253 -0
- package/dist-cli/__tests__/session-parser.test.js +335 -0
- package/dist-cli/__tests__/session-summarizer.test.js +117 -0
- package/dist-cli/__tests__/skill-server.test.js +191 -0
- package/dist-cli/__tests__/svd.test.js +112 -0
- package/dist-cli/__tests__/tfidf.test.js +88 -0
- package/dist-cli/__tests__/tokenizer.test.js +125 -0
- package/dist-cli/__tests__/topic-nodes.test.js +184 -0
- package/dist-cli/analyze-sessions.js +476 -0
- package/dist-cli/cli.js +215 -0
- package/dist-cli/knowledge-graph/clustering.js +174 -0
- package/dist-cli/knowledge-graph/community.js +220 -0
- package/dist-cli/knowledge-graph/constants.js +58 -0
- package/dist-cli/knowledge-graph/edges.js +193 -0
- package/dist-cli/knowledge-graph/feature-extraction.js +124 -0
- package/dist-cli/knowledge-graph/index.js +235 -0
- package/dist-cli/knowledge-graph/reusability.js +51 -0
- package/dist-cli/knowledge-graph/similarity.js +13 -0
- package/dist-cli/knowledge-graph/skill-generator.js +203 -0
- package/dist-cli/knowledge-graph/svd.js +195 -0
- package/dist-cli/knowledge-graph/tfidf.js +54 -0
- package/dist-cli/knowledge-graph/tokenizer.js +66 -0
- package/dist-cli/knowledge-graph/tool-pattern.js +173 -0
- package/dist-cli/knowledge-graph/topic-nodes.js +199 -0
- package/dist-cli/knowledge-graph/types.js +4 -0
- package/dist-cli/knowledge-graph-builder.js +27 -0
- package/dist-cli/session-parser.js +360 -0
- package/dist-cli/session-summarizer.js +133 -0
- package/dist-cli/skill-server.js +62 -0
- package/dist-cli/skill-synthesizer.js +189 -0
- 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,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
|
+
}
|