@chigichan24/crune 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +155 -0
- package/bin/crune.js +2 -0
- package/dist-cli/__tests__/cli.test.js +63 -0
- package/dist-cli/__tests__/clustering.test.js +200 -0
- package/dist-cli/__tests__/community.test.js +115 -0
- package/dist-cli/__tests__/edges.test.js +130 -0
- package/dist-cli/__tests__/feature-extraction.test.js +66 -0
- package/dist-cli/__tests__/fixtures.js +192 -0
- package/dist-cli/__tests__/orchestrator.test.js +253 -0
- package/dist-cli/__tests__/session-parser.test.js +335 -0
- package/dist-cli/__tests__/session-summarizer.test.js +117 -0
- package/dist-cli/__tests__/skill-server.test.js +191 -0
- package/dist-cli/__tests__/svd.test.js +112 -0
- package/dist-cli/__tests__/tfidf.test.js +88 -0
- package/dist-cli/__tests__/tokenizer.test.js +125 -0
- package/dist-cli/__tests__/topic-nodes.test.js +184 -0
- package/dist-cli/analyze-sessions.js +476 -0
- package/dist-cli/cli.js +215 -0
- package/dist-cli/knowledge-graph/clustering.js +174 -0
- package/dist-cli/knowledge-graph/community.js +220 -0
- package/dist-cli/knowledge-graph/constants.js +58 -0
- package/dist-cli/knowledge-graph/edges.js +193 -0
- package/dist-cli/knowledge-graph/feature-extraction.js +124 -0
- package/dist-cli/knowledge-graph/index.js +235 -0
- package/dist-cli/knowledge-graph/reusability.js +51 -0
- package/dist-cli/knowledge-graph/similarity.js +13 -0
- package/dist-cli/knowledge-graph/skill-generator.js +203 -0
- package/dist-cli/knowledge-graph/svd.js +195 -0
- package/dist-cli/knowledge-graph/tfidf.js +54 -0
- package/dist-cli/knowledge-graph/tokenizer.js +66 -0
- package/dist-cli/knowledge-graph/tool-pattern.js +173 -0
- package/dist-cli/knowledge-graph/topic-nodes.js +199 -0
- package/dist-cli/knowledge-graph/types.js +4 -0
- package/dist-cli/knowledge-graph-builder.js +27 -0
- package/dist-cli/session-parser.js +360 -0
- package/dist-cli/session-summarizer.js +133 -0
- package/dist-cli/skill-server.js +62 -0
- package/dist-cli/skill-synthesizer.js +189 -0
- package/package.json +47 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildSemanticKnowledgeGraph } from "../knowledge-graph-builder.js";
|
|
3
|
+
import { editHeavySession, readHeavySession, subagentSession, japaneseSession, makeSession, } from "./fixtures.js";
|
|
4
|
+
describe("buildSemanticKnowledgeGraph", () => {
|
|
5
|
+
it("returns empty graph for empty sessions array", () => {
|
|
6
|
+
const result = buildSemanticKnowledgeGraph([]);
|
|
7
|
+
expect(result).toEqual({
|
|
8
|
+
nodes: [],
|
|
9
|
+
edges: [],
|
|
10
|
+
communities: [],
|
|
11
|
+
metrics: {
|
|
12
|
+
totalTopics: 0,
|
|
13
|
+
totalEdges: 0,
|
|
14
|
+
graphDensity: 0,
|
|
15
|
+
modularity: 0,
|
|
16
|
+
isolatedTopicCount: 0,
|
|
17
|
+
bridgeTopicIds: [],
|
|
18
|
+
},
|
|
19
|
+
enrichedToolSequences: [],
|
|
20
|
+
skillCandidates: [],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it("returns 1 topic and 0 edges for a single session", () => {
|
|
24
|
+
const result = buildSemanticKnowledgeGraph([editHeavySession]);
|
|
25
|
+
expect(result.nodes).toHaveLength(1);
|
|
26
|
+
expect(result.edges).toHaveLength(0);
|
|
27
|
+
expect(result.communities).toHaveLength(1);
|
|
28
|
+
expect(result.metrics.totalTopics).toBe(1);
|
|
29
|
+
expect(result.metrics.totalEdges).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
it("produces >= 1 topic with correct structure for 5+ varied sessions", () => {
|
|
32
|
+
const sessions = [
|
|
33
|
+
editHeavySession,
|
|
34
|
+
readHeavySession,
|
|
35
|
+
subagentSession,
|
|
36
|
+
japaneseSession,
|
|
37
|
+
makeSession({
|
|
38
|
+
sessionId: "deploy-session",
|
|
39
|
+
projectDisplayName: "crune",
|
|
40
|
+
turns: [
|
|
41
|
+
{
|
|
42
|
+
userPrompt: "Deploy the application to production server",
|
|
43
|
+
assistantTexts: ["Deploying to production"],
|
|
44
|
+
toolCalls: [
|
|
45
|
+
{ toolName: "Bash", input: { command: "npm run build" } },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
meta: {
|
|
50
|
+
sessionId: "deploy-session",
|
|
51
|
+
createdAt: "2025-01-04T10:00:00Z",
|
|
52
|
+
lastActiveAt: "2025-01-04T10:30:00Z",
|
|
53
|
+
durationMinutes: 30,
|
|
54
|
+
filesEdited: ["/deploy/config.ts"],
|
|
55
|
+
gitBranch: "deploy",
|
|
56
|
+
toolBreakdown: { Bash: 3 },
|
|
57
|
+
subagentCount: 0,
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
makeSession({
|
|
61
|
+
sessionId: "testing-session",
|
|
62
|
+
projectDisplayName: "crune",
|
|
63
|
+
turns: [
|
|
64
|
+
{
|
|
65
|
+
userPrompt: "Write unit tests for the authentication module with jest",
|
|
66
|
+
assistantTexts: [
|
|
67
|
+
"I will create comprehensive test suites for the auth module",
|
|
68
|
+
],
|
|
69
|
+
toolCalls: [
|
|
70
|
+
{
|
|
71
|
+
toolName: "Write",
|
|
72
|
+
input: { file_path: "/src/__tests__/auth.test.ts", content: "test code" },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
toolName: "Bash",
|
|
76
|
+
input: { command: "npx jest --coverage" },
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
userPrompt: "Add integration tests for the login flow",
|
|
82
|
+
assistantTexts: [
|
|
83
|
+
"Adding integration tests for the login endpoint",
|
|
84
|
+
],
|
|
85
|
+
toolCalls: [
|
|
86
|
+
{
|
|
87
|
+
toolName: "Write",
|
|
88
|
+
input: {
|
|
89
|
+
file_path: "/src/__tests__/login.integration.test.ts",
|
|
90
|
+
content: "integration test code",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
meta: {
|
|
97
|
+
sessionId: "testing-session",
|
|
98
|
+
createdAt: "2025-01-05T14:00:00Z",
|
|
99
|
+
lastActiveAt: "2025-01-05T15:00:00Z",
|
|
100
|
+
durationMinutes: 60,
|
|
101
|
+
filesEdited: [
|
|
102
|
+
"/src/__tests__/auth.test.ts",
|
|
103
|
+
"/src/__tests__/login.integration.test.ts",
|
|
104
|
+
],
|
|
105
|
+
gitBranch: "feature/auth-tests",
|
|
106
|
+
toolBreakdown: { Write: 2, Bash: 1 },
|
|
107
|
+
subagentCount: 0,
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
makeSession({
|
|
111
|
+
sessionId: "refactor-session",
|
|
112
|
+
projectDisplayName: "other-project",
|
|
113
|
+
turns: [
|
|
114
|
+
{
|
|
115
|
+
userPrompt: "Refactor the database layer to use connection pooling",
|
|
116
|
+
assistantTexts: [
|
|
117
|
+
"Refactoring database connections to use a pool",
|
|
118
|
+
],
|
|
119
|
+
toolCalls: [
|
|
120
|
+
{
|
|
121
|
+
toolName: "Edit",
|
|
122
|
+
input: {
|
|
123
|
+
file_path: "/src/db/connection.ts",
|
|
124
|
+
old_string: "createConnection",
|
|
125
|
+
new_string: "createPool",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
toolName: "Read",
|
|
130
|
+
input: { file_path: "/src/db/queries.ts" },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
meta: {
|
|
136
|
+
sessionId: "refactor-session",
|
|
137
|
+
createdAt: "2025-01-06T09:00:00Z",
|
|
138
|
+
lastActiveAt: "2025-01-06T10:00:00Z",
|
|
139
|
+
durationMinutes: 60,
|
|
140
|
+
filesEdited: ["/src/db/connection.ts", "/src/db/queries.ts"],
|
|
141
|
+
gitBranch: "refactor/db-pool",
|
|
142
|
+
toolBreakdown: { Edit: 1, Read: 1 },
|
|
143
|
+
subagentCount: 0,
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
const result = buildSemanticKnowledgeGraph(sessions);
|
|
148
|
+
// Should produce at least 1 topic
|
|
149
|
+
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
|
|
150
|
+
// Verify top-level structure
|
|
151
|
+
expect(result).toHaveProperty("nodes");
|
|
152
|
+
expect(result).toHaveProperty("edges");
|
|
153
|
+
expect(result).toHaveProperty("communities");
|
|
154
|
+
expect(result).toHaveProperty("metrics");
|
|
155
|
+
expect(Array.isArray(result.nodes)).toBe(true);
|
|
156
|
+
expect(Array.isArray(result.edges)).toBe(true);
|
|
157
|
+
expect(Array.isArray(result.communities)).toBe(true);
|
|
158
|
+
// Verify metrics structure
|
|
159
|
+
expect(result.metrics).toHaveProperty("totalTopics");
|
|
160
|
+
expect(result.metrics).toHaveProperty("totalEdges");
|
|
161
|
+
expect(result.metrics).toHaveProperty("graphDensity");
|
|
162
|
+
expect(result.metrics).toHaveProperty("modularity");
|
|
163
|
+
expect(result.metrics).toHaveProperty("isolatedTopicCount");
|
|
164
|
+
expect(result.metrics).toHaveProperty("bridgeTopicIds");
|
|
165
|
+
expect(typeof result.metrics.totalTopics).toBe("number");
|
|
166
|
+
expect(typeof result.metrics.totalEdges).toBe("number");
|
|
167
|
+
expect(typeof result.metrics.graphDensity).toBe("number");
|
|
168
|
+
expect(typeof result.metrics.modularity).toBe("number");
|
|
169
|
+
expect(typeof result.metrics.isolatedTopicCount).toBe("number");
|
|
170
|
+
expect(Array.isArray(result.metrics.bridgeTopicIds)).toBe(true);
|
|
171
|
+
// Metrics should be consistent with nodes/edges
|
|
172
|
+
expect(result.metrics.totalTopics).toBe(result.nodes.length);
|
|
173
|
+
expect(result.metrics.totalEdges).toBe(result.edges.length);
|
|
174
|
+
});
|
|
175
|
+
it("nodes and edges have all required fields", () => {
|
|
176
|
+
const sessions = [
|
|
177
|
+
editHeavySession,
|
|
178
|
+
readHeavySession,
|
|
179
|
+
subagentSession,
|
|
180
|
+
japaneseSession,
|
|
181
|
+
makeSession({
|
|
182
|
+
sessionId: "deploy-session",
|
|
183
|
+
projectDisplayName: "crune",
|
|
184
|
+
turns: [
|
|
185
|
+
{
|
|
186
|
+
userPrompt: "Deploy the application to production server",
|
|
187
|
+
assistantTexts: ["Deploying to production"],
|
|
188
|
+
toolCalls: [
|
|
189
|
+
{ toolName: "Bash", input: { command: "npm run build" } },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
meta: {
|
|
194
|
+
sessionId: "deploy-session",
|
|
195
|
+
createdAt: "2025-01-04T10:00:00Z",
|
|
196
|
+
lastActiveAt: "2025-01-04T10:30:00Z",
|
|
197
|
+
durationMinutes: 30,
|
|
198
|
+
filesEdited: ["/deploy/config.ts"],
|
|
199
|
+
gitBranch: "deploy",
|
|
200
|
+
toolBreakdown: { Bash: 3 },
|
|
201
|
+
subagentCount: 0,
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
];
|
|
205
|
+
const result = buildSemanticKnowledgeGraph(sessions);
|
|
206
|
+
// Validate node fields
|
|
207
|
+
for (const node of result.nodes) {
|
|
208
|
+
expect(node).toHaveProperty("id");
|
|
209
|
+
expect(node).toHaveProperty("label");
|
|
210
|
+
expect(node).toHaveProperty("keywords");
|
|
211
|
+
expect(node).toHaveProperty("project");
|
|
212
|
+
expect(node).toHaveProperty("projects");
|
|
213
|
+
expect(node).toHaveProperty("sessionIds");
|
|
214
|
+
expect(node).toHaveProperty("sessionCount");
|
|
215
|
+
expect(node).toHaveProperty("totalDurationMinutes");
|
|
216
|
+
expect(node).toHaveProperty("totalToolCalls");
|
|
217
|
+
expect(node).toHaveProperty("firstSeen");
|
|
218
|
+
expect(node).toHaveProperty("lastSeen");
|
|
219
|
+
expect(node).toHaveProperty("betweennessCentrality");
|
|
220
|
+
expect(node).toHaveProperty("degreeCentrality");
|
|
221
|
+
expect(node).toHaveProperty("communityId");
|
|
222
|
+
expect(node).toHaveProperty("representativePrompts");
|
|
223
|
+
expect(node).toHaveProperty("suggestedPrompt");
|
|
224
|
+
expect(node).toHaveProperty("toolSignature");
|
|
225
|
+
expect(node).toHaveProperty("dominantRole");
|
|
226
|
+
expect(typeof node.id).toBe("string");
|
|
227
|
+
expect(typeof node.label).toBe("string");
|
|
228
|
+
expect(Array.isArray(node.keywords)).toBe(true);
|
|
229
|
+
expect(Array.isArray(node.sessionIds)).toBe(true);
|
|
230
|
+
expect(typeof node.sessionCount).toBe("number");
|
|
231
|
+
expect(typeof node.betweennessCentrality).toBe("number");
|
|
232
|
+
expect(typeof node.degreeCentrality).toBe("number");
|
|
233
|
+
expect(typeof node.communityId).toBe("number");
|
|
234
|
+
}
|
|
235
|
+
// Validate edge fields (if any edges exist)
|
|
236
|
+
for (const edge of result.edges) {
|
|
237
|
+
expect(edge).toHaveProperty("source");
|
|
238
|
+
expect(edge).toHaveProperty("target");
|
|
239
|
+
expect(edge).toHaveProperty("type");
|
|
240
|
+
expect(edge).toHaveProperty("strength");
|
|
241
|
+
expect(edge).toHaveProperty("label");
|
|
242
|
+
expect(edge).toHaveProperty("signals");
|
|
243
|
+
expect(typeof edge.source).toBe("string");
|
|
244
|
+
expect(typeof edge.target).toBe("string");
|
|
245
|
+
expect(typeof edge.type).toBe("string");
|
|
246
|
+
expect(typeof edge.strength).toBe("number");
|
|
247
|
+
expect(typeof edge.label).toBe("string");
|
|
248
|
+
expect(edge.signals).toHaveProperty("semanticSimilarity");
|
|
249
|
+
expect(edge.signals).toHaveProperty("fileOverlap");
|
|
250
|
+
expect(edge.signals).toHaveProperty("sessionOverlap");
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { inferProjectName, truncate, isRealUserMessage, isToolResultMessage, extractUserPrompt, buildTurns, extractMetadata, } from "../session-parser.js";
|
|
3
|
+
describe("inferProjectName", () => {
|
|
4
|
+
it("extracts org/repo from github-com pattern", () => {
|
|
5
|
+
expect(inferProjectName("-Users-kazuki-chigita-src-github-com-chigichan24-crune")).toBe("chigichan24/crune");
|
|
6
|
+
});
|
|
7
|
+
it("extracts org/repo from another github-com path", () => {
|
|
8
|
+
expect(inferProjectName("-Users-foo-github-com-org-repo")).toBe("org/repo");
|
|
9
|
+
});
|
|
10
|
+
it("falls back to last 2 segments when no github-com pattern", () => {
|
|
11
|
+
expect(inferProjectName("-Users-foo-bar-projects-myapp")).toBe("projects/myapp");
|
|
12
|
+
});
|
|
13
|
+
it("returns dirName as-is for single segment", () => {
|
|
14
|
+
expect(inferProjectName("short")).toBe("short");
|
|
15
|
+
});
|
|
16
|
+
it("returns empty string for empty input", () => {
|
|
17
|
+
expect(inferProjectName("")).toBe("");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("truncate", () => {
|
|
21
|
+
it("returns unchanged text shorter than limit", () => {
|
|
22
|
+
expect(truncate("hello", 10)).toBe("hello");
|
|
23
|
+
});
|
|
24
|
+
it("returns unchanged text exactly at limit", () => {
|
|
25
|
+
expect(truncate("12345", 5)).toBe("12345");
|
|
26
|
+
});
|
|
27
|
+
it("truncates text longer than limit with ellipsis", () => {
|
|
28
|
+
const result = truncate("hello world", 5);
|
|
29
|
+
expect(result).toBe("hello\u2026");
|
|
30
|
+
expect(result.length).toBe(6);
|
|
31
|
+
});
|
|
32
|
+
it("returns empty string for empty input", () => {
|
|
33
|
+
expect(truncate("", 10)).toBe("");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("isRealUserMessage", () => {
|
|
37
|
+
it("returns true for plain user message with string content", () => {
|
|
38
|
+
const line = { type: "user", message: { content: "hello" } };
|
|
39
|
+
expect(isRealUserMessage(line)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it("returns false when isMeta is true", () => {
|
|
42
|
+
const line = { type: "user", isMeta: true, message: { content: "hello" } };
|
|
43
|
+
expect(isRealUserMessage(line)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
it("returns false when content contains command-name tag", () => {
|
|
46
|
+
const line = { type: "user", message: { content: "<command-name>foo</command-name>" } };
|
|
47
|
+
expect(isRealUserMessage(line)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
it("returns false for empty/whitespace content", () => {
|
|
50
|
+
const line = { type: "user", message: { content: "" } };
|
|
51
|
+
expect(isRealUserMessage(line)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it("returns false for assistant type", () => {
|
|
54
|
+
const line = { type: "assistant", message: { content: "hello" } };
|
|
55
|
+
expect(isRealUserMessage(line)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("returns false when content is array with tool_result", () => {
|
|
58
|
+
const line = {
|
|
59
|
+
type: "user",
|
|
60
|
+
message: { content: [{ type: "tool_result", tool_use_id: "x", content: "result" }] },
|
|
61
|
+
};
|
|
62
|
+
expect(isRealUserMessage(line)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
it("returns true when content is array with text blocks", () => {
|
|
65
|
+
const line = {
|
|
66
|
+
type: "user",
|
|
67
|
+
message: { content: [{ type: "text", text: "hello" }] },
|
|
68
|
+
};
|
|
69
|
+
expect(isRealUserMessage(line)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("isToolResultMessage", () => {
|
|
73
|
+
it("returns true for user message with tool_result content", () => {
|
|
74
|
+
const line = {
|
|
75
|
+
type: "user",
|
|
76
|
+
message: { content: [{ type: "tool_result", tool_use_id: "x" }] },
|
|
77
|
+
};
|
|
78
|
+
expect(isToolResultMessage(line)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it("returns false for user message with string content", () => {
|
|
81
|
+
const line = { type: "user", message: { content: "hello" } };
|
|
82
|
+
expect(isToolResultMessage(line)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it("returns false for assistant message with tool_result content", () => {
|
|
85
|
+
const line = {
|
|
86
|
+
type: "assistant",
|
|
87
|
+
message: { content: [{ type: "tool_result" }] },
|
|
88
|
+
};
|
|
89
|
+
expect(isToolResultMessage(line)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe("extractUserPrompt", () => {
|
|
93
|
+
it("returns string content directly", () => {
|
|
94
|
+
const line = { type: "user", message: { content: "hello world" } };
|
|
95
|
+
expect(extractUserPrompt(line)).toBe("hello world");
|
|
96
|
+
});
|
|
97
|
+
it("joins text blocks from array content", () => {
|
|
98
|
+
const line = {
|
|
99
|
+
type: "user",
|
|
100
|
+
message: { content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] },
|
|
101
|
+
};
|
|
102
|
+
expect(extractUserPrompt(line)).toBe("first\nsecond");
|
|
103
|
+
});
|
|
104
|
+
it("returns empty string for array with no text blocks", () => {
|
|
105
|
+
const line = {
|
|
106
|
+
type: "user",
|
|
107
|
+
message: { content: [{ type: "tool_result", tool_use_id: "x" }] },
|
|
108
|
+
};
|
|
109
|
+
expect(extractUserPrompt(line)).toBe("");
|
|
110
|
+
});
|
|
111
|
+
it("returns empty string when no content", () => {
|
|
112
|
+
const line = { type: "user", message: {} };
|
|
113
|
+
expect(extractUserPrompt(line)).toBe("");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("buildTurns", () => {
|
|
117
|
+
it("returns empty array for empty input", () => {
|
|
118
|
+
expect(buildTurns([])).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
it("creates 1 turn from single user + assistant", () => {
|
|
121
|
+
const lines = [
|
|
122
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "hello" } },
|
|
123
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "hi there" }], model: "claude-sonnet" } },
|
|
124
|
+
];
|
|
125
|
+
const turns = buildTurns(lines);
|
|
126
|
+
expect(turns).toHaveLength(1);
|
|
127
|
+
expect(turns[0].turnIndex).toBe(0);
|
|
128
|
+
expect(turns[0].userPrompt).toBe("hello");
|
|
129
|
+
expect(turns[0].timestamp).toBe("2026-01-01T00:00:00Z");
|
|
130
|
+
expect(turns[0].assistantTexts).toEqual(["hi there"]);
|
|
131
|
+
expect(turns[0].model).toBe("claude-sonnet");
|
|
132
|
+
});
|
|
133
|
+
it("creates multiple turns with correct turnIndex", () => {
|
|
134
|
+
const lines = [
|
|
135
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "first" } },
|
|
136
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "reply1" }] } },
|
|
137
|
+
{ type: "user", timestamp: "2026-01-01T00:01:00Z", message: { content: "second" } },
|
|
138
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "reply2" }] } },
|
|
139
|
+
];
|
|
140
|
+
const turns = buildTurns(lines);
|
|
141
|
+
expect(turns).toHaveLength(2);
|
|
142
|
+
expect(turns[0].turnIndex).toBe(0);
|
|
143
|
+
expect(turns[0].userPrompt).toBe("first");
|
|
144
|
+
expect(turns[1].turnIndex).toBe(1);
|
|
145
|
+
expect(turns[1].userPrompt).toBe("second");
|
|
146
|
+
});
|
|
147
|
+
it("populates toolCalls from tool_use blocks", () => {
|
|
148
|
+
const lines = [
|
|
149
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "do something" } },
|
|
150
|
+
{
|
|
151
|
+
type: "assistant",
|
|
152
|
+
message: {
|
|
153
|
+
content: [
|
|
154
|
+
{ type: "tool_use", id: "tu1", name: "Read", input: { file_path: "/tmp/test.ts" } },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
const turns = buildTurns(lines);
|
|
160
|
+
expect(turns[0].toolCalls).toHaveLength(1);
|
|
161
|
+
expect(turns[0].toolCalls[0].toolUseId).toBe("tu1");
|
|
162
|
+
expect(turns[0].toolCalls[0].toolName).toBe("Read");
|
|
163
|
+
expect(turns[0].toolCalls[0].input).toEqual({ file_path: "/tmp/test.ts" });
|
|
164
|
+
});
|
|
165
|
+
it("matches tool_result to toolCall by tool_use_id", () => {
|
|
166
|
+
const lines = [
|
|
167
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "read file" } },
|
|
168
|
+
{
|
|
169
|
+
type: "assistant",
|
|
170
|
+
message: {
|
|
171
|
+
content: [
|
|
172
|
+
{ type: "tool_use", id: "tu1", name: "Read", input: { file_path: "/tmp/a.ts" } },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: "user",
|
|
178
|
+
message: {
|
|
179
|
+
content: [{ type: "tool_result", tool_use_id: "tu1", content: "file contents here" }],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
const turns = buildTurns(lines);
|
|
184
|
+
expect(turns[0].toolCalls[0].result).toBe("file contents here");
|
|
185
|
+
});
|
|
186
|
+
it("captures thinking blocks in assistantThinking", () => {
|
|
187
|
+
const lines = [
|
|
188
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "think" } },
|
|
189
|
+
{
|
|
190
|
+
type: "assistant",
|
|
191
|
+
message: {
|
|
192
|
+
content: [
|
|
193
|
+
{ type: "thinking", thinking: "let me consider..." },
|
|
194
|
+
{ type: "text", text: "here is my answer" },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
const turns = buildTurns(lines);
|
|
200
|
+
expect(turns[0].assistantThinking).toHaveLength(1);
|
|
201
|
+
expect(turns[0].assistantThinking[0]).toBe("let me consider...");
|
|
202
|
+
});
|
|
203
|
+
it("skips system and file-history-snapshot lines", () => {
|
|
204
|
+
const lines = [
|
|
205
|
+
{ type: "system", message: { content: "system init" } },
|
|
206
|
+
{ type: "file-history-snapshot", snapshot: { timestamp: "2026-01-01T00:00:00Z" } },
|
|
207
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "hello" } },
|
|
208
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } },
|
|
209
|
+
];
|
|
210
|
+
const turns = buildTurns(lines);
|
|
211
|
+
expect(turns).toHaveLength(1);
|
|
212
|
+
expect(turns[0].userPrompt).toBe("hello");
|
|
213
|
+
});
|
|
214
|
+
it("truncates Write tool_use content and adds contentLength", () => {
|
|
215
|
+
const longContent = "x".repeat(1000);
|
|
216
|
+
const lines = [
|
|
217
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "write file" } },
|
|
218
|
+
{
|
|
219
|
+
type: "assistant",
|
|
220
|
+
message: {
|
|
221
|
+
content: [
|
|
222
|
+
{ type: "tool_use", id: "tu1", name: "Write", input: { file_path: "/tmp/out.ts", content: longContent } },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
const turns = buildTurns(lines);
|
|
228
|
+
const tc = turns[0].toolCalls[0];
|
|
229
|
+
expect(tc.toolName).toBe("Write");
|
|
230
|
+
expect(tc.input.content.length).toBeLessThan(longContent.length);
|
|
231
|
+
expect(tc.input.contentLength).toBe(1000);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
describe("extractMetadata", () => {
|
|
235
|
+
const baseSessionFile = {
|
|
236
|
+
filePath: "/tmp/test.jsonl",
|
|
237
|
+
sessionId: "test-session-id",
|
|
238
|
+
projectDir: "-Users-foo-github-com-org-repo",
|
|
239
|
+
projectDisplayName: "org/repo",
|
|
240
|
+
subagentFiles: ["/tmp/sub1.jsonl", "/tmp/sub2.jsonl"],
|
|
241
|
+
};
|
|
242
|
+
it("extracts cwd, gitBranch, version, slug from first line that has them", () => {
|
|
243
|
+
const lines = [
|
|
244
|
+
{ type: "user", cwd: "/home/user/project", gitBranch: "main", version: "1.0.0", slug: "my-plan", timestamp: "2026-01-01T00:00:00Z", message: { content: "hello" } },
|
|
245
|
+
{ type: "user", cwd: "/other/path", gitBranch: "dev", version: "2.0.0", slug: "other-plan", timestamp: "2026-01-01T00:01:00Z", message: { content: "world" } },
|
|
246
|
+
];
|
|
247
|
+
const turns = buildTurns(lines);
|
|
248
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
249
|
+
expect(meta.cwd).toBe("/home/user/project");
|
|
250
|
+
expect(meta.gitBranch).toBe("main");
|
|
251
|
+
expect(meta.version).toBe("1.0.0");
|
|
252
|
+
expect(meta.slug).toBe("my-plan");
|
|
253
|
+
});
|
|
254
|
+
it("computes correct createdAt and lastActiveAt", () => {
|
|
255
|
+
const lines = [
|
|
256
|
+
{ type: "user", timestamp: "2026-01-01T10:00:00Z", message: { content: "first" } },
|
|
257
|
+
{ type: "assistant", timestamp: "2026-01-01T10:05:00Z", message: { content: [{ type: "text", text: "reply" }] } },
|
|
258
|
+
{ type: "user", timestamp: "2026-01-01T10:30:00Z", message: { content: "second" } },
|
|
259
|
+
];
|
|
260
|
+
const turns = buildTurns(lines);
|
|
261
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
262
|
+
expect(meta.createdAt).toBe("2026-01-01T10:00:00Z");
|
|
263
|
+
expect(meta.lastActiveAt).toBe("2026-01-01T10:30:00Z");
|
|
264
|
+
});
|
|
265
|
+
it("computes durationMinutes correctly", () => {
|
|
266
|
+
const lines = [
|
|
267
|
+
{ type: "user", timestamp: "2026-01-01T10:00:00Z", message: { content: "start" } },
|
|
268
|
+
{ type: "user", timestamp: "2026-01-01T10:45:00Z", message: { content: "end" } },
|
|
269
|
+
];
|
|
270
|
+
const turns = buildTurns(lines);
|
|
271
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
272
|
+
expect(meta.durationMinutes).toBe(45);
|
|
273
|
+
});
|
|
274
|
+
it("counts tool breakdown from turns", () => {
|
|
275
|
+
const lines = [
|
|
276
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "do stuff" } },
|
|
277
|
+
{
|
|
278
|
+
type: "assistant",
|
|
279
|
+
message: {
|
|
280
|
+
content: [
|
|
281
|
+
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
|
|
282
|
+
{ type: "tool_use", id: "t2", name: "Read", input: { file_path: "/b.ts" } },
|
|
283
|
+
{ type: "tool_use", id: "t3", name: "Edit", input: { file_path: "/a.ts", old_string: "x", new_string: "y" } },
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
const turns = buildTurns(lines);
|
|
289
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
290
|
+
expect(meta.toolBreakdown).toEqual({ Read: 2, Edit: 1 });
|
|
291
|
+
});
|
|
292
|
+
it("tracks filesEdited from Edit/Write toolCalls", () => {
|
|
293
|
+
const lines = [
|
|
294
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "edit files" } },
|
|
295
|
+
{
|
|
296
|
+
type: "assistant",
|
|
297
|
+
message: {
|
|
298
|
+
content: [
|
|
299
|
+
{ type: "tool_use", id: "t1", name: "Edit", input: { file_path: "/src/a.ts", old_string: "x", new_string: "y" } },
|
|
300
|
+
{ type: "tool_use", id: "t2", name: "Write", input: { file_path: "/src/b.ts", content: "new file" } },
|
|
301
|
+
{ type: "tool_use", id: "t3", name: "Read", input: { file_path: "/src/c.ts" } },
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
const turns = buildTurns(lines);
|
|
307
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
308
|
+
expect(meta.filesEdited.sort()).toEqual(["/src/a.ts", "/src/b.ts"]);
|
|
309
|
+
});
|
|
310
|
+
it("counts modelsUsed from assistant messages", () => {
|
|
311
|
+
const lines = [
|
|
312
|
+
{ type: "user", timestamp: "2026-01-01T00:00:00Z", message: { content: "hello" } },
|
|
313
|
+
{ type: "assistant", timestamp: "2026-01-01T00:00:01Z", message: { model: "claude-sonnet", content: [{ type: "text", text: "hi" }] } },
|
|
314
|
+
{ type: "user", timestamp: "2026-01-01T00:01:00Z", message: { content: "again" } },
|
|
315
|
+
{ type: "assistant", timestamp: "2026-01-01T00:01:01Z", message: { model: "claude-sonnet", content: [{ type: "text", text: "ok" }] } },
|
|
316
|
+
{ type: "assistant", timestamp: "2026-01-01T00:01:02Z", message: { model: "claude-opus", content: [{ type: "text", text: "deep" }] } },
|
|
317
|
+
];
|
|
318
|
+
const turns = buildTurns(lines);
|
|
319
|
+
const meta = extractMetadata(baseSessionFile, lines, turns);
|
|
320
|
+
expect(meta.modelsUsed).toEqual({ "claude-sonnet": 2, "claude-opus": 1 });
|
|
321
|
+
});
|
|
322
|
+
it("returns sensible defaults for empty turns/lines", () => {
|
|
323
|
+
const meta = extractMetadata(baseSessionFile, [], []);
|
|
324
|
+
expect(meta.sessionId).toBe("test-session-id");
|
|
325
|
+
expect(meta.cwd).toBe("");
|
|
326
|
+
expect(meta.gitBranch).toBe("");
|
|
327
|
+
expect(meta.durationMinutes).toBe(0);
|
|
328
|
+
expect(meta.turnCount).toBe(0);
|
|
329
|
+
expect(meta.toolBreakdown).toEqual({});
|
|
330
|
+
expect(meta.filesEdited).toEqual([]);
|
|
331
|
+
expect(meta.modelsUsed).toEqual({});
|
|
332
|
+
expect(meta.firstUserPrompt).toBe("");
|
|
333
|
+
expect(meta.subagentCount).toBe(2);
|
|
334
|
+
});
|
|
335
|
+
});
|