ai-token-usage-lite 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 +24 -0
- package/README.md +106 -0
- package/bin/ai-token-usage-lite.js +8 -0
- package/package.json +40 -0
- package/public/index.html +1003 -0
- package/src/aggregate.js +100 -0
- package/src/cli.js +115 -0
- package/src/doctor.js +30 -0
- package/src/export.js +32 -0
- package/src/fs-utils.js +65 -0
- package/src/paths.js +16 -0
- package/src/providers/claude.js +69 -0
- package/src/providers/codex.js +127 -0
- package/src/server.js +117 -0
- package/src/status.js +40 -0
- package/src/sync.js +33 -0
- package/src/turns/aggregate-turns.js +68 -0
- package/src/turns/claude-turns.js +120 -0
- package/src/turns/codex-turns.js +117 -0
- package/src/turns/common.js +42 -0
- package/src/turns/index.js +15 -0
- package/src/turns/skill-detect.js +65 -0
package/src/status.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { resolvePaths } = require("./paths");
|
|
3
|
+
const { exists, walk } = require("./fs-utils");
|
|
4
|
+
|
|
5
|
+
async function getStatus(options = {}) {
|
|
6
|
+
const paths = resolvePaths(options);
|
|
7
|
+
const claudeInstalled = await exists(paths.claudeProjectsDir);
|
|
8
|
+
const codexInstalled = await exists(paths.codexSessionsDir);
|
|
9
|
+
const claudeFiles = claudeInstalled
|
|
10
|
+
? (await walk(paths.claudeProjectsDir, (p) => p.endsWith(".jsonl"))).length
|
|
11
|
+
: 0;
|
|
12
|
+
const codexFiles = codexInstalled
|
|
13
|
+
? (await walk(paths.codexSessionsDir, (p) => path.basename(p).startsWith("rollout-") && p.endsWith(".jsonl"))).length
|
|
14
|
+
: 0;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
generatedAt: new Date().toISOString(),
|
|
18
|
+
paths: {
|
|
19
|
+
dataDir: paths.dataDir,
|
|
20
|
+
claudeProjectsDir: paths.claudeProjectsDir,
|
|
21
|
+
codexSessionsDir: paths.codexSessionsDir,
|
|
22
|
+
},
|
|
23
|
+
providers: {
|
|
24
|
+
claude: {
|
|
25
|
+
installed: claudeInstalled,
|
|
26
|
+
exact: true,
|
|
27
|
+
files: claudeFiles,
|
|
28
|
+
detail: claudeInstalled ? `${claudeFiles} jsonl files` : "projects directory missing",
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
installed: codexInstalled,
|
|
32
|
+
exact: true,
|
|
33
|
+
files: codexFiles,
|
|
34
|
+
detail: codexInstalled ? `${codexFiles} rollout files` : "sessions directory missing",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { getStatus };
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { aggregateEvents } = require("./aggregate");
|
|
2
|
+
const { resolvePaths } = require("./paths");
|
|
3
|
+
const { writeJson } = require("./fs-utils");
|
|
4
|
+
const { scanClaude } = require("./providers/claude");
|
|
5
|
+
const { scanCodex } = require("./providers/codex");
|
|
6
|
+
const { analyzeTurns } = require("./turns");
|
|
7
|
+
|
|
8
|
+
async function syncUsage(options = {}) {
|
|
9
|
+
const paths = resolvePaths(options);
|
|
10
|
+
const reports = [];
|
|
11
|
+
|
|
12
|
+
reports.push(await scanClaude(paths));
|
|
13
|
+
reports.push(await scanCodex(paths));
|
|
14
|
+
|
|
15
|
+
const events = reports.flatMap((report) => report.events || []);
|
|
16
|
+
const data = aggregateEvents(events);
|
|
17
|
+
data.turnAnalytics = await analyzeTurns(paths);
|
|
18
|
+
data.providers = reports.map((report) => ({
|
|
19
|
+
provider: report.provider,
|
|
20
|
+
files: report.files,
|
|
21
|
+
events: report.events ? report.events.length : 0,
|
|
22
|
+
skipped: report.skipped || null,
|
|
23
|
+
error: report.error || null,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
await writeJson(paths.usagePath, data);
|
|
27
|
+
return {
|
|
28
|
+
outputPath: paths.usagePath,
|
|
29
|
+
data,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { syncUsage };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { normalizeTimestamp, num } = require("./common");
|
|
2
|
+
|
|
3
|
+
function aggregateTurnAnalytics(turns) {
|
|
4
|
+
const cleanTurns = (turns || []).map(sanitizeTurn).sort((a, b) => b.startTime.localeCompare(a.startTime));
|
|
5
|
+
const skillMap = new Map();
|
|
6
|
+
for (const turn of cleanTurns) {
|
|
7
|
+
for (const skill of turn.skills) {
|
|
8
|
+
const key = skill.name;
|
|
9
|
+
if (!skillMap.has(key)) {
|
|
10
|
+
skillMap.set(key, {
|
|
11
|
+
skill: key,
|
|
12
|
+
confidence: skill.confidence,
|
|
13
|
+
turnCount: 0,
|
|
14
|
+
durationMs: 0,
|
|
15
|
+
totalTokens: 0,
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
outputTokens: 0,
|
|
18
|
+
cachedInputTokens: 0,
|
|
19
|
+
sources: new Set(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const row = skillMap.get(key);
|
|
23
|
+
row.turnCount += 1;
|
|
24
|
+
row.durationMs += turn.durationMs;
|
|
25
|
+
row.totalTokens += turn.totalTokens;
|
|
26
|
+
row.inputTokens += turn.inputTokens;
|
|
27
|
+
row.outputTokens += turn.outputTokens;
|
|
28
|
+
row.cachedInputTokens += turn.cachedInputTokens;
|
|
29
|
+
row.sources.add(turn.source);
|
|
30
|
+
if (skill.confidence === "explicit") row.confidence = "explicit";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const bySkill = Array.from(skillMap.values())
|
|
34
|
+
.map((row) => ({
|
|
35
|
+
...row,
|
|
36
|
+
sources: Array.from(row.sources).sort(),
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
39
|
+
return {
|
|
40
|
+
turns: cleanTurns,
|
|
41
|
+
bySkill,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeTurn(turn) {
|
|
46
|
+
return {
|
|
47
|
+
source: String(turn.source || "unknown"),
|
|
48
|
+
sessionId: turn.sessionId || null,
|
|
49
|
+
turnId: String(turn.turnId || ""),
|
|
50
|
+
model: String(turn.model || "unknown"),
|
|
51
|
+
startTime: normalizeTimestamp(turn.startTime),
|
|
52
|
+
endTime: normalizeTimestamp(turn.endTime || turn.startTime),
|
|
53
|
+
durationMs: num(turn.durationMs),
|
|
54
|
+
projectRef: turn.projectRef || null,
|
|
55
|
+
skills: (turn.skills || []).filter((skill) => skill && skill.name).map((skill) => ({
|
|
56
|
+
name: String(skill.name),
|
|
57
|
+
confidence: skill.confidence === "inferred" ? "inferred" : "explicit",
|
|
58
|
+
})),
|
|
59
|
+
inputTokens: num(turn.inputTokens),
|
|
60
|
+
outputTokens: num(turn.outputTokens),
|
|
61
|
+
cachedInputTokens: num(turn.cachedInputTokens),
|
|
62
|
+
totalTokens: num(turn.totalTokens),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
aggregateTurnAnalytics,
|
|
68
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const fs = require("node:fs/promises");
|
|
2
|
+
const { walk } = require("../fs-utils");
|
|
3
|
+
const { detectSkillFromPath, detectSkillsFromText, mergeSkills } = require("./skill-detect");
|
|
4
|
+
const { addTokenTotals, durationMs, emptyTokenTotals, normalizeTimestamp, num } = require("./common");
|
|
5
|
+
|
|
6
|
+
async function analyzeClaudeTurns(paths) {
|
|
7
|
+
const files = await walk(paths.claudeProjectsDir, (p) => p.endsWith(".jsonl"));
|
|
8
|
+
const all = [];
|
|
9
|
+
for (const file of files) {
|
|
10
|
+
all.push(...(await analyzeClaudeTurnsFromFile(file)));
|
|
11
|
+
}
|
|
12
|
+
return all;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function analyzeClaudeTurnsFromFile(filePath) {
|
|
16
|
+
const text = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
17
|
+
const turns = [];
|
|
18
|
+
let current = null;
|
|
19
|
+
|
|
20
|
+
for (const line of text.split(/\r?\n/)) {
|
|
21
|
+
if (!line.trim()) continue;
|
|
22
|
+
let obj;
|
|
23
|
+
try {
|
|
24
|
+
obj = JSON.parse(line);
|
|
25
|
+
} catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const timestamp = normalizeTimestamp(obj.timestamp);
|
|
29
|
+
if (isHumanUserMessage(obj)) {
|
|
30
|
+
finishCurrent(turns, current);
|
|
31
|
+
current = createTurn({ obj, timestamp, filePath });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!current) continue;
|
|
35
|
+
if (obj.type === "assistant" || obj.type === "user") {
|
|
36
|
+
current.endTime = timestamp;
|
|
37
|
+
}
|
|
38
|
+
if (obj.type === "assistant" && obj.message?.usage) {
|
|
39
|
+
const usage = claudeUsageToTotals(obj.message.usage);
|
|
40
|
+
addTokenTotals(current, usage);
|
|
41
|
+
current.model = obj.message.model || current.model;
|
|
42
|
+
}
|
|
43
|
+
current.skills = mergeSkills(current.skills, extractSkillsFromClaudeObject(obj));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
finishCurrent(turns, current);
|
|
47
|
+
return turns;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHumanUserMessage(obj) {
|
|
51
|
+
if (obj.type !== "user" || obj.message?.role !== "user") return false;
|
|
52
|
+
const content = obj.message.content;
|
|
53
|
+
if (typeof content === "string") return true;
|
|
54
|
+
if (!Array.isArray(content)) return false;
|
|
55
|
+
return content.some((item) => item && item.type !== "tool_result");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createTurn({ obj, timestamp, filePath }) {
|
|
59
|
+
return {
|
|
60
|
+
source: "claude",
|
|
61
|
+
sessionId: obj.sessionId || null,
|
|
62
|
+
turnId: obj.promptId || obj.uuid || `${filePath}:${timestamp}`,
|
|
63
|
+
model: "unknown",
|
|
64
|
+
startTime: timestamp,
|
|
65
|
+
endTime: timestamp,
|
|
66
|
+
durationMs: 0,
|
|
67
|
+
projectRef: obj.cwd || null,
|
|
68
|
+
skills: [],
|
|
69
|
+
...emptyTokenTotals(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function finishCurrent(turns, current) {
|
|
74
|
+
if (!current) return;
|
|
75
|
+
current.durationMs = durationMs(current.startTime, current.endTime);
|
|
76
|
+
current.skills = mergeSkills([], current.skills);
|
|
77
|
+
delete current.cacheCreationInputTokens;
|
|
78
|
+
delete current.reasoningOutputTokens;
|
|
79
|
+
if (current.totalTokens > 0 || current.skills.length > 0) turns.push(current);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function claudeUsageToTotals(usage) {
|
|
83
|
+
const inputTokens = num(usage.input_tokens);
|
|
84
|
+
const outputTokens = num(usage.output_tokens);
|
|
85
|
+
const cachedInputTokens = num(usage.cache_read_input_tokens);
|
|
86
|
+
const cacheCreationInputTokens =
|
|
87
|
+
num(usage.cache_creation_input_tokens) +
|
|
88
|
+
num(usage.claude_cache_creation_5_m_tokens) +
|
|
89
|
+
num(usage.claude_cache_creation_1_h_tokens);
|
|
90
|
+
return {
|
|
91
|
+
inputTokens,
|
|
92
|
+
outputTokens,
|
|
93
|
+
cachedInputTokens,
|
|
94
|
+
cacheCreationInputTokens,
|
|
95
|
+
reasoningOutputTokens: num(usage.reasoning_output_tokens),
|
|
96
|
+
totalTokens: inputTokens + outputTokens + cachedInputTokens + cacheCreationInputTokens,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractSkillsFromClaudeObject(obj) {
|
|
101
|
+
let skills = [];
|
|
102
|
+
const content = obj.message?.content;
|
|
103
|
+
if (typeof content === "string") {
|
|
104
|
+
skills = mergeSkills(skills, detectSkillsFromText(content));
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(content)) {
|
|
107
|
+
for (const item of content) {
|
|
108
|
+
if (typeof item?.text === "string") skills = mergeSkills(skills, detectSkillsFromText(item.text));
|
|
109
|
+
const filePath = item?.input?.file_path;
|
|
110
|
+
const skill = detectSkillFromPath(filePath);
|
|
111
|
+
if (skill) skills = mergeSkills(skills, [skill]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return skills;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
analyzeClaudeTurns,
|
|
119
|
+
analyzeClaudeTurnsFromFile,
|
|
120
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const fs = require("node:fs/promises");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { walk } = require("../fs-utils");
|
|
4
|
+
const { detectSkillFromPath, detectSkillsFromText, mergeSkills } = require("./skill-detect");
|
|
5
|
+
const { addTokenTotals, durationMs, emptyTokenTotals, normalizeTimestamp, num } = require("./common");
|
|
6
|
+
|
|
7
|
+
async function analyzeCodexTurns(paths) {
|
|
8
|
+
const files = await walk(paths.codexSessionsDir, (p) => path.basename(p).startsWith("rollout-") && p.endsWith(".jsonl"));
|
|
9
|
+
const all = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
all.push(...(await analyzeCodexTurnsFromFile(file)));
|
|
12
|
+
}
|
|
13
|
+
return all;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function analyzeCodexTurnsFromFile(filePath) {
|
|
17
|
+
const text = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
18
|
+
const turns = [];
|
|
19
|
+
let current = null;
|
|
20
|
+
|
|
21
|
+
for (const line of text.split(/\r?\n/)) {
|
|
22
|
+
if (!line.trim()) continue;
|
|
23
|
+
let obj;
|
|
24
|
+
try {
|
|
25
|
+
obj = JSON.parse(line);
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const timestamp = normalizeTimestamp(obj.timestamp);
|
|
30
|
+
const payload = obj.payload || {};
|
|
31
|
+
if (obj.type === "event_msg" && payload.type === "task_started") {
|
|
32
|
+
finishCurrent(turns, current);
|
|
33
|
+
current = createTurn({ payload, timestamp, filePath, obj });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!current) continue;
|
|
37
|
+
current.endTime = timestamp;
|
|
38
|
+
current.skills = mergeSkills(current.skills, extractSkillsFromCodexObject(obj));
|
|
39
|
+
if (obj.type === "event_msg" && payload.type === "token_count") {
|
|
40
|
+
addTokenTotals(current, codexTokenCountToTotals(payload.info));
|
|
41
|
+
}
|
|
42
|
+
if (obj.type === "event_msg" && payload.type === "task_complete") {
|
|
43
|
+
current.completed = true;
|
|
44
|
+
finishCurrent(turns, current);
|
|
45
|
+
current = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
finishCurrent(turns, current);
|
|
49
|
+
return turns;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createTurn({ payload, timestamp, filePath, obj }) {
|
|
53
|
+
return {
|
|
54
|
+
source: "codex",
|
|
55
|
+
sessionId: null,
|
|
56
|
+
turnId: payload.turn_id || `${filePath}:${timestamp}`,
|
|
57
|
+
model: obj.model || "codex",
|
|
58
|
+
startTime: timestamp,
|
|
59
|
+
endTime: timestamp,
|
|
60
|
+
durationMs: 0,
|
|
61
|
+
projectRef: null,
|
|
62
|
+
completed: false,
|
|
63
|
+
skills: [],
|
|
64
|
+
...emptyTokenTotals(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function finishCurrent(turns, current) {
|
|
69
|
+
if (!current) return;
|
|
70
|
+
current.durationMs = durationMs(current.startTime, current.endTime);
|
|
71
|
+
current.skills = mergeSkills([], current.skills);
|
|
72
|
+
delete current.completed;
|
|
73
|
+
delete current.cacheCreationInputTokens;
|
|
74
|
+
delete current.reasoningOutputTokens;
|
|
75
|
+
if (current.totalTokens > 0 || current.skills.length > 0) turns.push(current);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function codexTokenCountToTotals(info = {}) {
|
|
79
|
+
if (!info || typeof info !== "object") return emptyTokenTotals();
|
|
80
|
+
const usage = info.last_token_usage || info.total_token_usage || {};
|
|
81
|
+
const inputTokens = num(usage.input_tokens);
|
|
82
|
+
const outputTokens = num(usage.output_tokens);
|
|
83
|
+
const cachedInputTokens = num(usage.cached_input_tokens);
|
|
84
|
+
const cacheCreationInputTokens = num(usage.cache_creation_input_tokens);
|
|
85
|
+
return {
|
|
86
|
+
inputTokens,
|
|
87
|
+
outputTokens,
|
|
88
|
+
cachedInputTokens,
|
|
89
|
+
cacheCreationInputTokens,
|
|
90
|
+
reasoningOutputTokens: num(usage.reasoning_output_tokens),
|
|
91
|
+
totalTokens: num(usage.total_tokens) || inputTokens + outputTokens + cacheCreationInputTokens,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractSkillsFromCodexObject(obj) {
|
|
96
|
+
let skills = [];
|
|
97
|
+
const payload = obj.payload || {};
|
|
98
|
+
if (typeof payload.message === "string") skills = mergeSkills(skills, detectSkillsFromText(payload.message));
|
|
99
|
+
const content = payload.content;
|
|
100
|
+
if (typeof content === "string") skills = mergeSkills(skills, detectSkillsFromText(content));
|
|
101
|
+
if (Array.isArray(content)) {
|
|
102
|
+
for (const item of content) {
|
|
103
|
+
if (typeof item?.text === "string") skills = mergeSkills(skills, detectSkillsFromText(item.text));
|
|
104
|
+
const skill = detectSkillFromPath(item?.input?.file_path);
|
|
105
|
+
if (skill) skills = mergeSkills(skills, [skill]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const filePath = payload.input?.file_path || payload.arguments?.file_path;
|
|
109
|
+
const skill = detectSkillFromPath(filePath);
|
|
110
|
+
if (skill) skills = mergeSkills(skills, [skill]);
|
|
111
|
+
return skills;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
analyzeCodexTurns,
|
|
116
|
+
analyzeCodexTurnsFromFile,
|
|
117
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function emptyTokenTotals() {
|
|
2
|
+
return {
|
|
3
|
+
inputTokens: 0,
|
|
4
|
+
outputTokens: 0,
|
|
5
|
+
cachedInputTokens: 0,
|
|
6
|
+
cacheCreationInputTokens: 0,
|
|
7
|
+
reasoningOutputTokens: 0,
|
|
8
|
+
totalTokens: 0,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function addTokenTotals(target, usage) {
|
|
13
|
+
target.inputTokens += num(usage.inputTokens);
|
|
14
|
+
target.outputTokens += num(usage.outputTokens);
|
|
15
|
+
target.cachedInputTokens += num(usage.cachedInputTokens);
|
|
16
|
+
target.cacheCreationInputTokens += num(usage.cacheCreationInputTokens);
|
|
17
|
+
target.reasoningOutputTokens += num(usage.reasoningOutputTokens);
|
|
18
|
+
target.totalTokens += num(usage.totalTokens);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeTimestamp(value) {
|
|
22
|
+
const date = value ? new Date(value) : new Date();
|
|
23
|
+
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function durationMs(startTime, endTime) {
|
|
27
|
+
const start = new Date(startTime).getTime();
|
|
28
|
+
const end = new Date(endTime).getTime();
|
|
29
|
+
return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function num(value) {
|
|
33
|
+
return Number.isFinite(Number(value)) && Number(value) > 0 ? Math.round(Number(value)) : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
emptyTokenTotals,
|
|
38
|
+
addTokenTotals,
|
|
39
|
+
normalizeTimestamp,
|
|
40
|
+
durationMs,
|
|
41
|
+
num,
|
|
42
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { analyzeClaudeTurns } = require("./claude-turns");
|
|
2
|
+
const { analyzeCodexTurns } = require("./codex-turns");
|
|
3
|
+
const { aggregateTurnAnalytics } = require("./aggregate-turns");
|
|
4
|
+
|
|
5
|
+
async function analyzeTurns(paths) {
|
|
6
|
+
const turns = [
|
|
7
|
+
...(await analyzeClaudeTurns(paths)),
|
|
8
|
+
...(await analyzeCodexTurns(paths)),
|
|
9
|
+
];
|
|
10
|
+
return aggregateTurnAnalytics(turns);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
analyzeTurns,
|
|
15
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function detectSkillsFromText(text) {
|
|
2
|
+
const skills = new Map();
|
|
3
|
+
const raw = String(text || "");
|
|
4
|
+
const patterns = [
|
|
5
|
+
/Using\s+`(superpowers:[^`]+)`/gi,
|
|
6
|
+
/Using\s+`([^`]+)`[^.\n]{0,80}\bskill\b/gi,
|
|
7
|
+
/using\s+the\s+([a-z0-9:_-]+)\s+skill/gi,
|
|
8
|
+
/I'm using\s+the\s+([a-z0-9:_-]+)\s+skill/gi,
|
|
9
|
+
];
|
|
10
|
+
for (const pattern of patterns) {
|
|
11
|
+
let match;
|
|
12
|
+
while ((match = pattern.exec(raw))) {
|
|
13
|
+
addSkill(skills, normalizeSkillName(match[1]), "explicit");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return Array.from(skills.values());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function detectSkillFromPath(filePath) {
|
|
20
|
+
const raw = String(filePath || "");
|
|
21
|
+
const match = raw.match(/[\\/](?:skills|\.system)[\\/]([^\\/]+)[\\/](?:SKILL\.md|skill\.md)$/i);
|
|
22
|
+
if (match) return { name: normalizeSkillName(match[1]), confidence: "inferred" };
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mergeSkills(target, skills) {
|
|
27
|
+
const map = new Map((target || []).map((skill) => [skill.name, skill]));
|
|
28
|
+
for (const skill of skills || []) {
|
|
29
|
+
if (!skill || !skill.name) continue;
|
|
30
|
+
const existing = map.get(skill.name);
|
|
31
|
+
if (!existing || existing.confidence !== "explicit") map.set(skill.name, skill);
|
|
32
|
+
}
|
|
33
|
+
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addSkill(map, name, confidence) {
|
|
37
|
+
if (!isLikelySkillName(name)) return;
|
|
38
|
+
const existing = map.get(name);
|
|
39
|
+
if (!existing || existing.confidence !== "explicit") map.set(name, { name, confidence });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeSkillName(value) {
|
|
43
|
+
return String(value || "").trim().replace(/^\/+/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLikelySkillName(name) {
|
|
47
|
+
const value = String(name || "");
|
|
48
|
+
if (!/^[a-z0-9][a-z0-9:_-]{2,}$/.test(value)) return false;
|
|
49
|
+
if (value.startsWith("superpowers:")) return true;
|
|
50
|
+
if (/[:_-]/.test(value)) return true;
|
|
51
|
+
return KNOWN_SINGLE_WORD_SKILLS.has(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const KNOWN_SINGLE_WORD_SKILLS = new Set([
|
|
55
|
+
"diagnose",
|
|
56
|
+
"prototype",
|
|
57
|
+
"tdd",
|
|
58
|
+
"triage",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
detectSkillsFromText,
|
|
63
|
+
detectSkillFromPath,
|
|
64
|
+
mergeSkills,
|
|
65
|
+
};
|