@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,184 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractDominantAction, classifyDominantRole, computeToolSignature, } from "../knowledge-graph-builder.js";
|
|
3
|
+
function makeSession(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
sessionId: "s1",
|
|
6
|
+
projectDisplayName: "test-project",
|
|
7
|
+
turns: [],
|
|
8
|
+
subagents: {},
|
|
9
|
+
meta: {
|
|
10
|
+
sessionId: "s1",
|
|
11
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
12
|
+
lastActiveAt: "2025-01-01T01:00:00Z",
|
|
13
|
+
durationMinutes: 60,
|
|
14
|
+
filesEdited: [],
|
|
15
|
+
gitBranch: "main",
|
|
16
|
+
toolBreakdown: {},
|
|
17
|
+
subagentCount: 0,
|
|
18
|
+
},
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function makeToolIdf(tools, idfWeights) {
|
|
23
|
+
const toolVocabIndex = new Map();
|
|
24
|
+
tools.forEach((t, i) => toolVocabIndex.set(t, i));
|
|
25
|
+
return {
|
|
26
|
+
toolVocabulary: tools,
|
|
27
|
+
toolVocabIndex,
|
|
28
|
+
toolIdfWeights: new Map(Object.entries(idfWeights)),
|
|
29
|
+
vectors: new Map(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
describe("extractDominantAction", () => {
|
|
33
|
+
it('returns "fix" when fix is the most common English verb', () => {
|
|
34
|
+
const prompts = ["fix the bug", "fix another bug"];
|
|
35
|
+
expect(extractDominantAction(prompts)).toBe("fix");
|
|
36
|
+
});
|
|
37
|
+
it('maps Japanese 修正 to "fix"', () => {
|
|
38
|
+
const prompts = ["修正してください", "修正お願い"];
|
|
39
|
+
expect(extractDominantAction(prompts)).toBe("fix");
|
|
40
|
+
});
|
|
41
|
+
it('returns "work on" when no recognized action verbs are found', () => {
|
|
42
|
+
const prompts = ["hello world"];
|
|
43
|
+
expect(extractDominantAction(prompts)).toBe("work on");
|
|
44
|
+
});
|
|
45
|
+
it("returns the most frequent verb when multiple are present", () => {
|
|
46
|
+
const prompts = [
|
|
47
|
+
"add a new feature",
|
|
48
|
+
"add another thing",
|
|
49
|
+
"add the component",
|
|
50
|
+
"fix the bug",
|
|
51
|
+
];
|
|
52
|
+
expect(extractDominantAction(prompts)).toBe("add");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("classifyDominantRole", () => {
|
|
56
|
+
it('returns "subagent-delegated" when subagent ratio > 0.15', () => {
|
|
57
|
+
const session = makeSession({
|
|
58
|
+
turns: [
|
|
59
|
+
{
|
|
60
|
+
userPrompt: "do something",
|
|
61
|
+
assistantTexts: ["ok"],
|
|
62
|
+
toolCalls: [
|
|
63
|
+
{ toolName: "Agent", input: {} },
|
|
64
|
+
{ toolName: "Agent", input: {} },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
subagents: {
|
|
69
|
+
"agent-1": {
|
|
70
|
+
agentId: "agent-1",
|
|
71
|
+
agentType: "code",
|
|
72
|
+
turns: [],
|
|
73
|
+
},
|
|
74
|
+
"agent-2": {
|
|
75
|
+
agentId: "agent-2",
|
|
76
|
+
agentType: "code",
|
|
77
|
+
turns: [],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(classifyDominantRole([session])).toBe("subagent-delegated");
|
|
82
|
+
});
|
|
83
|
+
it('returns "tool-heavy" when tool ratio > 0.6', () => {
|
|
84
|
+
const session = makeSession({
|
|
85
|
+
turns: [
|
|
86
|
+
{
|
|
87
|
+
userPrompt: "run tools",
|
|
88
|
+
assistantTexts: ["ok"],
|
|
89
|
+
toolCalls: [
|
|
90
|
+
{ toolName: "Bash", input: {} },
|
|
91
|
+
{ toolName: "Read", input: {} },
|
|
92
|
+
{ toolName: "Grep", input: {} },
|
|
93
|
+
{ toolName: "Bash", input: {} },
|
|
94
|
+
{ toolName: "Edit", input: {} },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
subagents: {},
|
|
99
|
+
});
|
|
100
|
+
expect(classifyDominantRole([session])).toBe("tool-heavy");
|
|
101
|
+
});
|
|
102
|
+
it('returns "user-driven" when neither ratio is high', () => {
|
|
103
|
+
const sessions = [
|
|
104
|
+
makeSession({
|
|
105
|
+
turns: [
|
|
106
|
+
{ userPrompt: "prompt 1", assistantTexts: ["ok"], toolCalls: [] },
|
|
107
|
+
{ userPrompt: "prompt 2", assistantTexts: ["ok"], toolCalls: [] },
|
|
108
|
+
{
|
|
109
|
+
userPrompt: "prompt 3",
|
|
110
|
+
assistantTexts: ["ok"],
|
|
111
|
+
toolCalls: [{ toolName: "Read", input: {} }],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
subagents: {},
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
expect(classifyDominantRole(sessions)).toBe("user-driven");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe("computeToolSignature", () => {
|
|
121
|
+
it("returns sorted array with max 5 items, scored by log(1+count)*IDF", () => {
|
|
122
|
+
const session = makeSession({
|
|
123
|
+
meta: {
|
|
124
|
+
sessionId: "s1",
|
|
125
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
126
|
+
lastActiveAt: "2025-01-01T01:00:00Z",
|
|
127
|
+
durationMinutes: 60,
|
|
128
|
+
filesEdited: [],
|
|
129
|
+
gitBranch: "main",
|
|
130
|
+
toolBreakdown: {
|
|
131
|
+
Bash: 50,
|
|
132
|
+
Read: 30,
|
|
133
|
+
Edit: 20,
|
|
134
|
+
Grep: 10,
|
|
135
|
+
Glob: 5,
|
|
136
|
+
Write: 3,
|
|
137
|
+
Agent: 1,
|
|
138
|
+
},
|
|
139
|
+
subagentCount: 0,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const toolIdf = makeToolIdf(["Bash", "Read", "Edit", "Grep", "Glob", "Write", "Agent"], {
|
|
143
|
+
Bash: 1.0,
|
|
144
|
+
Read: 1.5,
|
|
145
|
+
Edit: 2.0,
|
|
146
|
+
Grep: 1.8,
|
|
147
|
+
Glob: 2.5,
|
|
148
|
+
Write: 3.0,
|
|
149
|
+
Agent: 4.0,
|
|
150
|
+
});
|
|
151
|
+
const result = computeToolSignature([session], toolIdf);
|
|
152
|
+
// Max 5 items
|
|
153
|
+
expect(result.length).toBeLessThanOrEqual(5);
|
|
154
|
+
// Sorted descending by weight
|
|
155
|
+
for (let i = 1; i < result.length; i++) {
|
|
156
|
+
expect(result[i - 1].weight).toBeGreaterThanOrEqual(result[i].weight);
|
|
157
|
+
}
|
|
158
|
+
// Each entry has tool and weight
|
|
159
|
+
for (const entry of result) {
|
|
160
|
+
expect(entry).toHaveProperty("tool");
|
|
161
|
+
expect(entry).toHaveProperty("weight");
|
|
162
|
+
expect(typeof entry.tool).toBe("string");
|
|
163
|
+
expect(typeof entry.weight).toBe("number");
|
|
164
|
+
expect(entry.weight).toBeGreaterThan(0);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it("returns empty array when sessions have no tools", () => {
|
|
168
|
+
const session = makeSession({
|
|
169
|
+
meta: {
|
|
170
|
+
sessionId: "s1",
|
|
171
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
172
|
+
lastActiveAt: "2025-01-01T01:00:00Z",
|
|
173
|
+
durationMinutes: 60,
|
|
174
|
+
filesEdited: [],
|
|
175
|
+
gitBranch: "main",
|
|
176
|
+
toolBreakdown: {},
|
|
177
|
+
subagentCount: 0,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const toolIdf = makeToolIdf([], {});
|
|
181
|
+
const result = computeToolSignature([session], toolIdf);
|
|
182
|
+
expect(result).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyze-sessions.ts
|
|
3
|
+
*
|
|
4
|
+
* Data pipeline that reads Claude Code JSONL session logs and generates
|
|
5
|
+
* JSON files for the crune web UI.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx tsx scripts/analyze-sessions.ts [--sessions-dir <path>] [--output-dir <path>]
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import { buildSemanticKnowledgeGraph, } from "./knowledge-graph-builder.js";
|
|
14
|
+
import { buildSynthesisPrompt, synthesizeWithClaude } from "./skill-synthesizer.js";
|
|
15
|
+
import { generateSessionSummary } from "./session-summarizer.js";
|
|
16
|
+
import { discoverSessions, parseJsonlFile, buildTurns, extractMetadata, parseSubagents, loadLinkedPlan, } from "./session-parser.js";
|
|
17
|
+
function parseArgs() {
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
let sessionsDir = path.join(os.homedir(), ".claude", "projects");
|
|
20
|
+
let outputDir = path.resolve("public", "data", "sessions");
|
|
21
|
+
let skipSynthesis = false;
|
|
22
|
+
let synthesisModel;
|
|
23
|
+
let synthesisCount = 5;
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
if (args[i] === "--sessions-dir" && args[i + 1]) {
|
|
26
|
+
sessionsDir = path.resolve(args[++i]);
|
|
27
|
+
}
|
|
28
|
+
else if (args[i] === "--output-dir" && args[i + 1]) {
|
|
29
|
+
outputDir = path.resolve(args[++i]);
|
|
30
|
+
}
|
|
31
|
+
else if (args[i] === "--skip-synthesis") {
|
|
32
|
+
skipSynthesis = true;
|
|
33
|
+
}
|
|
34
|
+
else if (args[i] === "--synthesis-model" && args[i + 1]) {
|
|
35
|
+
synthesisModel = args[++i];
|
|
36
|
+
}
|
|
37
|
+
else if (args[i] === "--synthesis-count" && args[i + 1]) {
|
|
38
|
+
synthesisCount = Math.max(1, parseInt(args[++i], 10) || 5);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { sessionsDir, outputDir, skipSynthesis, synthesisModel, synthesisCount };
|
|
42
|
+
}
|
|
43
|
+
// ─── Task 1.5: index.json Generation ────────────────────────────────────────
|
|
44
|
+
function generateIndex(sessions) {
|
|
45
|
+
const projectMap = new Map();
|
|
46
|
+
const sessionSummaries = sessions.map((s) => {
|
|
47
|
+
const existing = projectMap.get(s.projectDisplayName) || {
|
|
48
|
+
count: 0,
|
|
49
|
+
duration: 0,
|
|
50
|
+
};
|
|
51
|
+
projectMap.set(s.projectDisplayName, {
|
|
52
|
+
count: existing.count + 1,
|
|
53
|
+
duration: existing.duration + s.meta.durationMinutes,
|
|
54
|
+
});
|
|
55
|
+
const summaryInfo = generateSessionSummary(s.turns.map((t) => ({ userPrompt: t.userPrompt, permissionMode: s.meta.permissionMode })), {
|
|
56
|
+
toolBreakdown: s.meta.toolBreakdown,
|
|
57
|
+
filesEdited: s.meta.filesEdited,
|
|
58
|
+
permissionMode: s.meta.permissionMode,
|
|
59
|
+
turnCount: s.meta.turnCount,
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
sessionId: s.meta.sessionId,
|
|
63
|
+
project: s.projectDisplayName,
|
|
64
|
+
cwd: s.meta.cwd,
|
|
65
|
+
gitBranch: s.meta.gitBranch,
|
|
66
|
+
slug: s.meta.slug,
|
|
67
|
+
createdAt: s.meta.createdAt,
|
|
68
|
+
lastActiveAt: s.meta.lastActiveAt,
|
|
69
|
+
durationMinutes: s.meta.durationMinutes,
|
|
70
|
+
turnCount: s.meta.turnCount,
|
|
71
|
+
toolBreakdown: s.meta.toolBreakdown,
|
|
72
|
+
firstUserPrompt: s.meta.firstUserPrompt,
|
|
73
|
+
summaryText: summaryInfo.summary,
|
|
74
|
+
keywords: summaryInfo.keywords,
|
|
75
|
+
scope: summaryInfo.scope,
|
|
76
|
+
workType: summaryInfo.workType,
|
|
77
|
+
permissionMode: s.meta.permissionMode,
|
|
78
|
+
subagentCount: s.meta.subagentCount,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
// Sort sessions by createdAt descending
|
|
82
|
+
sessionSummaries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
83
|
+
const projects = [...projectMap.entries()].map(([name, data]) => ({
|
|
84
|
+
name,
|
|
85
|
+
sessionCount: data.count,
|
|
86
|
+
totalDurationMinutes: data.duration,
|
|
87
|
+
}));
|
|
88
|
+
projects.sort((a, b) => b.sessionCount - a.sessionCount);
|
|
89
|
+
return {
|
|
90
|
+
generatedAt: new Date().toISOString(),
|
|
91
|
+
totalSessions: sessions.length,
|
|
92
|
+
projects,
|
|
93
|
+
sessions: sessionSummaries,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// ─── Task 1.6: detail/{sessionId}.json Generation ───────────────────────────
|
|
97
|
+
function generateDetail(session) {
|
|
98
|
+
return {
|
|
99
|
+
sessionId: session.meta.sessionId,
|
|
100
|
+
meta: {
|
|
101
|
+
project: session.projectDisplayName,
|
|
102
|
+
cwd: session.meta.cwd,
|
|
103
|
+
branch: session.meta.gitBranch,
|
|
104
|
+
slug: session.meta.slug,
|
|
105
|
+
version: session.meta.version,
|
|
106
|
+
createdAt: session.meta.createdAt,
|
|
107
|
+
lastActiveAt: session.meta.lastActiveAt,
|
|
108
|
+
durationMinutes: session.meta.durationMinutes,
|
|
109
|
+
permissionMode: session.meta.permissionMode,
|
|
110
|
+
},
|
|
111
|
+
turns: session.turns,
|
|
112
|
+
subagents: session.subagents,
|
|
113
|
+
linkedPlan: session.linkedPlan,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function generateOverview(sessions, synthesisConfig = { skip: false, count: 5 }) {
|
|
117
|
+
// Activity heatmap: 7 days x 24 hours
|
|
118
|
+
const heatmap = Array.from({ length: 7 }, () => Array(24).fill(0));
|
|
119
|
+
// Project distribution
|
|
120
|
+
const projectMap = new Map();
|
|
121
|
+
// Weekly tool trends
|
|
122
|
+
const weeklyTools = new Map();
|
|
123
|
+
// Duration distribution buckets
|
|
124
|
+
const durationBuckets = {
|
|
125
|
+
"0-5min": 0,
|
|
126
|
+
"5-15min": 0,
|
|
127
|
+
"15-30min": 0,
|
|
128
|
+
"30-60min": 0,
|
|
129
|
+
"60-120min": 0,
|
|
130
|
+
"120min+": 0,
|
|
131
|
+
};
|
|
132
|
+
// File edit counts
|
|
133
|
+
const fileEditCounts = new Map();
|
|
134
|
+
// Model usage
|
|
135
|
+
const modelCounts = new Map();
|
|
136
|
+
// Knowledge graph (built separately via semantic pipeline)
|
|
137
|
+
// Tacit knowledge
|
|
138
|
+
const projectPlanMode = new Map();
|
|
139
|
+
const toolSequences = [];
|
|
140
|
+
const sessionFileEdits = new Map();
|
|
141
|
+
for (const session of sessions) {
|
|
142
|
+
const { meta } = session;
|
|
143
|
+
// Heatmap
|
|
144
|
+
if (meta.createdAt) {
|
|
145
|
+
const date = new Date(meta.createdAt);
|
|
146
|
+
const day = date.getDay(); // 0=Sun
|
|
147
|
+
const hour = date.getHours();
|
|
148
|
+
heatmap[day][hour]++;
|
|
149
|
+
}
|
|
150
|
+
// Project distribution
|
|
151
|
+
const projData = projectMap.get(session.projectDisplayName) || {
|
|
152
|
+
sessionCount: 0,
|
|
153
|
+
totalDurationMinutes: 0,
|
|
154
|
+
};
|
|
155
|
+
projData.sessionCount++;
|
|
156
|
+
projData.totalDurationMinutes += meta.durationMinutes;
|
|
157
|
+
projectMap.set(session.projectDisplayName, projData);
|
|
158
|
+
// Weekly tool trends
|
|
159
|
+
if (meta.createdAt) {
|
|
160
|
+
const weekLabel = getWeekLabel(new Date(meta.createdAt));
|
|
161
|
+
const weekTools = weeklyTools.get(weekLabel) || {};
|
|
162
|
+
for (const [tool, count] of Object.entries(meta.toolBreakdown)) {
|
|
163
|
+
weekTools[tool] = (weekTools[tool] || 0) + count;
|
|
164
|
+
}
|
|
165
|
+
weeklyTools.set(weekLabel, weekTools);
|
|
166
|
+
}
|
|
167
|
+
// Duration distribution
|
|
168
|
+
const dur = meta.durationMinutes;
|
|
169
|
+
if (dur < 5)
|
|
170
|
+
durationBuckets["0-5min"]++;
|
|
171
|
+
else if (dur < 15)
|
|
172
|
+
durationBuckets["5-15min"]++;
|
|
173
|
+
else if (dur < 30)
|
|
174
|
+
durationBuckets["15-30min"]++;
|
|
175
|
+
else if (dur < 60)
|
|
176
|
+
durationBuckets["30-60min"]++;
|
|
177
|
+
else if (dur < 120)
|
|
178
|
+
durationBuckets["60-120min"]++;
|
|
179
|
+
else
|
|
180
|
+
durationBuckets["120min+"]++;
|
|
181
|
+
// File edit counts
|
|
182
|
+
for (const file of meta.filesEdited) {
|
|
183
|
+
fileEditCounts.set(file, (fileEditCounts.get(file) || 0) + 1);
|
|
184
|
+
}
|
|
185
|
+
// Track per-session file edit counts for pain points
|
|
186
|
+
const sessionFileEditMap = new Map();
|
|
187
|
+
for (const turn of session.turns) {
|
|
188
|
+
for (const tc of turn.toolCalls) {
|
|
189
|
+
if (tc.toolName === "Edit" && typeof tc.input.file_path === "string") {
|
|
190
|
+
const fp = tc.input.file_path;
|
|
191
|
+
sessionFileEditMap.set(fp, (sessionFileEditMap.get(fp) || 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
sessionFileEdits.set(meta.sessionId, sessionFileEditMap);
|
|
196
|
+
// Model usage
|
|
197
|
+
for (const [model, count] of Object.entries(meta.modelsUsed)) {
|
|
198
|
+
modelCounts.set(model, (modelCounts.get(model) || 0) + count);
|
|
199
|
+
}
|
|
200
|
+
// Tacit knowledge: plan mode usage
|
|
201
|
+
const projPlan = projectPlanMode.get(session.projectDisplayName) || {
|
|
202
|
+
planCount: 0,
|
|
203
|
+
totalCount: 0,
|
|
204
|
+
};
|
|
205
|
+
projPlan.totalCount++;
|
|
206
|
+
if (meta.permissionMode === "plan")
|
|
207
|
+
projPlan.planCount++;
|
|
208
|
+
projectPlanMode.set(session.projectDisplayName, projPlan);
|
|
209
|
+
// Tool sequences (3-grams)
|
|
210
|
+
const toolNames = session.turns.flatMap((t) => t.toolCalls.map((tc) => tc.toolName));
|
|
211
|
+
for (let i = 0; i <= toolNames.length - 3; i++) {
|
|
212
|
+
toolSequences.push(toolNames.slice(i, i + 3));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Build semantic knowledge graph
|
|
216
|
+
const sessionInputs = sessions.map((s) => ({
|
|
217
|
+
sessionId: s.meta.sessionId,
|
|
218
|
+
projectDisplayName: s.projectDisplayName,
|
|
219
|
+
turns: s.turns.map((t) => ({
|
|
220
|
+
userPrompt: t.userPrompt,
|
|
221
|
+
assistantTexts: t.assistantTexts,
|
|
222
|
+
toolCalls: t.toolCalls.map((tc) => ({
|
|
223
|
+
toolName: tc.toolName,
|
|
224
|
+
input: tc.input,
|
|
225
|
+
})),
|
|
226
|
+
})),
|
|
227
|
+
subagents: Object.fromEntries(Object.entries(s.subagents).map(([id, sub]) => [
|
|
228
|
+
id,
|
|
229
|
+
{
|
|
230
|
+
agentId: sub.agentId,
|
|
231
|
+
agentType: sub.agentType,
|
|
232
|
+
turns: sub.turns.map((t) => ({
|
|
233
|
+
userPrompt: t.userPrompt,
|
|
234
|
+
assistantTexts: t.assistantTexts,
|
|
235
|
+
toolCalls: t.toolCalls.map((tc) => ({
|
|
236
|
+
toolName: tc.toolName,
|
|
237
|
+
input: tc.input,
|
|
238
|
+
})),
|
|
239
|
+
})),
|
|
240
|
+
},
|
|
241
|
+
])),
|
|
242
|
+
meta: {
|
|
243
|
+
sessionId: s.meta.sessionId,
|
|
244
|
+
createdAt: s.meta.createdAt,
|
|
245
|
+
lastActiveAt: s.meta.lastActiveAt,
|
|
246
|
+
durationMinutes: s.meta.durationMinutes,
|
|
247
|
+
filesEdited: s.meta.filesEdited,
|
|
248
|
+
gitBranch: s.meta.gitBranch,
|
|
249
|
+
toolBreakdown: s.meta.toolBreakdown,
|
|
250
|
+
subagentCount: s.meta.subagentCount,
|
|
251
|
+
},
|
|
252
|
+
}));
|
|
253
|
+
const knowledgeGraph = buildSemanticKnowledgeGraph(sessionInputs);
|
|
254
|
+
// Top files
|
|
255
|
+
const topFiles = [...fileEditCounts.entries()]
|
|
256
|
+
.sort((a, b) => b[1] - a[1])
|
|
257
|
+
.slice(0, 50)
|
|
258
|
+
.map(([file, editCount]) => ({ file, editCount }));
|
|
259
|
+
// Model usage
|
|
260
|
+
const modelUsage = [...modelCounts.entries()]
|
|
261
|
+
.sort((a, b) => b[1] - a[1])
|
|
262
|
+
.map(([model, count]) => ({ model, count }));
|
|
263
|
+
// Weekly tool trends sorted by week
|
|
264
|
+
const weeklyToolTrends = [...weeklyTools.entries()]
|
|
265
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
266
|
+
.map(([week, tools]) => ({ week, tools }));
|
|
267
|
+
// Duration distribution
|
|
268
|
+
const durationDistribution = Object.entries(durationBuckets).map(([bucket, count]) => ({ bucket, count }));
|
|
269
|
+
// Project distribution
|
|
270
|
+
const projectDistribution = [...projectMap.entries()]
|
|
271
|
+
.sort((a, b) => b[1].sessionCount - a[1].sessionCount)
|
|
272
|
+
.map(([name, data]) => ({
|
|
273
|
+
name,
|
|
274
|
+
sessionCount: data.sessionCount,
|
|
275
|
+
totalDurationMinutes: data.totalDurationMinutes,
|
|
276
|
+
}));
|
|
277
|
+
// Tacit knowledge
|
|
278
|
+
const workflowPatterns = [...projectPlanMode.entries()].map(([project, data]) => ({
|
|
279
|
+
project,
|
|
280
|
+
planModeUsage: data.planCount,
|
|
281
|
+
totalSessions: data.totalCount,
|
|
282
|
+
}));
|
|
283
|
+
// Common tool 3-gram sequences
|
|
284
|
+
const seqCounts = new Map();
|
|
285
|
+
for (const seq of toolSequences) {
|
|
286
|
+
const key = seq.join(" -> ");
|
|
287
|
+
seqCounts.set(key, (seqCounts.get(key) || 0) + 1);
|
|
288
|
+
}
|
|
289
|
+
const commonToolSequences = [...seqCounts.entries()]
|
|
290
|
+
.filter(([, count]) => count >= 2)
|
|
291
|
+
.sort((a, b) => b[1] - a[1])
|
|
292
|
+
.slice(0, 20)
|
|
293
|
+
.map(([key, count]) => ({
|
|
294
|
+
sequence: key.split(" -> "),
|
|
295
|
+
count,
|
|
296
|
+
}));
|
|
297
|
+
// Pain points
|
|
298
|
+
const durations = sessions.map((s) => s.meta.durationMinutes);
|
|
299
|
+
const sortedDurations = [...durations].sort((a, b) => a - b);
|
|
300
|
+
const medianDuration = sortedDurations.length > 0
|
|
301
|
+
? sortedDurations[Math.floor(sortedDurations.length / 2)]
|
|
302
|
+
: 0;
|
|
303
|
+
const longSessions = sessions
|
|
304
|
+
.filter((s) => s.meta.durationMinutes > medianDuration * 2 && medianDuration > 0)
|
|
305
|
+
.map((s) => ({
|
|
306
|
+
sessionId: s.meta.sessionId,
|
|
307
|
+
durationMinutes: s.meta.durationMinutes,
|
|
308
|
+
medianDuration,
|
|
309
|
+
}));
|
|
310
|
+
const hotFiles = [];
|
|
311
|
+
for (const [sessionId, fileMap] of sessionFileEdits) {
|
|
312
|
+
for (const [file, count] of fileMap) {
|
|
313
|
+
if (count >= 5) {
|
|
314
|
+
hotFiles.push({ file, editCount: count, sessionId });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
hotFiles.sort((a, b) => b.editCount - a.editCount);
|
|
319
|
+
// Pre-synthesize top skill candidates with claude -p
|
|
320
|
+
if (!synthesisConfig.skip) {
|
|
321
|
+
const topCandidates = [...knowledgeGraph.skillCandidates]
|
|
322
|
+
.sort((a, b) => b.reusabilityScore - a.reusabilityScore)
|
|
323
|
+
.slice(0, synthesisConfig.count);
|
|
324
|
+
const synthOpts = {};
|
|
325
|
+
if (synthesisConfig.model) {
|
|
326
|
+
synthOpts.model = synthesisConfig.model;
|
|
327
|
+
}
|
|
328
|
+
const total = topCandidates.length;
|
|
329
|
+
if (total > 0) {
|
|
330
|
+
console.error(`[crune] Synthesizing top ${total} skill candidates${synthesisConfig.model ? ` (model: ${synthesisConfig.model})` : ""}...`);
|
|
331
|
+
}
|
|
332
|
+
for (let i = 0; i < topCandidates.length; i++) {
|
|
333
|
+
const candidate = topCandidates[i];
|
|
334
|
+
const topic = knowledgeGraph.nodes.find((n) => n.id === candidate.topicId);
|
|
335
|
+
if (!topic)
|
|
336
|
+
continue;
|
|
337
|
+
const topicSessionSet = new Set(topic.sessionIds);
|
|
338
|
+
const relatedSequences = knowledgeGraph.enrichedToolSequences.filter((seq) => seq.sessionIds.some((sid) => topicSessionSet.has(sid)));
|
|
339
|
+
console.error(`[crune] [${i + 1}/${total}] ${topic.label}...`);
|
|
340
|
+
const prompt = buildSynthesisPrompt({
|
|
341
|
+
skillCandidate: candidate,
|
|
342
|
+
topicNode: topic,
|
|
343
|
+
enrichedSequences: relatedSequences,
|
|
344
|
+
});
|
|
345
|
+
const result = await synthesizeWithClaude(prompt, synthOpts);
|
|
346
|
+
if (result.success) {
|
|
347
|
+
const original = knowledgeGraph.skillCandidates.find((sc) => sc.topicId === candidate.topicId);
|
|
348
|
+
if (original) {
|
|
349
|
+
original.synthesizedMarkdown = result.stdout;
|
|
350
|
+
}
|
|
351
|
+
console.error(`[crune] [${i + 1}/${total}] Done.`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
console.error(`[crune] [${i + 1}/${total}] Failed: ${result.error}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
generatedAt: new Date().toISOString(),
|
|
360
|
+
activityHeatmap: heatmap,
|
|
361
|
+
projectDistribution,
|
|
362
|
+
weeklyToolTrends,
|
|
363
|
+
durationDistribution,
|
|
364
|
+
topFiles,
|
|
365
|
+
modelUsage,
|
|
366
|
+
knowledgeGraph,
|
|
367
|
+
tacitKnowledge: {
|
|
368
|
+
workflowPatterns,
|
|
369
|
+
commonToolSequences,
|
|
370
|
+
enrichedToolSequences: knowledgeGraph.enrichedToolSequences ?? [],
|
|
371
|
+
skillCandidates: knowledgeGraph.skillCandidates ?? [],
|
|
372
|
+
painPoints: {
|
|
373
|
+
longSessions,
|
|
374
|
+
hotFiles: hotFiles.slice(0, 20),
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function getWeekLabel(date) {
|
|
380
|
+
// ISO week label: "2026-W10"
|
|
381
|
+
const jan4 = new Date(date.getFullYear(), 0, 4);
|
|
382
|
+
const dayDiff = (date.getTime() - jan4.getTime()) / 86400000;
|
|
383
|
+
const weekNum = Math.ceil((dayDiff + jan4.getDay() + 1) / 7);
|
|
384
|
+
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
|
385
|
+
}
|
|
386
|
+
// buildKnowledgeGraphEdges removed — replaced by buildSemanticKnowledgeGraph
|
|
387
|
+
// ─── Main Pipeline ──────────────────────────────────────────────────────────
|
|
388
|
+
async function main() {
|
|
389
|
+
const { sessionsDir, outputDir, skipSynthesis, synthesisModel, synthesisCount } = parseArgs();
|
|
390
|
+
console.error(`[crune] Sessions dir: ${sessionsDir}`);
|
|
391
|
+
console.error(`[crune] Output dir: ${outputDir}`);
|
|
392
|
+
// Step 1: Discover sessions
|
|
393
|
+
console.error(`\n[crune] Discovering sessions...`);
|
|
394
|
+
const sessionFiles = discoverSessions(sessionsDir);
|
|
395
|
+
console.error(`[crune] Found ${sessionFiles.length} sessions`);
|
|
396
|
+
if (sessionFiles.length === 0) {
|
|
397
|
+
console.error("[crune] No sessions found. Exiting.");
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
// Step 2: Parse each session with metadata and subagents
|
|
401
|
+
const parsedSessions = [];
|
|
402
|
+
for (let i = 0; i < sessionFiles.length; i++) {
|
|
403
|
+
const sf = sessionFiles[i];
|
|
404
|
+
console.error(`[crune] Processing session ${i + 1}/${sessionFiles.length}: ${sf.sessionId}`);
|
|
405
|
+
try {
|
|
406
|
+
// Parse main JSONL
|
|
407
|
+
const lines = await parseJsonlFile(sf.filePath);
|
|
408
|
+
const turns = buildTurns(lines);
|
|
409
|
+
const meta = extractMetadata(sf, lines, turns);
|
|
410
|
+
// Update projectDisplayName from cwd if available
|
|
411
|
+
let displayName = sf.projectDisplayName;
|
|
412
|
+
if (meta.cwd) {
|
|
413
|
+
const cwdParts = meta.cwd.split(path.sep).filter(Boolean);
|
|
414
|
+
if (cwdParts.length >= 2) {
|
|
415
|
+
displayName = cwdParts.slice(-2).join("/");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Parse subagents
|
|
419
|
+
const subagents = await parseSubagents(sf.subagentFiles);
|
|
420
|
+
// Load linked plan
|
|
421
|
+
const linkedPlan = loadLinkedPlan(meta.slug);
|
|
422
|
+
parsedSessions.push({
|
|
423
|
+
meta,
|
|
424
|
+
turns,
|
|
425
|
+
subagents,
|
|
426
|
+
linkedPlan,
|
|
427
|
+
projectDir: sf.projectDir,
|
|
428
|
+
projectDisplayName: displayName,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
console.error(` [ERROR] Failed to process ${sf.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
console.error(`\n[crune] Successfully parsed ${parsedSessions.length}/${sessionFiles.length} sessions`);
|
|
436
|
+
// Step 3: Generate output files
|
|
437
|
+
console.error(`\n[crune] Generating output files...`);
|
|
438
|
+
// Ensure output directories exist
|
|
439
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
440
|
+
fs.mkdirSync(path.join(outputDir, "detail"), { recursive: true });
|
|
441
|
+
// index.json
|
|
442
|
+
const indexData = generateIndex(parsedSessions);
|
|
443
|
+
const indexPath = path.join(outputDir, "index.json");
|
|
444
|
+
fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
|
|
445
|
+
const indexSize = fs.statSync(indexPath).size;
|
|
446
|
+
console.error(`[crune] Wrote ${indexPath} (${(indexSize / 1024).toFixed(1)} KB)`);
|
|
447
|
+
// detail/{sessionId}.json
|
|
448
|
+
let totalDetailSize = 0;
|
|
449
|
+
for (const session of parsedSessions) {
|
|
450
|
+
const detail = generateDetail(session);
|
|
451
|
+
const detailPath = path.join(outputDir, "detail", `${session.meta.sessionId}.json`);
|
|
452
|
+
fs.writeFileSync(detailPath, JSON.stringify(detail, null, 2));
|
|
453
|
+
totalDetailSize += fs.statSync(detailPath).size;
|
|
454
|
+
}
|
|
455
|
+
console.error(`[crune] Wrote ${parsedSessions.length} detail files (${(totalDetailSize / 1024).toFixed(1)} KB total)`);
|
|
456
|
+
// overview.json
|
|
457
|
+
const overviewData = await generateOverview(parsedSessions, {
|
|
458
|
+
skip: skipSynthesis,
|
|
459
|
+
model: synthesisModel,
|
|
460
|
+
count: synthesisCount,
|
|
461
|
+
});
|
|
462
|
+
const overviewPath = path.join(outputDir, "overview.json");
|
|
463
|
+
fs.writeFileSync(overviewPath, JSON.stringify(overviewData, null, 2));
|
|
464
|
+
const overviewSize = fs.statSync(overviewPath).size;
|
|
465
|
+
console.error(`[crune] Wrote ${overviewPath} (${(overviewSize / 1024).toFixed(1)} KB)`);
|
|
466
|
+
// Summary
|
|
467
|
+
console.error(`\n[crune] --- Summary ---`);
|
|
468
|
+
console.error(`[crune] Total sessions: ${parsedSessions.length}`);
|
|
469
|
+
console.error(`[crune] Total projects: ${indexData.projects.length}`);
|
|
470
|
+
console.error(`[crune] Output size: ${((indexSize + totalDetailSize + overviewSize) / 1024).toFixed(1)} KB`);
|
|
471
|
+
console.error(`[crune] Done.`);
|
|
472
|
+
}
|
|
473
|
+
main().catch((err) => {
|
|
474
|
+
console.error(`[crune] Fatal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
475
|
+
process.exit(1);
|
|
476
|
+
});
|