@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,133 @@
1
+ import { tokenize } from "./knowledge-graph/tokenizer.js";
2
+ import { STOP_WORDS } from "./knowledge-graph/constants.js";
3
+ export function classifyWorkType(toolBreakdown, permissionMode, turnCount) {
4
+ const total = Object.values(toolBreakdown).reduce((a, b) => a + b, 0);
5
+ const readCount = (toolBreakdown["Read"] ?? 0) +
6
+ (toolBreakdown["Grep"] ?? 0) +
7
+ (toolBreakdown["Glob"] ?? 0);
8
+ const writeCount = (toolBreakdown["Edit"] ?? 0) + (toolBreakdown["Write"] ?? 0);
9
+ const bashCount = toolBreakdown["Bash"] ?? 0;
10
+ const writeRatio = total > 0 ? writeCount / total : 0;
11
+ const readRatio = total > 0 ? readCount / total : 0;
12
+ const bashRatio = total > 0 ? bashCount / total : 0;
13
+ if (total === 0 ||
14
+ (permissionMode === "plan" && turnCount < 5 && writeRatio === 0)) {
15
+ return "planning";
16
+ }
17
+ if (readRatio >= 0.7) {
18
+ return "investigation";
19
+ }
20
+ if (bashRatio >= 0.4 && writeCount > 0) {
21
+ return "debugging";
22
+ }
23
+ if (writeRatio >= 0.4) {
24
+ return "implementation";
25
+ }
26
+ return "implementation";
27
+ }
28
+ export function findCommonPathPrefix(paths) {
29
+ if (paths.length === 0)
30
+ return "";
31
+ const splitPaths = paths.map((p) => p.split("/"));
32
+ if (splitPaths.length === 1) {
33
+ // Single file: return its directory
34
+ const parts = splitPaths[0];
35
+ if (parts.length <= 1)
36
+ return "";
37
+ return parts.slice(0, -1).join("/");
38
+ }
39
+ const minLen = Math.min(...splitPaths.map((p) => p.length));
40
+ const commonParts = [];
41
+ for (let i = 0; i < minLen; i++) {
42
+ const segment = splitPaths[0][i];
43
+ if (splitPaths.every((p) => p[i] === segment)) {
44
+ commonParts.push(segment);
45
+ }
46
+ else {
47
+ break;
48
+ }
49
+ }
50
+ // Remove the last segment if it looks like a filename (has extension)
51
+ // The common prefix should be a directory
52
+ if (commonParts.length > 0) {
53
+ const last = commonParts[commonParts.length - 1];
54
+ if (last.includes(".")) {
55
+ commonParts.pop();
56
+ }
57
+ }
58
+ const result = commonParts.join("/");
59
+ if (result === "" || result === "/")
60
+ return "";
61
+ return result;
62
+ }
63
+ function selectRepresentativePrompt(prompts) {
64
+ if (prompts.length === 0)
65
+ return "";
66
+ if (prompts.length === 1)
67
+ return prompts[0].slice(0, 300);
68
+ const tokenSets = prompts.map((p) => new Set(tokenize(p)));
69
+ let bestIndex = 0;
70
+ let bestScore = -Infinity;
71
+ for (let i = 0; i < prompts.length; i++) {
72
+ let centralitySum = 0;
73
+ for (let j = 0; j < prompts.length; j++) {
74
+ if (i === j)
75
+ continue;
76
+ const intersection = new Set([...tokenSets[i]].filter((t) => tokenSets[j].has(t)));
77
+ const union = new Set([...tokenSets[i], ...tokenSets[j]]);
78
+ if (union.size > 0) {
79
+ centralitySum += intersection.size / union.size;
80
+ }
81
+ }
82
+ const positionWeight = 1 / (1 + i);
83
+ const score = centralitySum * positionWeight;
84
+ if (score > bestScore) {
85
+ bestScore = score;
86
+ bestIndex = i;
87
+ }
88
+ }
89
+ return prompts[bestIndex].slice(0, 300);
90
+ }
91
+ function extractKeywords(prompts) {
92
+ const allTokens = [];
93
+ for (const prompt of prompts) {
94
+ const tokens = tokenize(prompt);
95
+ for (const t of tokens) {
96
+ if (!STOP_WORDS.has(t)) {
97
+ allTokens.push(t);
98
+ }
99
+ }
100
+ }
101
+ const freq = new Map();
102
+ for (const t of allTokens) {
103
+ freq.set(t, (freq.get(t) ?? 0) + 1);
104
+ }
105
+ return [...freq.entries()]
106
+ .sort((a, b) => b[1] - a[1])
107
+ .slice(0, 5)
108
+ .map(([word]) => word);
109
+ }
110
+ export function generateSessionSummary(turns, meta) {
111
+ // 1. Collect candidate prompts
112
+ let candidatePrompts = turns
113
+ .filter((t) => {
114
+ const mode = t.permissionMode ?? meta.permissionMode;
115
+ return mode === "plan";
116
+ })
117
+ .map((t) => t.userPrompt)
118
+ .filter((p) => p.trim().length > 0);
119
+ if (candidatePrompts.length === 0) {
120
+ candidatePrompts = turns
121
+ .map((t) => t.userPrompt)
122
+ .filter((p) => p.trim().length > 0);
123
+ }
124
+ // 2. Select representative prompt
125
+ const summary = selectRepresentativePrompt(candidatePrompts);
126
+ // 3. Extract keywords
127
+ const keywords = extractKeywords(candidatePrompts);
128
+ // 4. Determine workType
129
+ const workType = classifyWorkType(meta.toolBreakdown, meta.permissionMode, meta.turnCount);
130
+ // 5. Compute scope
131
+ const scope = findCommonPathPrefix(meta.filesEdited);
132
+ return { summary, keywords, scope, workType };
133
+ }
@@ -0,0 +1,62 @@
1
+ import { createServer } from "node:http";
2
+ import { buildSynthesisPrompt, synthesizeWithClaude } from "./skill-synthesizer.js";
3
+ // ---------- Helpers ----------
4
+ function readBody(req) {
5
+ return new Promise((resolve, reject) => {
6
+ const chunks = [];
7
+ req.on("data", (chunk) => chunks.push(chunk));
8
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
9
+ req.on("error", reject);
10
+ });
11
+ }
12
+ function sendJson(res, status, body) {
13
+ res.writeHead(status, { "Content-Type": "application/json" });
14
+ res.end(JSON.stringify(body));
15
+ }
16
+ // ---------- Request Handler ----------
17
+ async function handleSynthesize(req, res) {
18
+ let body;
19
+ try {
20
+ const raw = await readBody(req);
21
+ body = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ sendJson(res, 400, { success: false, error: "Invalid JSON in request body" });
25
+ return;
26
+ }
27
+ if (!body.skillCandidate || !body.topicNode) {
28
+ sendJson(res, 400, { success: false, error: "Missing required fields: skillCandidate, topicNode" });
29
+ return;
30
+ }
31
+ const prompt = buildSynthesisPrompt(body);
32
+ const result = await synthesizeWithClaude(prompt);
33
+ if (!result.success) {
34
+ sendJson(res, 500, { success: false, error: result.error });
35
+ return;
36
+ }
37
+ sendJson(res, 200, { success: true, synthesizedMarkdown: result.stdout });
38
+ }
39
+ // ---------- Server ----------
40
+ const isDirectRun = process.argv[1]?.endsWith("skill-server.ts") || process.argv[1]?.endsWith("skill-server.js");
41
+ if (isDirectRun) {
42
+ const PORT = 3456;
43
+ const server = createServer(async (req, res) => {
44
+ if (req.method === "POST" && req.url === "/api/synthesize") {
45
+ await handleSynthesize(req, res);
46
+ }
47
+ else {
48
+ sendJson(res, 404, { error: "Not found" });
49
+ }
50
+ });
51
+ server.listen(PORT, () => {
52
+ console.log(`Skill synthesis server listening on http://localhost:${PORT}`);
53
+ });
54
+ function shutdown() {
55
+ console.log("\nShutting down...");
56
+ server.close(() => {
57
+ process.exit(0);
58
+ });
59
+ }
60
+ process.on("SIGINT", shutdown);
61
+ process.on("SIGTERM", shutdown);
62
+ }
@@ -0,0 +1,189 @@
1
+ import { spawn } from "node:child_process";
2
+ // ---------- Prompt Builder ----------
3
+ export function buildSynthesisPrompt(body) {
4
+ const { skillCandidate, topicNode, enrichedSequences, graphContext } = body;
5
+ const topicInfo = [
6
+ `## Topic Information`,
7
+ `- **Label:** ${topicNode.label}`,
8
+ `- **Keywords:** ${topicNode.keywords.join(", ")}`,
9
+ `- **Dominant Role:** ${topicNode.dominantRole}`,
10
+ `- **Projects:** ${topicNode.projects.join(", ")}`,
11
+ `- **Session Count:** ${topicNode.sessionCount}`,
12
+ `- **Total Duration:** ${topicNode.totalDurationMinutes} minutes`,
13
+ ].join("\n");
14
+ const prompts = topicNode.representativePrompts.length > 0
15
+ ? [
16
+ `## Representative User Prompts`,
17
+ ...topicNode.representativePrompts.map((p, i) => `${i + 1}. ${p}`),
18
+ ].join("\n")
19
+ : "";
20
+ const toolSig = [
21
+ `## Tool Signature`,
22
+ ...topicNode.toolSignature.map((t) => `- ${t.tool}: ${(t.weight * 100).toFixed(1)}%`),
23
+ ].join("\n");
24
+ let toolPatterns = "";
25
+ if (enrichedSequences && enrichedSequences.length > 0) {
26
+ const top5 = enrichedSequences.slice(0, 5);
27
+ const flows = top5.map((seq) => {
28
+ const flow = seq.sequence.map((s) => s.toolName).join(" → ");
29
+ return `- ${flow} (${seq.count}x across ${seq.projects.length} project(s))`;
30
+ });
31
+ toolPatterns = [`## Enriched Tool Patterns`, ...flows].join("\n");
32
+ }
33
+ // --- Graph context sections ---
34
+ let graphPosition = "";
35
+ let connectedTopicsSection = "";
36
+ if (graphContext) {
37
+ // Graph Position section
38
+ const positionLines = [`## Graph Position`];
39
+ const betweenness = topicNode.betweennessCentrality;
40
+ const degree = topicNode.degreeCentrality;
41
+ if (betweenness > 0.2) {
42
+ positionLines.push("- This is a critical bridge topic connecting multiple knowledge domains");
43
+ }
44
+ else if (betweenness > 0.05) {
45
+ positionLines.push("- This topic bridges several knowledge domains");
46
+ }
47
+ else if (degree > 0.5) {
48
+ positionLines.push("- This is a hub topic connected to many other topics");
49
+ }
50
+ else if (degree === 0) {
51
+ positionLines.push("- This is an isolated topic with no connections to other topics");
52
+ }
53
+ else {
54
+ positionLines.push("- This is a peripheral topic");
55
+ }
56
+ if (graphContext.community) {
57
+ positionLines.push(`- Belongs to community: ${graphContext.community.label} (${graphContext.community.memberCount} topics)`);
58
+ }
59
+ if (graphContext.isBridgeTopic) {
60
+ positionLines.push("- Identified as a bridge topic in the knowledge graph");
61
+ }
62
+ graphPosition = positionLines.join("\n");
63
+ // Connected Topics section
64
+ if (graphContext.connectedTopics.length > 0) {
65
+ const grouped = {};
66
+ for (const ct of graphContext.connectedTopics) {
67
+ if (!grouped[ct.edgeType]) {
68
+ grouped[ct.edgeType] = [];
69
+ }
70
+ const kw = ct.keywords.join(", ");
71
+ if (ct.edgeType === "workflow-continuation") {
72
+ if (ct.direction === "incoming") {
73
+ grouped[ct.edgeType].push(`- Prerequisite: ${ct.label} [${kw}] (strength: ${ct.strength})`);
74
+ }
75
+ else {
76
+ grouped[ct.edgeType].push(`- Follow-up: ${ct.label} [${kw}] (strength: ${ct.strength})`);
77
+ }
78
+ }
79
+ else if (ct.edgeType === "shared-module") {
80
+ grouped[ct.edgeType].push(`- Related (shared files): ${ct.label} [${kw}]`);
81
+ }
82
+ else if (ct.edgeType === "cross-project-bridge") {
83
+ grouped[ct.edgeType].push(`- Cross-project link: ${ct.label} [${kw}]`);
84
+ }
85
+ else if (ct.edgeType === "semantic-similarity") {
86
+ grouped[ct.edgeType].push(`- Similar topic (differentiate from): ${ct.label} [${kw}]`);
87
+ }
88
+ }
89
+ const lines = [`## Connected Topics`];
90
+ if (grouped["workflow-continuation"]) {
91
+ lines.push("### Workflow Continuation");
92
+ lines.push(...grouped["workflow-continuation"]);
93
+ }
94
+ if (grouped["shared-module"]) {
95
+ lines.push("### Shared Module");
96
+ lines.push(...grouped["shared-module"]);
97
+ }
98
+ if (grouped["cross-project-bridge"]) {
99
+ lines.push("### Cross-Project Bridge");
100
+ lines.push(...grouped["cross-project-bridge"]);
101
+ }
102
+ if (grouped["semantic-similarity"]) {
103
+ lines.push("### Semantic Similarity");
104
+ lines.push(...grouped["semantic-similarity"]);
105
+ }
106
+ connectedTopicsSection = lines.join("\n");
107
+ }
108
+ }
109
+ const reference = [
110
+ `## Current Heuristic-Generated Skill (for reference)`,
111
+ "```",
112
+ skillCandidate.skillMarkdown,
113
+ "```",
114
+ ].join("\n");
115
+ const instructionLines = [
116
+ `## Your Task`,
117
+ `Produce a refined SKILL.md for this workflow following anthropics/skills conventions. Rules:`,
118
+ ``,
119
+ `1. Start with YAML frontmatter containing \`name\` and \`description\`. The description MUST include an explicit "when to use" trigger (pushiness) so Claude knows when to activate this skill. Counter under-triggering by being specific about activation context.`,
120
+ `2. Use concise, imperative writing style throughout.`,
121
+ `3. Structure the body with these sections:`,
122
+ ` - **Overview**: What this skill automates and why (1-2 sentences)`,
123
+ ` - **When to Use**: Explicit trigger patterns with concrete examples from the representative prompts above`,
124
+ ` - **Workflow**: Step-by-step imperative instructions using the detected tool patterns`,
125
+ ` - **Guidelines**: "Why"-based rules (not bare MUST/NEVER). Each guideline explains reasoning.`,
126
+ `4. Include concrete examples drawn from the representative prompts above.`,
127
+ `5. Focus on the ESSENCE of what makes this workflow distinct and reusable. Progressive disclosure: keep body scannable.`,
128
+ `6. Write the body in Japanese. Skill names, tool names, technical terms, and proper nouns should remain in English.`,
129
+ `7. Output ONLY the markdown content. No code fences wrapping the output, no explanations before or after.`,
130
+ ];
131
+ if (graphContext && graphContext.connectedTopics.some(ct => ct.edgeType === "workflow-continuation")) {
132
+ instructionLines.push(`8. If workflow-continuation connections exist, include \`requires\` and/or \`next\` fields in the YAML frontmatter listing the connected topic labels.`);
133
+ }
134
+ const instruction = instructionLines.join("\n");
135
+ const parts = [topicInfo, prompts, toolSig, toolPatterns, graphPosition, connectedTopicsSection, reference, instruction].filter(Boolean);
136
+ return parts.join("\n\n");
137
+ }
138
+ export function synthesizeWithClaude(prompt, options = {}) {
139
+ const timeoutMs = options.timeoutMs ?? 120_000;
140
+ return new Promise((resolve) => {
141
+ const args = ["-p", "--output-format", "text"];
142
+ if (options.model) {
143
+ args.push("--model", options.model);
144
+ }
145
+ const child = spawn("claude", args, {
146
+ stdio: ["pipe", "pipe", "pipe"],
147
+ });
148
+ let notFound = false;
149
+ child.on("error", (err) => {
150
+ if (err.code === "ENOENT") {
151
+ notFound = true;
152
+ resolve({ success: false, stdout: "", stderr: "", error: "claude CLI not found. Install Claude Code first." });
153
+ }
154
+ });
155
+ const stdoutChunks = [];
156
+ const stderrChunks = [];
157
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
158
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
159
+ const timeout = setTimeout(() => {
160
+ child.kill("SIGKILL");
161
+ resolve({
162
+ success: false,
163
+ stdout: "",
164
+ stderr: "",
165
+ error: `Synthesis timed out (${timeoutMs / 1000}s)`,
166
+ });
167
+ }, timeoutMs);
168
+ child.on("close", (code) => {
169
+ clearTimeout(timeout);
170
+ if (!notFound) {
171
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
172
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
173
+ if (code !== 0) {
174
+ resolve({
175
+ success: false,
176
+ stdout: "",
177
+ stderr,
178
+ error: `claude exited with code ${code}: ${stderr}`,
179
+ });
180
+ }
181
+ else {
182
+ resolve({ success: true, stdout, stderr: "" });
183
+ }
184
+ }
185
+ });
186
+ child.stdin.write(prompt);
187
+ child.stdin.end();
188
+ });
189
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@chigichan24/crune",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "crune": "./bin/crune.js"
7
+ },
8
+ "files": [
9
+ "dist-cli/",
10
+ "bin/"
11
+ ],
12
+ "scripts": {
13
+ "dev": "vite",
14
+ "build": "tsc -b && vite build",
15
+ "build:cli": "tsc -p tsconfig.cli.json",
16
+ "lint": "eslint .",
17
+ "preview": "vite preview",
18
+ "analyze-sessions": "tsx scripts/analyze-sessions.ts",
19
+ "skill-server": "tsx scripts/skill-server.ts",
20
+ "dev:full": "npm run skill-server & npm run dev",
21
+ "test": "vitest run",
22
+ "prepublishOnly": "npm run build:cli"
23
+ },
24
+ "dependencies": {
25
+ "chart.js": "^4.5.1",
26
+ "react": "^19.2.4",
27
+ "react-chartjs-2": "^5.3.1",
28
+ "react-dom": "^19.2.4",
29
+ "react-force-graph-2d": "^1.29.1"
30
+ },
31
+ "devDependencies": {
32
+ "@eslint/js": "^9.39.4",
33
+ "@types/node": "^24.12.0",
34
+ "@types/react": "^19.2.14",
35
+ "@types/react-dom": "^19.2.3",
36
+ "@vitejs/plugin-react": "^6.0.0",
37
+ "eslint": "^9.39.4",
38
+ "eslint-plugin-react-hooks": "^7.0.1",
39
+ "eslint-plugin-react-refresh": "^0.5.2",
40
+ "globals": "^17.4.0",
41
+ "tsx": "^4.21.0",
42
+ "typescript": "~5.9.3",
43
+ "typescript-eslint": "^8.56.1",
44
+ "vite": "^8.0.0",
45
+ "vitest": "^3.2.4"
46
+ }
47
+ }