@chigichan24/crune 0.1.4 → 0.1.6

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 CHANGED
@@ -17,7 +17,7 @@ Decipher the traces etched in past sessions and resurrect them as reusable skill
17
17
  - **Overview Dashboard** --- Activity heatmap, project distribution, tool usage trends, duration distribution, model usage, and top edited files
18
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))
19
19
  - **Tacit Knowledge** --- Extracted workflow patterns, tool sequences, and pain points (long sessions, hot files)
20
- - **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
21
21
  - **Skill Synthesis** --- Synthesize reusable skill definitions from the knowledge graph ([algorithm details](docs/skill-generation-algorithm.md))
22
22
 
23
23
  ### Overview Dashboard
@@ -77,8 +77,10 @@ npm run dev
77
77
  -> parse & build turns
78
78
  -> extract metadata, subagents, linked plans
79
79
  -> session summarization (centrality-based representative prompt, workType classification)
80
- -> TF-IDF + Tool-IDF + structural features -> Truncated SVD -> agglomerative clustering -> Louvain
81
- -> skill synthesis (reusability score top-N -> claude -p)
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)
82
84
  -> output:
83
85
  public/data/sessions/index.json (session list)
84
86
  public/data/sessions/overview.json (cross-session analytics + knowledge graph)
@@ -91,9 +93,20 @@ Custom paths:
91
93
  npm run analyze-sessions -- --sessions-dir /path/to/sessions --output-dir /path/to/output
92
94
  ```
93
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
+
94
107
  ## Session Summarization
95
108
 
96
- The session list displays auto-generated summaries, processed entirely locally without LLM.
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.
97
110
 
98
111
  - **Representative prompt selection**: Selects the most representative prompt from plan mode turns using Jaccard centrality with position weighting
99
112
  - **workType classification**: Automatically classifies each session into one of four types:
@@ -138,13 +151,15 @@ npm run analyze-sessions -- --skip-synthesis
138
151
  | `npm run skill-server` | Skill synthesis local server (localhost:3456) |
139
152
  | `npm run dev:full` | skill-server + Vite dev server together |
140
153
 
141
- ### Synthesis options for analyze-sessions
154
+ ### Options for analyze-sessions
142
155
 
143
156
  | Flag | Description |
144
157
  |------|-------------|
145
158
  | `--synthesis-model <model>` | Model to use for synthesis (e.g. `haiku` for speed) |
146
159
  | `--synthesis-count <n>` | Number of candidates to synthesize (default: 5) |
147
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 |
148
163
 
149
164
  ## Tech Stack
150
165
 
@@ -172,6 +187,8 @@ scripts/
172
187
  skill-synthesizer.ts # Skill synthesis (claude -p)
173
188
  skill-server.ts # Synthesis HTTP server
174
189
  knowledge-graph-builder.ts # Semantic embedding + graph construction
190
+ knowledge-graph/
191
+ facets-reader.ts # /insights facets data reader + normalization
175
192
  public/
176
193
  data/sessions/ # Generated JSON (gitignored)
177
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
+ });
@@ -123,3 +123,20 @@ describe("tokenize", () => {
123
123
  expect(tokens).not.toContain("go");
124
124
  });
125
125
  });
126
+ describe("tokenize - large input regression (Issue #18)", () => {
127
+ // Real Claude Code sessions can contain `ls -R` / `find` / `tree` dumps
128
+ // with hundreds of thousands of path segments. `tokens.push(...arr)` on
129
+ // such an array hits V8's argument count limit and throws
130
+ // `RangeError: Maximum call stack size exceeded` even though it is not
131
+ // recursion. See https://github.com/chigichan24/crune/issues/18.
132
+ it("extractPathTokens does not throw on a huge corpus of file paths", () => {
133
+ const pathSegments = Array.from({ length: 100_000 }, (_, i) => `/dir${i}/file${i}.ts`);
134
+ const text = pathSegments.join(" ");
135
+ expect(() => extractPathTokens(text)).not.toThrow();
136
+ });
137
+ it("tokenize does not throw on a huge corpus of file paths", () => {
138
+ const pathSegments = Array.from({ length: 100_000 }, (_, i) => `/dir${i}/file${i}.ts`);
139
+ const text = pathSegments.join(" ");
140
+ expect(() => tokenize(text)).not.toThrow();
141
+ });
142
+ });