@chigichan24/crune 0.1.3 → 0.1.5
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/README.md +56 -8
- package/dist-cli/__tests__/facets-reader.test.js +180 -0
- package/dist-cli/__tests__/merge-narrow-clusters.test.js +171 -0
- package/dist-cli/__tests__/reusability-scoring.test.js +175 -0
- package/dist-cli/analyze-sessions.js +57 -13
- package/dist-cli/cli.js +8 -6
- package/dist-cli/knowledge-graph/clustering.js +108 -0
- package/dist-cli/knowledge-graph/facets-reader.js +188 -0
- package/dist-cli/knowledge-graph/index.js +22 -7
- package/dist-cli/knowledge-graph/reusability.js +42 -6
- package/dist-cli/knowledge-graph/topic-nodes.js +27 -4
- package/dist-cli/knowledge-graph-builder.js +4 -2
- package/dist-cli/session-parser.js +19 -0
- package/dist-cli/skill-server.js +2 -2
- package/dist-cli/skill-synthesizer.js +39 -4
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
<img src="public/favicon.svg" alt="crune logo" width="80" height="80" />
|
|
3
3
|
<h1>crune</h1>
|
|
4
4
|
<p><strong>Claude Code Rune — Traces linger, skills emerge</strong></p>
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://www.npmjs.com/package/@chigichan24/crune"><img src="https://img.shields.io/npm/v/@chigichan24/crune.svg" alt="npm version" /></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/@chigichan24/crune"><img src="https://img.shields.io/npm/dm/@chigichan24/crune.svg" alt="npm downloads" /></a>
|
|
8
|
+
<a href="https://github.com/chigichan24/crune/blob/main/LICENSE"><img src="https://img.shields.io/github/license/chigichan24/crune.svg" alt="license" /></a>
|
|
9
|
+
</p>
|
|
5
10
|
</div>
|
|
6
11
|
|
|
7
|
-
Decipher the traces etched in past sessions and resurrect them as reusable skills. crune is a static web dashboard that analyzes Claude Code session logs, providing session playback, analytics, a semantic knowledge graph, and skill synthesis.
|
|
12
|
+
Decipher the traces etched in past sessions and resurrect them as reusable skills. crune is a static web dashboard and CLI tool that analyzes Claude Code session logs, providing session playback, analytics, a semantic knowledge graph, and skill synthesis.
|
|
8
13
|
|
|
9
14
|
## Features
|
|
10
15
|
|
|
@@ -12,7 +17,7 @@ Decipher the traces etched in past sessions and resurrect them as reusable skill
|
|
|
12
17
|
- **Overview Dashboard** --- Activity heatmap, project distribution, tool usage trends, duration distribution, model usage, and top edited files
|
|
13
18
|
- **Semantic Knowledge Graph** --- TF-IDF + Tool-IDF + structural features, Truncated SVD, agglomerative clustering, Louvain community detection, Brandes centrality ([algorithm details](docs/knowledge-graph-algorithm.md))
|
|
14
19
|
- **Tacit Knowledge** --- Extracted workflow patterns, tool sequences, and pain points (long sessions, hot files)
|
|
15
|
-
- **Session Summarization** --- Automatic session summary and classification (no LLM required)
|
|
20
|
+
- **Session Summarization** --- Automatic session summary and classification (no LLM required), enriched with `/insights` facets data when available
|
|
16
21
|
- **Skill Synthesis** --- Synthesize reusable skill definitions from the knowledge graph ([algorithm details](docs/skill-generation-algorithm.md))
|
|
17
22
|
|
|
18
23
|
### Overview Dashboard
|
|
@@ -27,7 +32,31 @@ Decipher the traces etched in past sessions and resurrect them as reusable skill
|
|
|
27
32
|
|
|
28
33
|
<img src="docs/rune.png" alt="Knowledge Graph" width="800" />
|
|
29
34
|
|
|
30
|
-
##
|
|
35
|
+
## CLI — Generate Skills via npx
|
|
36
|
+
|
|
37
|
+
Generate reusable Claude Code skill definitions directly from your session logs. No clone required.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @chigichan24/crune --dry-run # Preview skill candidates
|
|
41
|
+
npx @chigichan24/crune --skip-synthesis # Generate heuristic skills (no LLM)
|
|
42
|
+
npx @chigichan24/crune --count 3 --model haiku # LLM-synthesized skills (requires claude CLI)
|
|
43
|
+
npx @chigichan24/crune --output-dir ~/.claude/skills # Install skills directly
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Output follows the [Claude Code skill format](https://docs.anthropic.com/en/docs/claude-code/skills) (`<name>/SKILL.md`), ready to use as `/skill-name` commands.
|
|
47
|
+
|
|
48
|
+
| Flag | Description |
|
|
49
|
+
|------|-------------|
|
|
50
|
+
| `--sessions-dir <path>` | Session logs directory (default: `~/.claude/projects`) |
|
|
51
|
+
| `--output-dir <path>` | Output directory for skill files (default: `./skills`) |
|
|
52
|
+
| `--count <n>` | Number of skills to generate (default: 5) |
|
|
53
|
+
| `--model <model>` | Claude model for synthesis (e.g. `haiku`, `sonnet`) |
|
|
54
|
+
| `--skip-synthesis` | Skip LLM synthesis, output heuristic skills only |
|
|
55
|
+
| `--dry-run` | Show candidates without writing files |
|
|
56
|
+
|
|
57
|
+
## Web Dashboard
|
|
58
|
+
|
|
59
|
+
### Quick Start
|
|
31
60
|
|
|
32
61
|
```bash
|
|
33
62
|
npm install
|
|
@@ -48,8 +77,10 @@ npm run dev
|
|
|
48
77
|
-> parse & build turns
|
|
49
78
|
-> extract metadata, subagents, linked plans
|
|
50
79
|
-> session summarization (centrality-based representative prompt, workType classification)
|
|
51
|
-
-> TF-IDF + Tool-IDF + structural features -> Truncated SVD -> agglomerative clustering
|
|
52
|
-
->
|
|
80
|
+
-> TF-IDF + Tool-IDF + structural features -> Truncated SVD -> agglomerative clustering
|
|
81
|
+
-> /insights facets integration (narrow cluster merging, goal-based labeling)
|
|
82
|
+
-> Louvain community detection
|
|
83
|
+
-> skill synthesis (reusability score top-N -> claude -p, enriched with facets insights)
|
|
53
84
|
-> output:
|
|
54
85
|
public/data/sessions/index.json (session list)
|
|
55
86
|
public/data/sessions/overview.json (cross-session analytics + knowledge graph)
|
|
@@ -62,9 +93,20 @@ Custom paths:
|
|
|
62
93
|
npm run analyze-sessions -- --sessions-dir /path/to/sessions --output-dir /path/to/output
|
|
63
94
|
```
|
|
64
95
|
|
|
96
|
+
### /insights Integration
|
|
97
|
+
|
|
98
|
+
Before analysis, `analyze-sessions` automatically runs `/insights` to refresh facets data. This enriches the pipeline with:
|
|
99
|
+
- **Better topic labels** using LLM-generated `underlying_goal` instead of TF-IDF keywords
|
|
100
|
+
- **Narrow cluster merging** based on shared goal categories
|
|
101
|
+
- **Quality-aware reusability scoring** using session outcome and helpfulness
|
|
102
|
+
- **Richer synthesis prompts** with friction details and success rates
|
|
103
|
+
- **Session summaries** using `brief_summary` from facets
|
|
104
|
+
|
|
105
|
+
Skip with `--skip-facets` or customize the facets path with `--facets-dir <path>`.
|
|
106
|
+
|
|
65
107
|
## Session Summarization
|
|
66
108
|
|
|
67
|
-
The session list displays auto-generated summaries, processed
|
|
109
|
+
The session list displays auto-generated summaries. When `/insights` facets data is available, the LLM-generated `brief_summary` is used. Otherwise, summaries are processed locally without LLM.
|
|
68
110
|
|
|
69
111
|
- **Representative prompt selection**: Selects the most representative prompt from plan mode turns using Jaccard centrality with position weighting
|
|
70
112
|
- **workType classification**: Automatically classifies each session into one of four types:
|
|
@@ -109,13 +151,15 @@ npm run analyze-sessions -- --skip-synthesis
|
|
|
109
151
|
| `npm run skill-server` | Skill synthesis local server (localhost:3456) |
|
|
110
152
|
| `npm run dev:full` | skill-server + Vite dev server together |
|
|
111
153
|
|
|
112
|
-
###
|
|
154
|
+
### Options for analyze-sessions
|
|
113
155
|
|
|
114
156
|
| Flag | Description |
|
|
115
157
|
|------|-------------|
|
|
116
158
|
| `--synthesis-model <model>` | Model to use for synthesis (e.g. `haiku` for speed) |
|
|
117
159
|
| `--synthesis-count <n>` | Number of candidates to synthesize (default: 5) |
|
|
118
160
|
| `--skip-synthesis` | Skip LLM synthesis |
|
|
161
|
+
| `--facets-dir <path>` | Custom facets directory (default: `~/.claude/usage-data/facets`) |
|
|
162
|
+
| `--skip-facets` | Skip `/insights` refresh and facets integration |
|
|
119
163
|
|
|
120
164
|
## Tech Stack
|
|
121
165
|
|
|
@@ -136,11 +180,15 @@ src/
|
|
|
136
180
|
hooks/ # Data fetching (useSessionIndex, useSessionDetail, useSessionOverview)
|
|
137
181
|
types/ # TypeScript type definitions
|
|
138
182
|
scripts/
|
|
139
|
-
|
|
183
|
+
cli.ts # npx CLI entry point
|
|
184
|
+
session-parser.ts # JSONL parsing, turn building, metadata extraction
|
|
185
|
+
analyze-sessions.ts # Dashboard JSON pipeline
|
|
140
186
|
session-summarizer.ts # Session summarization (local NLP)
|
|
141
187
|
skill-synthesizer.ts # Skill synthesis (claude -p)
|
|
142
188
|
skill-server.ts # Synthesis HTTP server
|
|
143
189
|
knowledge-graph-builder.ts # Semantic embedding + graph construction
|
|
190
|
+
knowledge-graph/
|
|
191
|
+
facets-reader.ts # /insights facets data reader + normalization
|
|
144
192
|
public/
|
|
145
193
|
data/sessions/ # Generated JSON (gitignored)
|
|
146
194
|
```
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeGoalCategory, helpfulnessToScore, aggregateFacetsForTopic, } from "../knowledge-graph-builder.js";
|
|
3
|
+
describe("normalizeGoalCategory", () => {
|
|
4
|
+
it("maps feature_implementation → feature", () => {
|
|
5
|
+
expect(normalizeGoalCategory("feature_implementation")).toBe("feature");
|
|
6
|
+
});
|
|
7
|
+
it("maps fix_build_errors → bugfix", () => {
|
|
8
|
+
expect(normalizeGoalCategory("fix_build_errors")).toBe("bugfix");
|
|
9
|
+
});
|
|
10
|
+
it("maps debugging → bugfix", () => {
|
|
11
|
+
expect(normalizeGoalCategory("debugging")).toBe("bugfix");
|
|
12
|
+
});
|
|
13
|
+
it("maps refactoring → refactoring", () => {
|
|
14
|
+
expect(normalizeGoalCategory("refactoring")).toBe("refactoring");
|
|
15
|
+
});
|
|
16
|
+
it("maps code_cleanup → refactoring", () => {
|
|
17
|
+
expect(normalizeGoalCategory("code_cleanup")).toBe("refactoring");
|
|
18
|
+
});
|
|
19
|
+
it("maps documentation_update → documentation", () => {
|
|
20
|
+
expect(normalizeGoalCategory("documentation_update")).toBe("documentation");
|
|
21
|
+
});
|
|
22
|
+
it("maps code_review → review", () => {
|
|
23
|
+
expect(normalizeGoalCategory("code_review")).toBe("review");
|
|
24
|
+
});
|
|
25
|
+
it("maps test_writing → testing", () => {
|
|
26
|
+
expect(normalizeGoalCategory("test_writing")).toBe("testing");
|
|
27
|
+
});
|
|
28
|
+
it("maps ci_setup → ci", () => {
|
|
29
|
+
expect(normalizeGoalCategory("ci_setup")).toBe("ci");
|
|
30
|
+
});
|
|
31
|
+
it("maps git_operations → git_ops", () => {
|
|
32
|
+
expect(normalizeGoalCategory("git_operations")).toBe("git_ops");
|
|
33
|
+
});
|
|
34
|
+
it("maps create_pr → git_ops", () => {
|
|
35
|
+
expect(normalizeGoalCategory("create_pr")).toBe("git_ops");
|
|
36
|
+
});
|
|
37
|
+
it("maps pr_creation → git_ops", () => {
|
|
38
|
+
expect(normalizeGoalCategory("pr_creation")).toBe("git_ops");
|
|
39
|
+
});
|
|
40
|
+
it("maps setup_deployment → setup", () => {
|
|
41
|
+
expect(normalizeGoalCategory("setup_deployment")).toBe("setup");
|
|
42
|
+
});
|
|
43
|
+
it("maps npm_publishing_guidance → setup", () => {
|
|
44
|
+
expect(normalizeGoalCategory("npm_publishing_guidance")).toBe("setup");
|
|
45
|
+
});
|
|
46
|
+
it("maps unknown_category → other", () => {
|
|
47
|
+
expect(normalizeGoalCategory("unknown_category")).toBe("other");
|
|
48
|
+
});
|
|
49
|
+
it("maps quick_question → other", () => {
|
|
50
|
+
expect(normalizeGoalCategory("quick_question")).toBe("other");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("helpfulnessToScore", () => {
|
|
54
|
+
it("returns 1.0 for essential", () => {
|
|
55
|
+
expect(helpfulnessToScore("essential")).toBe(1.0);
|
|
56
|
+
});
|
|
57
|
+
it("returns 0.8 for very_helpful", () => {
|
|
58
|
+
expect(helpfulnessToScore("very_helpful")).toBe(0.8);
|
|
59
|
+
});
|
|
60
|
+
it("returns 0.5 for moderately_helpful", () => {
|
|
61
|
+
expect(helpfulnessToScore("moderately_helpful")).toBe(0.5);
|
|
62
|
+
});
|
|
63
|
+
it("returns 0.25 for slightly_helpful", () => {
|
|
64
|
+
expect(helpfulnessToScore("slightly_helpful")).toBe(0.25);
|
|
65
|
+
});
|
|
66
|
+
it("returns 0.0 for unhelpful", () => {
|
|
67
|
+
expect(helpfulnessToScore("unhelpful")).toBe(0.0);
|
|
68
|
+
});
|
|
69
|
+
it("returns 0.5 for unknown string", () => {
|
|
70
|
+
expect(helpfulnessToScore("something_else")).toBe(0.5);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("aggregateFacetsForTopic", () => {
|
|
74
|
+
function makeFacets(overrides = {}) {
|
|
75
|
+
return {
|
|
76
|
+
sessionId: "s1",
|
|
77
|
+
underlyingGoal: "Implement feature X",
|
|
78
|
+
goalCategories: { feature_implementation: 1 },
|
|
79
|
+
outcome: "fully_achieved",
|
|
80
|
+
claudeHelpfulness: "very_helpful",
|
|
81
|
+
sessionType: "implementation",
|
|
82
|
+
frictionCounts: {},
|
|
83
|
+
frictionDetail: "",
|
|
84
|
+
primarySuccess: "Feature works",
|
|
85
|
+
briefSummary: "Implemented feature X",
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
it("returns undefined when no sessions have facets", () => {
|
|
90
|
+
const facetsMap = new Map();
|
|
91
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
92
|
+
expect(result).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
it("aggregates goals from multiple sessions", () => {
|
|
95
|
+
const facetsMap = new Map([
|
|
96
|
+
["s1", makeFacets({ sessionId: "s1", underlyingGoal: "Goal A" })],
|
|
97
|
+
["s2", makeFacets({ sessionId: "s2", underlyingGoal: "Goal B" })],
|
|
98
|
+
["s3", makeFacets({ sessionId: "s3", underlyingGoal: "Goal C" })],
|
|
99
|
+
["s4", makeFacets({ sessionId: "s4", underlyingGoal: "Goal D" })],
|
|
100
|
+
]);
|
|
101
|
+
const result = aggregateFacetsForTopic(["s1", "s2", "s3", "s4"], facetsMap);
|
|
102
|
+
expect(result).toBeDefined();
|
|
103
|
+
// max 3 goals
|
|
104
|
+
expect(result.aggregatedGoals).toHaveLength(3);
|
|
105
|
+
expect(result.aggregatedGoals).toContain("Goal A");
|
|
106
|
+
expect(result.aggregatedGoals).toContain("Goal B");
|
|
107
|
+
expect(result.aggregatedGoals).toContain("Goal C");
|
|
108
|
+
});
|
|
109
|
+
it("computes successRate correctly with mix of achieved and not", () => {
|
|
110
|
+
const facetsMap = new Map([
|
|
111
|
+
["s1", makeFacets({ sessionId: "s1", outcome: "fully_achieved" })],
|
|
112
|
+
["s2", makeFacets({ sessionId: "s2", outcome: "mostly_achieved" })],
|
|
113
|
+
["s3", makeFacets({ sessionId: "s3", outcome: "not_achieved" })],
|
|
114
|
+
["s4", makeFacets({ sessionId: "s4", outcome: "partially_achieved" })],
|
|
115
|
+
]);
|
|
116
|
+
const result = aggregateFacetsForTopic(["s1", "s2", "s3", "s4"], facetsMap);
|
|
117
|
+
expect(result).toBeDefined();
|
|
118
|
+
// 2 out of 4 achieved
|
|
119
|
+
expect(result.successRate).toBe(0.5);
|
|
120
|
+
});
|
|
121
|
+
it("computes helpfulnessScore correctly", () => {
|
|
122
|
+
const facetsMap = new Map([
|
|
123
|
+
[
|
|
124
|
+
"s1",
|
|
125
|
+
makeFacets({ sessionId: "s1", claudeHelpfulness: "essential" }),
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
"s2",
|
|
129
|
+
makeFacets({ sessionId: "s2", claudeHelpfulness: "unhelpful" }),
|
|
130
|
+
],
|
|
131
|
+
]);
|
|
132
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
133
|
+
expect(result).toBeDefined();
|
|
134
|
+
// (1.0 + 0.0) / 2 = 0.5
|
|
135
|
+
expect(result.helpfulnessScore).toBe(0.5);
|
|
136
|
+
});
|
|
137
|
+
it("merges and sorts friction counts", () => {
|
|
138
|
+
const facetsMap = new Map([
|
|
139
|
+
[
|
|
140
|
+
"s1",
|
|
141
|
+
makeFacets({
|
|
142
|
+
sessionId: "s1",
|
|
143
|
+
frictionCounts: { unclear_spec: 3, slow_response: 1 },
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
"s2",
|
|
148
|
+
makeFacets({
|
|
149
|
+
sessionId: "s2",
|
|
150
|
+
frictionCounts: { unclear_spec: 2, wrong_tool: 5 },
|
|
151
|
+
}),
|
|
152
|
+
],
|
|
153
|
+
]);
|
|
154
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
155
|
+
expect(result).toBeDefined();
|
|
156
|
+
// unclear_spec: 5, wrong_tool: 5, slow_response: 1
|
|
157
|
+
expect(result.commonFrictions[0]).toBe("unclear_spec");
|
|
158
|
+
expect(result.commonFrictions).toContain("wrong_tool");
|
|
159
|
+
expect(result.commonFrictions).toContain("slow_response");
|
|
160
|
+
});
|
|
161
|
+
it("handles sessions without facets with neutral 0.5 for scores", () => {
|
|
162
|
+
const facetsMap = new Map([
|
|
163
|
+
[
|
|
164
|
+
"s1",
|
|
165
|
+
makeFacets({
|
|
166
|
+
sessionId: "s1",
|
|
167
|
+
outcome: "fully_achieved",
|
|
168
|
+
claudeHelpfulness: "essential",
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
]);
|
|
172
|
+
// s2 has no facets — should get 0.5 for both scores
|
|
173
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
174
|
+
expect(result).toBeDefined();
|
|
175
|
+
// successRate: (1.0 + 0.5) / 2 = 0.75
|
|
176
|
+
expect(result.successRate).toBe(0.75);
|
|
177
|
+
// helpfulnessScore: (1.0 + 0.5) / 2 = 0.75
|
|
178
|
+
expect(result.helpfulnessScore).toBe(0.75);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeNarrowClusters } from "../knowledge-graph-builder.js";
|
|
3
|
+
// Helper: build a FacetsData object
|
|
4
|
+
function makeFacets(sessionId, goalCategories, outcome = "fully_achieved") {
|
|
5
|
+
return {
|
|
6
|
+
sessionId,
|
|
7
|
+
underlyingGoal: "test goal",
|
|
8
|
+
goalCategories,
|
|
9
|
+
outcome,
|
|
10
|
+
claudeHelpfulness: "essential",
|
|
11
|
+
sessionType: "single_task",
|
|
12
|
+
frictionCounts: {},
|
|
13
|
+
frictionDetail: "",
|
|
14
|
+
primarySuccess: "multi_file_changes",
|
|
15
|
+
briefSummary: "test summary",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Helper: build a distance map with key format `${min}:${max}`
|
|
19
|
+
function buildDistMap(entries) {
|
|
20
|
+
const m = new Map();
|
|
21
|
+
for (const [i, j, d] of entries) {
|
|
22
|
+
const lo = Math.min(i, j);
|
|
23
|
+
const hi = Math.max(i, j);
|
|
24
|
+
m.set(`${lo}:${hi}`, d);
|
|
25
|
+
}
|
|
26
|
+
return m;
|
|
27
|
+
}
|
|
28
|
+
// ─── mergeNarrowClusters ────────────────────────────────────────────────────
|
|
29
|
+
describe("mergeNarrowClusters", () => {
|
|
30
|
+
it("returns unchanged when no narrow clusters exist", () => {
|
|
31
|
+
// All clusters have > maxNarrowSize (2) members
|
|
32
|
+
const clusters = [
|
|
33
|
+
[0, 1, 2],
|
|
34
|
+
[3, 4, 5],
|
|
35
|
+
];
|
|
36
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4", "s5"];
|
|
37
|
+
const facetsMap = new Map();
|
|
38
|
+
const dist = buildDistMap([
|
|
39
|
+
[0, 1, 0.1], [0, 2, 0.1], [1, 2, 0.1],
|
|
40
|
+
[3, 4, 0.1], [3, 5, 0.1], [4, 5, 0.1],
|
|
41
|
+
[0, 3, 0.9], [0, 4, 0.9], [0, 5, 0.9],
|
|
42
|
+
[1, 3, 0.9], [1, 4, 0.9], [1, 5, 0.9],
|
|
43
|
+
[2, 3, 0.9], [2, 4, 0.9], [2, 5, 0.9],
|
|
44
|
+
]);
|
|
45
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
46
|
+
expect(result).toEqual(clusters);
|
|
47
|
+
});
|
|
48
|
+
it("returns unchanged when narrow clusters have no facets data", () => {
|
|
49
|
+
const clusters = [[0], [1]];
|
|
50
|
+
const sessionIds = ["s0", "s1"];
|
|
51
|
+
const facetsMap = new Map(); // empty
|
|
52
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
53
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
54
|
+
// Both clusters survive unmerged (no facets to match on)
|
|
55
|
+
expect(result.length).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
it("merges two narrow clusters with shared goal categories and close distance", () => {
|
|
58
|
+
const clusters = [[0], [1]];
|
|
59
|
+
const sessionIds = ["s0", "s1"];
|
|
60
|
+
const facetsMap = new Map([
|
|
61
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
62
|
+
["s1", makeFacets("s1", { feature_update_header: 1 })],
|
|
63
|
+
]);
|
|
64
|
+
// Both normalize to "feature"
|
|
65
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
66
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
67
|
+
expect(result.length).toBe(1);
|
|
68
|
+
expect([...result[0]].sort()).toEqual([0, 1]);
|
|
69
|
+
});
|
|
70
|
+
it("does not merge when goal categories differ", () => {
|
|
71
|
+
const clusters = [[0], [1]];
|
|
72
|
+
const sessionIds = ["s0", "s1"];
|
|
73
|
+
const facetsMap = new Map([
|
|
74
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
75
|
+
["s1", makeFacets("s1", { fix_bug_crash: 1 })],
|
|
76
|
+
]);
|
|
77
|
+
// "feature" vs "bugfix" - no overlap
|
|
78
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
79
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
80
|
+
expect(result.length).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
it("does not merge when distance exceeds threshold", () => {
|
|
83
|
+
const clusters = [[0], [1]];
|
|
84
|
+
const sessionIds = ["s0", "s1"];
|
|
85
|
+
const facetsMap = new Map([
|
|
86
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
87
|
+
["s1", makeFacets("s1", { feature_update_header: 1 })],
|
|
88
|
+
]);
|
|
89
|
+
// Shared category "feature" but distance too far
|
|
90
|
+
const dist = buildDistMap([[0, 1, 0.9]]);
|
|
91
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
92
|
+
0.7);
|
|
93
|
+
expect(result.length).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
it("respects maxMergedSize limit", () => {
|
|
96
|
+
// 3 narrow clusters of size 2 each; maxMergedSize = 3
|
|
97
|
+
const clusters = [[0, 1], [2, 3], [4, 5]];
|
|
98
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4", "s5"];
|
|
99
|
+
const facetsMap = new Map([
|
|
100
|
+
["s0", makeFacets("s0", { feature_x: 1 })],
|
|
101
|
+
["s1", makeFacets("s1", { feature_y: 1 })],
|
|
102
|
+
["s2", makeFacets("s2", { feature_z: 1 })],
|
|
103
|
+
["s3", makeFacets("s3", { feature_w: 1 })],
|
|
104
|
+
["s4", makeFacets("s4", { feature_v: 1 })],
|
|
105
|
+
["s5", makeFacets("s5", { feature_u: 1 })],
|
|
106
|
+
]);
|
|
107
|
+
// All close distances
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (let i = 0; i < 6; i++) {
|
|
110
|
+
for (let j = i + 1; j < 6; j++) {
|
|
111
|
+
entries.push([i, j, 0.2]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const dist = buildDistMap(entries);
|
|
115
|
+
// maxMergedSize = 3 means first merge (2+2=4) already exceeds limit
|
|
116
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
117
|
+
0.7, // distanceThreshold
|
|
118
|
+
3);
|
|
119
|
+
// No merges possible since 2+2=4 > 3
|
|
120
|
+
expect(result.length).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
it("handles chain merges: A+B then (A+B)+C", () => {
|
|
123
|
+
// Three singleton narrow clusters, all share "feature" category and are close
|
|
124
|
+
const clusters = [[0], [1], [2]];
|
|
125
|
+
const sessionIds = ["s0", "s1", "s2"];
|
|
126
|
+
const facetsMap = new Map([
|
|
127
|
+
["s0", makeFacets("s0", { feature_a: 1 })],
|
|
128
|
+
["s1", makeFacets("s1", { feature_b: 1 })],
|
|
129
|
+
["s2", makeFacets("s2", { feature_c: 1 })],
|
|
130
|
+
]);
|
|
131
|
+
const dist = buildDistMap([
|
|
132
|
+
[0, 1, 0.2],
|
|
133
|
+
[0, 2, 0.3],
|
|
134
|
+
[1, 2, 0.25],
|
|
135
|
+
]);
|
|
136
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
137
|
+
0.7, // distanceThreshold
|
|
138
|
+
8);
|
|
139
|
+
// All three should be merged into one cluster
|
|
140
|
+
expect(result.length).toBe(1);
|
|
141
|
+
expect([...result[0]].sort()).toEqual([0, 1, 2]);
|
|
142
|
+
});
|
|
143
|
+
it("leaves large clusters untouched and only merges narrow ones", () => {
|
|
144
|
+
// One large cluster [0,1,2], two narrow singletons [3], [4]
|
|
145
|
+
const clusters = [[0, 1, 2], [3], [4]];
|
|
146
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4"];
|
|
147
|
+
const facetsMap = new Map([
|
|
148
|
+
["s3", makeFacets("s3", { fix_bug_a: 1 })],
|
|
149
|
+
["s4", makeFacets("s4", { fix_bug_b: 1 })],
|
|
150
|
+
]);
|
|
151
|
+
// Narrow clusters are close
|
|
152
|
+
const entries = [];
|
|
153
|
+
for (let i = 0; i < 5; i++) {
|
|
154
|
+
for (let j = i + 1; j < 5; j++) {
|
|
155
|
+
entries.push([i, j, i < 3 && j < 3 ? 0.1 : 0.3]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const dist = buildDistMap(entries);
|
|
159
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
160
|
+
0.7, // distanceThreshold
|
|
161
|
+
8);
|
|
162
|
+
// Large cluster stays as-is, two narrow clusters merge into one
|
|
163
|
+
expect(result.length).toBe(2);
|
|
164
|
+
// The large cluster should be present
|
|
165
|
+
const sorted = result
|
|
166
|
+
.map((c) => [...c].sort((a, b) => a - b))
|
|
167
|
+
.sort((a, b) => a[0] - b[0]);
|
|
168
|
+
expect(sorted[0]).toEqual([0, 1, 2]);
|
|
169
|
+
expect(sorted[1]).toEqual([3, 4]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeReusabilityScores, } from "../knowledge-graph-builder.js";
|
|
3
|
+
function makeTopic(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: "topic-001",
|
|
6
|
+
label: "test",
|
|
7
|
+
keywords: ["test"],
|
|
8
|
+
project: "proj",
|
|
9
|
+
projects: ["proj"],
|
|
10
|
+
sessionIds: ["s1"],
|
|
11
|
+
sessionCount: 1,
|
|
12
|
+
totalDurationMinutes: 60,
|
|
13
|
+
totalToolCalls: 10,
|
|
14
|
+
firstSeen: "2026-01-01T00:00:00Z",
|
|
15
|
+
lastSeen: "2026-03-01T00:00:00Z",
|
|
16
|
+
betweennessCentrality: 0,
|
|
17
|
+
degreeCentrality: 0,
|
|
18
|
+
communityId: 0,
|
|
19
|
+
representativePrompts: [],
|
|
20
|
+
suggestedPrompt: "",
|
|
21
|
+
toolSignature: [],
|
|
22
|
+
dominantRole: "user-driven",
|
|
23
|
+
reusabilityScore: { overall: 0, frequency: 0, timeCost: 0, crossProjectScore: 0, recency: 0 },
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeFacets(sessionId, outcome, helpfulness) {
|
|
28
|
+
return {
|
|
29
|
+
sessionId,
|
|
30
|
+
underlyingGoal: "test",
|
|
31
|
+
goalCategories: {},
|
|
32
|
+
outcome,
|
|
33
|
+
claudeHelpfulness: helpfulness,
|
|
34
|
+
sessionType: "single_task",
|
|
35
|
+
frictionCounts: {},
|
|
36
|
+
frictionDetail: "",
|
|
37
|
+
primarySuccess: "",
|
|
38
|
+
briefSummary: "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ─── No facetsMap (backward compatible) ─────────────────────────────────────
|
|
42
|
+
describe("computeReusabilityScores without facets", () => {
|
|
43
|
+
it("uses original formula weights (0.35/0.25/0.25/0.15)", () => {
|
|
44
|
+
// Single topic: frequency=1, timeCost=1, crossProject=0, recency=1
|
|
45
|
+
const topic = makeTopic({
|
|
46
|
+
sessionCount: 1,
|
|
47
|
+
totalDurationMinutes: 60,
|
|
48
|
+
projects: ["proj"],
|
|
49
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
50
|
+
});
|
|
51
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
52
|
+
computeReusabilityScores([topic], now);
|
|
53
|
+
// frequency = 1/1 = 1, timeCost = 60/60 = 1, crossProject = 0 (single project), recency = 1 (same day)
|
|
54
|
+
// overall = 0.35*1 + 0.25*1 + 0.25*0 + 0.15*1 = 0.75
|
|
55
|
+
expect(topic.reusabilityScore.overall).toBe(0.75);
|
|
56
|
+
expect(topic.reusabilityScore.frequency).toBe(1);
|
|
57
|
+
expect(topic.reusabilityScore.timeCost).toBe(1);
|
|
58
|
+
expect(topic.reusabilityScore.crossProjectScore).toBe(0);
|
|
59
|
+
expect(topic.reusabilityScore.recency).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// ─── Empty facetsMap (backward compatible) ──────────────────────────────────
|
|
63
|
+
describe("computeReusabilityScores with empty facetsMap", () => {
|
|
64
|
+
it("behaves same as no facetsMap", () => {
|
|
65
|
+
const topic = makeTopic({
|
|
66
|
+
sessionCount: 1,
|
|
67
|
+
totalDurationMinutes: 60,
|
|
68
|
+
projects: ["proj"],
|
|
69
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
70
|
+
});
|
|
71
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
72
|
+
computeReusabilityScores([topic], now, new Map());
|
|
73
|
+
expect(topic.reusabilityScore.overall).toBe(0.75);
|
|
74
|
+
expect(topic.reusabilityScore.successRate).toBeUndefined();
|
|
75
|
+
expect(topic.reusabilityScore.helpfulness).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
// ─── FacetsMap with full coverage ───────────────────────────────────────────
|
|
79
|
+
describe("computeReusabilityScores with full facets coverage", () => {
|
|
80
|
+
it("uses new weights (0.30/0.20/0.20/0.10/0.10/0.10) with successRate=1 and helpfulness=1", () => {
|
|
81
|
+
const topic = makeTopic({
|
|
82
|
+
sessionIds: ["s1", "s2"],
|
|
83
|
+
sessionCount: 2,
|
|
84
|
+
totalDurationMinutes: 120,
|
|
85
|
+
projects: ["proj"],
|
|
86
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
87
|
+
});
|
|
88
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
89
|
+
const facetsMap = new Map();
|
|
90
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "essential"));
|
|
91
|
+
facetsMap.set("s2", makeFacets("s2", "mostly_achieved", "essential"));
|
|
92
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
93
|
+
// frequency=1, timeCost=1, crossProject=0, recency=1, successRate=1, helpfulness=1
|
|
94
|
+
// overall = 0.30*1 + 0.20*1 + 0.20*0 + 0.10*1 + 0.10*1 + 0.10*1 = 0.80
|
|
95
|
+
expect(topic.reusabilityScore.overall).toBe(0.8);
|
|
96
|
+
expect(topic.reusabilityScore.successRate).toBe(1);
|
|
97
|
+
expect(topic.reusabilityScore.helpfulness).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// ─── FacetsMap with partial coverage ────────────────────────────────────────
|
|
101
|
+
describe("computeReusabilityScores with partial facets coverage", () => {
|
|
102
|
+
it("defaults missing sessions to 0.5 for successRate and helpfulness", () => {
|
|
103
|
+
const topic = makeTopic({
|
|
104
|
+
sessionIds: ["s1", "s2"],
|
|
105
|
+
sessionCount: 2,
|
|
106
|
+
totalDurationMinutes: 120,
|
|
107
|
+
projects: ["proj"],
|
|
108
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
109
|
+
});
|
|
110
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
111
|
+
// Only s1 has facets
|
|
112
|
+
const facetsMap = new Map();
|
|
113
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "essential"));
|
|
114
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
115
|
+
// successRate = (1.0 + 0.5) / 2 = 0.75
|
|
116
|
+
// helpfulness = (1.0 + 0.5) / 2 = 0.75
|
|
117
|
+
expect(topic.reusabilityScore.successRate).toBe(0.75);
|
|
118
|
+
expect(topic.reusabilityScore.helpfulness).toBe(0.75);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ─── Not achieved + unhelpful lowers score ──────────────────────────────────
|
|
122
|
+
describe("computeReusabilityScores with negative facets", () => {
|
|
123
|
+
it("sets successRate=0 and helpfulness=0 for not_achieved + unhelpful", () => {
|
|
124
|
+
const topic = makeTopic({
|
|
125
|
+
sessionIds: ["s1"],
|
|
126
|
+
sessionCount: 1,
|
|
127
|
+
totalDurationMinutes: 60,
|
|
128
|
+
projects: ["proj"],
|
|
129
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
130
|
+
});
|
|
131
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
132
|
+
const facetsMap = new Map();
|
|
133
|
+
facetsMap.set("s1", makeFacets("s1", "not_achieved", "unhelpful"));
|
|
134
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
135
|
+
expect(topic.reusabilityScore.successRate).toBe(0);
|
|
136
|
+
expect(topic.reusabilityScore.helpfulness).toBe(0);
|
|
137
|
+
// overall = 0.30*1 + 0.20*1 + 0.20*0 + 0.10*1 + 0.10*0 + 0.10*0 = 0.60
|
|
138
|
+
expect(topic.reusabilityScore.overall).toBe(0.6);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// ─── Score fields set on ReusabilityScore ───────────────────────────────────
|
|
142
|
+
describe("ReusabilityScore fields with facets", () => {
|
|
143
|
+
it("sets successRate and helpfulness fields when facetsMap is provided", () => {
|
|
144
|
+
const topic = makeTopic({
|
|
145
|
+
sessionIds: ["s1"],
|
|
146
|
+
sessionCount: 1,
|
|
147
|
+
totalDurationMinutes: 60,
|
|
148
|
+
projects: ["proj"],
|
|
149
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
150
|
+
});
|
|
151
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
152
|
+
const facetsMap = new Map();
|
|
153
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "very_helpful"));
|
|
154
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
155
|
+
expect(topic.reusabilityScore.successRate).toBeDefined();
|
|
156
|
+
expect(topic.reusabilityScore.helpfulness).toBeDefined();
|
|
157
|
+
expect(typeof topic.reusabilityScore.successRate).toBe("number");
|
|
158
|
+
expect(typeof topic.reusabilityScore.helpfulness).toBe("number");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
// ─── Score fields NOT set when no facets ────────────────────────────────────
|
|
162
|
+
describe("ReusabilityScore fields without facets", () => {
|
|
163
|
+
it("does not set successRate and helpfulness when no facetsMap", () => {
|
|
164
|
+
const topic = makeTopic({
|
|
165
|
+
sessionCount: 1,
|
|
166
|
+
totalDurationMinutes: 60,
|
|
167
|
+
projects: ["proj"],
|
|
168
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
169
|
+
});
|
|
170
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
171
|
+
computeReusabilityScores([topic], now);
|
|
172
|
+
expect(topic.reusabilityScore.successRate).toBeUndefined();
|
|
173
|
+
expect(topic.reusabilityScore.helpfulness).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
});
|