@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,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
+ });