@aci-metrics/score 0.0.1
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 +228 -0
- package/aci-score.js +861 -0
- package/config/default.json +29 -0
- package/config/models.json +49 -0
- package/lib/provider-factory.js +181 -0
- package/lib/providers/base.js +218 -0
- package/lib/providers/node-llama-cpp.js +196 -0
- package/lib/providers/ollama.js +432 -0
- package/models/.gitkeep +2 -0
- package/package.json +31 -0
- package/prompts/gemma.txt +15 -0
- package/prompts/llama.txt +17 -0
- package/prompts/phi.txt +16 -0
- package/prompts/qwen.txt +18 -0
- package/test-model.js +232 -0
package/aci-score.js
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aci-score.js
|
|
4
|
+
*
|
|
5
|
+
* ACI Local Scorer - Tier 1
|
|
6
|
+
* Analyzes Claude Code transcripts and generates an ACI score preview.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node aci-score.js [project-path]
|
|
10
|
+
* node aci-score.js ~/.claude/projects/my-project/
|
|
11
|
+
* node aci-score.js (scans default ~/.claude/projects/)
|
|
12
|
+
*
|
|
13
|
+
* Milestone 1: Deterministic analysis only (no AI inference)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
var fs = require('fs');
|
|
17
|
+
var path = require('path');
|
|
18
|
+
var os = require('os');
|
|
19
|
+
var zlib = require('zlib');
|
|
20
|
+
var readline = require('readline');
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// CONFIGURATION
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
var DEFAULT_CLAUDE_PATH = path.join(os.homedir(), '.claude', 'projects');
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// SECRET SCRUBBING (from clean-transcripts.js)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
var SECRET_PATTERNS = [
|
|
33
|
+
{ name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{32,}/g },
|
|
34
|
+
{ name: 'Anthropic API Key', pattern: /sk-ant-[a-zA-Z0-9\-_]{32,}/g },
|
|
35
|
+
{ name: 'GitHub Token', pattern: /gh[pousr]_[a-zA-Z0-9]{36,}/g },
|
|
36
|
+
{ name: 'GitHub Fine-Grained Token', pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g },
|
|
37
|
+
{ name: 'AWS Access Key', pattern: /AKIA[A-Z0-9]{16}/g },
|
|
38
|
+
{ name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|secret_access_key|aws_secret)[\s]*[=:][\s]*["']?([A-Za-z0-9\/+=]{40})["']?/gi },
|
|
39
|
+
{ name: 'Google API Key', pattern: /AIza[a-zA-Z0-9\-_]{35}/g },
|
|
40
|
+
{ name: 'Slack Token', pattern: /xox[baprs]-[a-zA-Z0-9\-]{10,}/g },
|
|
41
|
+
{ name: 'Stripe Key', pattern: /sk_live_[a-zA-Z0-9]{24,}/g },
|
|
42
|
+
{ name: 'Private Key', pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g },
|
|
43
|
+
{ name: 'Bearer Token', pattern: /Bearer\s+[a-zA-Z0-9\-_\.]{20,}/gi },
|
|
44
|
+
{ name: 'JWT Token', pattern: /eyJ[a-zA-Z0-9\-_]+\.eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+/g },
|
|
45
|
+
{ name: 'DB Connection String', pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^:]+:[^@]+@[^\s]+/gi },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function scrubSecrets(text) {
|
|
49
|
+
if (typeof text !== 'string') return { text: text, count: 0 };
|
|
50
|
+
var count = 0;
|
|
51
|
+
var scrubbed = text;
|
|
52
|
+
for (var i = 0; i < SECRET_PATTERNS.length; i++) {
|
|
53
|
+
SECRET_PATTERNS[i].pattern.lastIndex = 0;
|
|
54
|
+
var matches = scrubbed.match(SECRET_PATTERNS[i].pattern);
|
|
55
|
+
if (matches) {
|
|
56
|
+
count += matches.length;
|
|
57
|
+
scrubbed = scrubbed.replace(SECRET_PATTERNS[i].pattern, '[REDACTED]');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { text: scrubbed, count: count };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// CLI OUTPUT HELPERS
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
var COLORS = {
|
|
68
|
+
reset: '\x1b[0m',
|
|
69
|
+
bright: '\x1b[1m',
|
|
70
|
+
dim: '\x1b[2m',
|
|
71
|
+
green: '\x1b[32m',
|
|
72
|
+
yellow: '\x1b[33m',
|
|
73
|
+
blue: '\x1b[34m',
|
|
74
|
+
cyan: '\x1b[36m',
|
|
75
|
+
white: '\x1b[37m',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function log(msg) {
|
|
79
|
+
console.log(msg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function status(msg) {
|
|
83
|
+
process.stdout.write(COLORS.cyan + ' → ' + COLORS.reset + msg + '\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function success(msg) {
|
|
87
|
+
process.stdout.write(COLORS.green + ' ✓ ' + COLORS.reset + msg + '\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function warn(msg) {
|
|
91
|
+
process.stdout.write(COLORS.yellow + ' ⚠ ' + COLORS.reset + msg + '\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function header(msg) {
|
|
95
|
+
log('');
|
|
96
|
+
log(COLORS.bright + msg + COLORS.reset);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// USER INPUT PROMPTS
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
function askQuestion(question) {
|
|
104
|
+
var rl = readline.createInterface({
|
|
105
|
+
input: process.stdin,
|
|
106
|
+
output: process.stdout
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return new Promise(function(resolve) {
|
|
110
|
+
rl.question(question, function(answer) {
|
|
111
|
+
rl.close();
|
|
112
|
+
resolve(answer.trim());
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getTranscriptModTime(filepath) {
|
|
118
|
+
try {
|
|
119
|
+
var stat = fs.statSync(filepath);
|
|
120
|
+
return stat.mtime;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return new Date(0);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function filterTranscriptsBySelection(transcriptFiles, selection) {
|
|
127
|
+
// Sort by modification time (newest first)
|
|
128
|
+
var sorted = transcriptFiles.slice().sort(function(a, b) {
|
|
129
|
+
return getTranscriptModTime(b) - getTranscriptModTime(a);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
switch (selection) {
|
|
133
|
+
case '1':
|
|
134
|
+
// All transcripts
|
|
135
|
+
return sorted;
|
|
136
|
+
|
|
137
|
+
case '2':
|
|
138
|
+
// Last 20
|
|
139
|
+
return sorted.slice(0, 20);
|
|
140
|
+
|
|
141
|
+
case '3':
|
|
142
|
+
// Last week
|
|
143
|
+
var oneWeekAgo = new Date();
|
|
144
|
+
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
145
|
+
return sorted.filter(function(f) {
|
|
146
|
+
return getTranscriptModTime(f) >= oneWeekAgo;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
case '4':
|
|
150
|
+
// Custom range - for now, same as all (will prompt separately)
|
|
151
|
+
return sorted;
|
|
152
|
+
|
|
153
|
+
default:
|
|
154
|
+
return sorted;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// REPORT-ID GENERATION
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
function generateReportId() {
|
|
163
|
+
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
|
164
|
+
var segments = [];
|
|
165
|
+
for (var s = 0; s < 6; s++) {
|
|
166
|
+
var segment = '';
|
|
167
|
+
for (var c = 0; c < 4; c++) {
|
|
168
|
+
segment += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
169
|
+
}
|
|
170
|
+
segments.push(segment);
|
|
171
|
+
}
|
|
172
|
+
return segments.join('-');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// TRANSCRIPT DISCOVERY
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
function findProjects(basePath) {
|
|
180
|
+
var projects = [];
|
|
181
|
+
|
|
182
|
+
if (!fs.existsSync(basePath)) {
|
|
183
|
+
return projects;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
var entries = fs.readdirSync(basePath);
|
|
187
|
+
|
|
188
|
+
for (var i = 0; i < entries.length; i++) {
|
|
189
|
+
var entry = entries[i];
|
|
190
|
+
var entryPath = path.join(basePath, entry);
|
|
191
|
+
var stat = fs.statSync(entryPath);
|
|
192
|
+
|
|
193
|
+
if (stat.isDirectory()) {
|
|
194
|
+
// Check for JSONL files in this directory
|
|
195
|
+
var files = fs.readdirSync(entryPath);
|
|
196
|
+
var jsonlFiles = files.filter(function(f) {
|
|
197
|
+
return f.endsWith('.jsonl');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (jsonlFiles.length > 0) {
|
|
201
|
+
projects.push({
|
|
202
|
+
name: entry,
|
|
203
|
+
path: entryPath,
|
|
204
|
+
files: jsonlFiles.map(function(f) {
|
|
205
|
+
return path.join(entryPath, f);
|
|
206
|
+
})
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return projects;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findTranscripts(inputPath) {
|
|
216
|
+
var stat;
|
|
217
|
+
try {
|
|
218
|
+
stat = fs.statSync(inputPath);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (stat.isDirectory()) {
|
|
224
|
+
// Check if it's a project directory with JSONL files
|
|
225
|
+
var files = fs.readdirSync(inputPath);
|
|
226
|
+
var jsonlFiles = files.filter(function(f) {
|
|
227
|
+
return f.endsWith('.jsonl');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (jsonlFiles.length > 0) {
|
|
231
|
+
return jsonlFiles.map(function(f) {
|
|
232
|
+
return path.join(inputPath, f);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Otherwise, treat it as ~/.claude/projects/ and find all projects
|
|
237
|
+
var projects = findProjects(inputPath);
|
|
238
|
+
var allFiles = [];
|
|
239
|
+
for (var i = 0; i < projects.length; i++) {
|
|
240
|
+
allFiles = allFiles.concat(projects[i].files);
|
|
241
|
+
}
|
|
242
|
+
return allFiles;
|
|
243
|
+
} else if (inputPath.endsWith('.jsonl')) {
|
|
244
|
+
return [inputPath];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// TRANSCRIPT PARSING
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
function parseTranscript(filepath) {
|
|
255
|
+
var messages = [];
|
|
256
|
+
var content = fs.readFileSync(filepath, 'utf8');
|
|
257
|
+
var lines = content.split('\n');
|
|
258
|
+
var secretsFound = 0;
|
|
259
|
+
|
|
260
|
+
for (var i = 0; i < lines.length; i++) {
|
|
261
|
+
var line = lines[i].trim();
|
|
262
|
+
if (!line) continue;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
var raw = JSON.parse(line);
|
|
266
|
+
var msg = {
|
|
267
|
+
type: raw.type || raw.role || 'unknown',
|
|
268
|
+
ts: raw.ts || raw.timestamp || raw.created_at || null,
|
|
269
|
+
contentLength: 0
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Calculate content length (for metrics)
|
|
273
|
+
var contentStr = '';
|
|
274
|
+
if (typeof raw.content === 'string') {
|
|
275
|
+
contentStr = raw.content;
|
|
276
|
+
} else if (raw.content) {
|
|
277
|
+
contentStr = JSON.stringify(raw.content);
|
|
278
|
+
} else if (raw.message) {
|
|
279
|
+
contentStr = typeof raw.message === 'string' ? raw.message : JSON.stringify(raw.message);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
var scrubResult = scrubSecrets(contentStr);
|
|
283
|
+
secretsFound += scrubResult.count;
|
|
284
|
+
msg.contentLength = scrubResult.text.length;
|
|
285
|
+
|
|
286
|
+
if (msg.ts) {
|
|
287
|
+
messages.push(msg);
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// Skip malformed lines
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { messages: messages, secretsFound: secretsFound };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// METRICS CALCULATION (Deterministic Layer)
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
function calculateMetrics(transcriptFiles) {
|
|
302
|
+
var allMessages = [];
|
|
303
|
+
var totalSecretsFound = 0;
|
|
304
|
+
var sessionCount = transcriptFiles.length;
|
|
305
|
+
|
|
306
|
+
for (var i = 0; i < transcriptFiles.length; i++) {
|
|
307
|
+
var result = parseTranscript(transcriptFiles[i]);
|
|
308
|
+
allMessages = allMessages.concat(result.messages);
|
|
309
|
+
totalSecretsFound += result.secretsFound;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Sort by timestamp
|
|
313
|
+
allMessages.sort(function(a, b) {
|
|
314
|
+
return new Date(a.ts) - new Date(b.ts);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Calculate basic metrics
|
|
318
|
+
var userMessages = allMessages.filter(function(m) {
|
|
319
|
+
return m.type === 'user' || m.type === 'human';
|
|
320
|
+
});
|
|
321
|
+
var assistantMessages = allMessages.filter(function(m) {
|
|
322
|
+
return m.type === 'assistant' || m.type === 'ai';
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Time range
|
|
326
|
+
var firstTs = allMessages.length > 0 ? new Date(allMessages[0].ts) : null;
|
|
327
|
+
var lastTs = allMessages.length > 0 ? new Date(allMessages[allMessages.length - 1].ts) : null;
|
|
328
|
+
|
|
329
|
+
// Calculate total activity duration (rough estimate based on session gaps)
|
|
330
|
+
var totalActivityMinutes = 0;
|
|
331
|
+
if (allMessages.length > 1) {
|
|
332
|
+
var prevTs = firstTs;
|
|
333
|
+
for (var i = 1; i < allMessages.length; i++) {
|
|
334
|
+
var currTs = new Date(allMessages[i].ts);
|
|
335
|
+
var gapMinutes = (currTs - prevTs) / 1000 / 60;
|
|
336
|
+
// Only count gaps under 30 minutes as "active"
|
|
337
|
+
if (gapMinutes < 30) {
|
|
338
|
+
totalActivityMinutes += gapMinutes;
|
|
339
|
+
}
|
|
340
|
+
prevTs = currTs;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Token estimation (rough: 4 chars per token)
|
|
345
|
+
var totalChars = allMessages.reduce(function(sum, m) {
|
|
346
|
+
return sum + m.contentLength;
|
|
347
|
+
}, 0);
|
|
348
|
+
var estimatedTokens = Math.round(totalChars / 4);
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
sessionCount: sessionCount,
|
|
352
|
+
promptCount: userMessages.length,
|
|
353
|
+
responseCount: assistantMessages.length,
|
|
354
|
+
totalMessages: allMessages.length,
|
|
355
|
+
firstTimestamp: firstTs,
|
|
356
|
+
lastTimestamp: lastTs,
|
|
357
|
+
activityHours: Math.round(totalActivityMinutes / 60 * 10) / 10,
|
|
358
|
+
estimatedTokens: estimatedTokens,
|
|
359
|
+
secretsRedacted: totalSecretsFound
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ============================================================================
|
|
364
|
+
// SUBMISSION FILE CREATION (gzipped JSON)
|
|
365
|
+
// ============================================================================
|
|
366
|
+
|
|
367
|
+
function createSubmissionFile(reportId, metrics, transcriptFiles, outputDir) {
|
|
368
|
+
// Parse all transcripts and collect sessions
|
|
369
|
+
var sessions = [];
|
|
370
|
+
var totalSecretsRedacted = 0;
|
|
371
|
+
var mergedToolCounts = {};
|
|
372
|
+
|
|
373
|
+
for (var i = 0; i < transcriptFiles.length; i++) {
|
|
374
|
+
var filepath = transcriptFiles[i];
|
|
375
|
+
var parsed = parseTranscriptForSubmission(filepath);
|
|
376
|
+
|
|
377
|
+
sessions.push(parsed.session);
|
|
378
|
+
totalSecretsRedacted += parsed.secretsRedacted;
|
|
379
|
+
|
|
380
|
+
// Merge tool counts
|
|
381
|
+
for (var tool in parsed.toolCounts) {
|
|
382
|
+
mergedToolCounts[tool] = (mergedToolCounts[tool] || 0) + parsed.toolCounts[tool];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Calculate aggregate token metrics from sessions
|
|
387
|
+
var totalTokensIn = 0;
|
|
388
|
+
var totalTokensOut = 0;
|
|
389
|
+
var totalCacheRead = 0;
|
|
390
|
+
var promptCount = 0;
|
|
391
|
+
var responseCount = 0;
|
|
392
|
+
|
|
393
|
+
for (var i = 0; i < sessions.length; i++) {
|
|
394
|
+
var session = sessions[i];
|
|
395
|
+
for (var j = 0; j < session.messages.length; j++) {
|
|
396
|
+
var msg = session.messages[j];
|
|
397
|
+
if (msg.role === 'user') {
|
|
398
|
+
promptCount++;
|
|
399
|
+
} else if (msg.role === 'assistant') {
|
|
400
|
+
responseCount++;
|
|
401
|
+
if (msg.tokens) {
|
|
402
|
+
totalTokensIn += msg.tokens.in || 0;
|
|
403
|
+
totalTokensOut += msg.tokens.out || 0;
|
|
404
|
+
totalCacheRead += msg.tokens.cacheRead || 0;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Build submission payload
|
|
411
|
+
var submission = {
|
|
412
|
+
reportId: reportId,
|
|
413
|
+
version: '1.0.0',
|
|
414
|
+
generatedAt: new Date().toISOString(),
|
|
415
|
+
|
|
416
|
+
sessions: sessions,
|
|
417
|
+
|
|
418
|
+
metrics: {
|
|
419
|
+
sessionCount: sessions.length,
|
|
420
|
+
promptCount: promptCount,
|
|
421
|
+
responseCount: responseCount,
|
|
422
|
+
totalTokensIn: totalTokensIn,
|
|
423
|
+
totalTokensOut: totalTokensOut,
|
|
424
|
+
totalCacheRead: totalCacheRead,
|
|
425
|
+
toolCounts: mergedToolCounts,
|
|
426
|
+
activityMinutes: metrics.activityHours * 60,
|
|
427
|
+
secretsRedacted: totalSecretsRedacted,
|
|
428
|
+
firstTimestamp: metrics.firstTimestamp ? metrics.firstTimestamp.toISOString() : null,
|
|
429
|
+
lastTimestamp: metrics.lastTimestamp ? metrics.lastTimestamp.toISOString() : null
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Convert to JSON (pretty-printed) and compress with gzip
|
|
434
|
+
var jsonData = JSON.stringify(submission, null, 2);
|
|
435
|
+
var compressed = zlib.gzipSync(Buffer.from(jsonData, 'utf8'));
|
|
436
|
+
|
|
437
|
+
// Write compressed file
|
|
438
|
+
var outputPath = path.join(outputDir, reportId + '.aci.gz');
|
|
439
|
+
fs.writeFileSync(outputPath, compressed);
|
|
440
|
+
|
|
441
|
+
// Return path and stats
|
|
442
|
+
return {
|
|
443
|
+
path: outputPath,
|
|
444
|
+
uncompressedSize: jsonData.length,
|
|
445
|
+
compressedSize: compressed.length,
|
|
446
|
+
secretsRedacted: totalSecretsRedacted
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Recursively scrub secrets from content (handles strings, arrays, objects)
|
|
452
|
+
*/
|
|
453
|
+
function scrubContentDeep(content) {
|
|
454
|
+
if (typeof content === 'string') {
|
|
455
|
+
return scrubSecrets(content);
|
|
456
|
+
}
|
|
457
|
+
if (Array.isArray(content)) {
|
|
458
|
+
var totalCount = 0;
|
|
459
|
+
var scrubbedArray = content.map(function(item) {
|
|
460
|
+
var result = scrubContentDeep(item);
|
|
461
|
+
totalCount += result.count;
|
|
462
|
+
return result.text;
|
|
463
|
+
});
|
|
464
|
+
return { text: scrubbedArray, count: totalCount };
|
|
465
|
+
}
|
|
466
|
+
if (content && typeof content === 'object') {
|
|
467
|
+
var totalCount = 0;
|
|
468
|
+
var scrubbedObj = {};
|
|
469
|
+
for (var key in content) {
|
|
470
|
+
if (content.hasOwnProperty(key)) {
|
|
471
|
+
var result = scrubContentDeep(content[key]);
|
|
472
|
+
scrubbedObj[key] = result.text;
|
|
473
|
+
totalCount += result.count;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return { text: scrubbedObj, count: totalCount };
|
|
477
|
+
}
|
|
478
|
+
return { text: content, count: 0 };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Parse a content block (text, thinking, tool_use, tool_result)
|
|
483
|
+
*/
|
|
484
|
+
function parseContentBlock(block, secretsCounter) {
|
|
485
|
+
if (!block || !block.type) return null;
|
|
486
|
+
|
|
487
|
+
if (block.type === 'text') {
|
|
488
|
+
var scrubbed = scrubSecrets(block.text || '');
|
|
489
|
+
secretsCounter.count += scrubbed.count;
|
|
490
|
+
return { type: 'text', text: scrubbed.text };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (block.type === 'thinking') {
|
|
494
|
+
var scrubbed = scrubSecrets(block.thinking || '');
|
|
495
|
+
secretsCounter.count += scrubbed.count;
|
|
496
|
+
return {
|
|
497
|
+
type: 'thinking',
|
|
498
|
+
length: (block.thinking || '').length,
|
|
499
|
+
text: scrubbed.text
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (block.type === 'tool_use') {
|
|
504
|
+
var scrubbedInput = scrubContentDeep(block.input || {});
|
|
505
|
+
secretsCounter.count += scrubbedInput.count;
|
|
506
|
+
return {
|
|
507
|
+
type: 'tool_use',
|
|
508
|
+
name: block.name,
|
|
509
|
+
input: scrubbedInput.text
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (block.type === 'tool_result') {
|
|
514
|
+
var scrubbedContent = scrubContentDeep(block.content || '');
|
|
515
|
+
secretsCounter.count += scrubbedContent.count;
|
|
516
|
+
return {
|
|
517
|
+
type: 'tool_result',
|
|
518
|
+
success: !block.is_error,
|
|
519
|
+
content: scrubbedContent.text
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Parse transcript for submission with full schema
|
|
528
|
+
*/
|
|
529
|
+
function parseTranscriptForSubmission(filepath) {
|
|
530
|
+
var fileContent = fs.readFileSync(filepath, 'utf8');
|
|
531
|
+
var lines = fileContent.split('\n');
|
|
532
|
+
var filename = path.basename(filepath);
|
|
533
|
+
|
|
534
|
+
// Session metadata (extracted from first relevant message)
|
|
535
|
+
var session = {
|
|
536
|
+
id: null,
|
|
537
|
+
slug: null,
|
|
538
|
+
start: null,
|
|
539
|
+
end: null,
|
|
540
|
+
cwd: null,
|
|
541
|
+
branch: null,
|
|
542
|
+
model: null,
|
|
543
|
+
clientVersion: null,
|
|
544
|
+
permissionMode: null,
|
|
545
|
+
messages: []
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
var secretsCounter = { count: 0 };
|
|
549
|
+
var lastUserTimestamp = null;
|
|
550
|
+
var toolCounts = {};
|
|
551
|
+
|
|
552
|
+
for (var i = 0; i < lines.length; i++) {
|
|
553
|
+
var line = lines[i].trim();
|
|
554
|
+
if (!line) continue;
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
var raw = JSON.parse(line);
|
|
558
|
+
|
|
559
|
+
// Skip noise entries
|
|
560
|
+
if (raw.type === 'queue-operation' ||
|
|
561
|
+
raw.type === 'file-history-snapshot' ||
|
|
562
|
+
raw.type === 'progress') {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Only process user/assistant messages
|
|
567
|
+
if (raw.type !== 'user' && raw.type !== 'assistant') {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
var timestamp = raw.timestamp || raw.ts || null;
|
|
572
|
+
if (!timestamp) continue;
|
|
573
|
+
|
|
574
|
+
// Extract session metadata from first message
|
|
575
|
+
if (!session.id && raw.sessionId) {
|
|
576
|
+
session.id = raw.sessionId.split('-')[0]; // Short ID
|
|
577
|
+
}
|
|
578
|
+
if (!session.slug && raw.slug) {
|
|
579
|
+
session.slug = raw.slug;
|
|
580
|
+
}
|
|
581
|
+
if (!session.cwd && raw.cwd) {
|
|
582
|
+
session.cwd = raw.cwd;
|
|
583
|
+
}
|
|
584
|
+
if (!session.branch && raw.gitBranch) {
|
|
585
|
+
session.branch = raw.gitBranch;
|
|
586
|
+
}
|
|
587
|
+
if (!session.clientVersion && raw.version) {
|
|
588
|
+
session.clientVersion = raw.version;
|
|
589
|
+
}
|
|
590
|
+
if (!session.permissionMode && raw.permissionMode) {
|
|
591
|
+
session.permissionMode = raw.permissionMode;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Track time range
|
|
595
|
+
if (!session.start) {
|
|
596
|
+
session.start = timestamp;
|
|
597
|
+
}
|
|
598
|
+
session.end = timestamp;
|
|
599
|
+
|
|
600
|
+
// Build message object
|
|
601
|
+
var msg = {
|
|
602
|
+
role: raw.type,
|
|
603
|
+
timestamp: timestamp
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// For assistant messages, extract model and tokens
|
|
607
|
+
if (raw.type === 'assistant' && raw.message) {
|
|
608
|
+
if (!session.model && raw.message.model) {
|
|
609
|
+
session.model = raw.message.model;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Calculate response time from last user message
|
|
613
|
+
if (lastUserTimestamp) {
|
|
614
|
+
var responseMs = new Date(timestamp) - new Date(lastUserTimestamp);
|
|
615
|
+
msg.responseTime = Math.round(responseMs / 100) / 10; // seconds, 1 decimal
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Extract token usage
|
|
619
|
+
if (raw.message.usage) {
|
|
620
|
+
msg.tokens = {
|
|
621
|
+
in: raw.message.usage.input_tokens || 0,
|
|
622
|
+
out: raw.message.usage.output_tokens || 0
|
|
623
|
+
};
|
|
624
|
+
if (raw.message.usage.cache_read_input_tokens) {
|
|
625
|
+
msg.tokens.cacheRead = raw.message.usage.cache_read_input_tokens;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Track last user timestamp for responseTime calculation
|
|
631
|
+
if (raw.type === 'user') {
|
|
632
|
+
lastUserTimestamp = timestamp;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Parse content blocks
|
|
636
|
+
var contentSource = raw.message ? raw.message.content : null;
|
|
637
|
+
if (Array.isArray(contentSource)) {
|
|
638
|
+
msg.content = [];
|
|
639
|
+
for (var j = 0; j < contentSource.length; j++) {
|
|
640
|
+
var parsed = parseContentBlock(contentSource[j], secretsCounter);
|
|
641
|
+
if (parsed) {
|
|
642
|
+
msg.content.push(parsed);
|
|
643
|
+
|
|
644
|
+
// Track tool usage
|
|
645
|
+
if (parsed.type === 'tool_use' && parsed.name) {
|
|
646
|
+
toolCounts[parsed.name] = (toolCounts[parsed.name] || 0) + 1;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
session.messages.push(msg);
|
|
653
|
+
|
|
654
|
+
} catch (e) {
|
|
655
|
+
// Skip malformed lines
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
filename: filename,
|
|
661
|
+
session: session,
|
|
662
|
+
secretsRedacted: secretsCounter.count,
|
|
663
|
+
toolCounts: toolCounts
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ============================================================================
|
|
668
|
+
// CLI REPORT OUTPUT
|
|
669
|
+
// ============================================================================
|
|
670
|
+
|
|
671
|
+
function formatNumber(n) {
|
|
672
|
+
if (n >= 1000000000) return (n / 1000000000).toFixed(1) + ' B';
|
|
673
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + ' M';
|
|
674
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + ' k';
|
|
675
|
+
return String(n);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function formatBytes(bytes) {
|
|
679
|
+
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
680
|
+
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
681
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
682
|
+
return bytes + ' B';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function formatDate(d) {
|
|
686
|
+
if (!d) return '--';
|
|
687
|
+
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
688
|
+
return months[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function progressBar(value, max, width) {
|
|
692
|
+
var filled = Math.round((value / max) * width);
|
|
693
|
+
var empty = width - filled;
|
|
694
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function printReport(reportId, metrics) {
|
|
698
|
+
var line = '═'.repeat(72);
|
|
699
|
+
var thinLine = '─'.repeat(72);
|
|
700
|
+
|
|
701
|
+
log('');
|
|
702
|
+
log(line);
|
|
703
|
+
log(' AI COLLABORATION INDEX (ACI) - Estimate');
|
|
704
|
+
log('');
|
|
705
|
+
log(' Report-ID: ' + reportId);
|
|
706
|
+
log(' Generated: ' + formatDate(new Date()));
|
|
707
|
+
log(line);
|
|
708
|
+
log('');
|
|
709
|
+
|
|
710
|
+
// Placeholder scores (will be calculated by AI layer later)
|
|
711
|
+
log(' ACI SCORE (estimate) ' + COLORS.dim + '--- (AI analysis pending)' + COLORS.reset);
|
|
712
|
+
log('');
|
|
713
|
+
log(' Velocity ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
|
|
714
|
+
log(' Quality ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
|
|
715
|
+
log(' Integration ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
|
|
716
|
+
log(' Literacy ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
|
|
717
|
+
log('');
|
|
718
|
+
log(thinLine);
|
|
719
|
+
log(' RAW METRICS');
|
|
720
|
+
log(thinLine);
|
|
721
|
+
log('');
|
|
722
|
+
|
|
723
|
+
var timeRange = formatDate(metrics.firstTimestamp) + ' – ' + formatDate(metrics.lastTimestamp);
|
|
724
|
+
|
|
725
|
+
log(' Timerange: ' + timeRange);
|
|
726
|
+
log(' Activity: ' + metrics.activityHours + ' h');
|
|
727
|
+
log(' Sessions: ' + metrics.sessionCount);
|
|
728
|
+
log(' Prompts: ' + formatNumber(metrics.promptCount));
|
|
729
|
+
log(' Responses: ' + formatNumber(metrics.responseCount));
|
|
730
|
+
log(' Tokens (est): ' + formatNumber(metrics.estimatedTokens));
|
|
731
|
+
log('');
|
|
732
|
+
log(' ' + COLORS.dim + 'Additional metrics require AI analysis' + COLORS.reset);
|
|
733
|
+
log('');
|
|
734
|
+
log(thinLine);
|
|
735
|
+
log(' DETECTED PATTERNS');
|
|
736
|
+
log(thinLine);
|
|
737
|
+
log('');
|
|
738
|
+
log(' ' + COLORS.dim + '(Pending AI analysis)' + COLORS.reset);
|
|
739
|
+
log('');
|
|
740
|
+
log(thinLine);
|
|
741
|
+
log(' TIPS & NEXT-STEPS');
|
|
742
|
+
log(thinLine);
|
|
743
|
+
log('');
|
|
744
|
+
log(' ' + COLORS.dim + '(Pending AI analysis)' + COLORS.reset);
|
|
745
|
+
log('');
|
|
746
|
+
log(line);
|
|
747
|
+
log(' © 2026 ACI Metrics | aci-metrics.com/terms');
|
|
748
|
+
log(line);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ============================================================================
|
|
752
|
+
// MAIN
|
|
753
|
+
// ============================================================================
|
|
754
|
+
|
|
755
|
+
async function main() {
|
|
756
|
+
var inputPath = process.argv[2] || DEFAULT_CLAUDE_PATH;
|
|
757
|
+
|
|
758
|
+
log('');
|
|
759
|
+
log(COLORS.bright + ' ACI Local Scorer v1.0.0' + COLORS.reset);
|
|
760
|
+
log(COLORS.dim + ' Milestone 1: Deterministic Analysis' + COLORS.reset);
|
|
761
|
+
log('');
|
|
762
|
+
|
|
763
|
+
// Step 1: Find transcripts
|
|
764
|
+
header(' SCANNING FOR TRANSCRIPTS');
|
|
765
|
+
status('Looking in: ' + inputPath);
|
|
766
|
+
|
|
767
|
+
var allTranscriptFiles = findTranscripts(inputPath);
|
|
768
|
+
|
|
769
|
+
if (allTranscriptFiles.length === 0) {
|
|
770
|
+
warn('No transcript files found');
|
|
771
|
+
log('');
|
|
772
|
+
log(' Expected location: ~/.claude/projects/<project-name>/*.jsonl');
|
|
773
|
+
log(' Or specify a path: node aci-score.js /path/to/transcripts');
|
|
774
|
+
log('');
|
|
775
|
+
process.exit(1);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
success('Found ' + allTranscriptFiles.length + ' transcript file(s)');
|
|
779
|
+
|
|
780
|
+
// Step 2: Ask user which transcripts to include
|
|
781
|
+
log('');
|
|
782
|
+
log(' ' + COLORS.bright + 'Which transcripts would you like to analyze?' + COLORS.reset);
|
|
783
|
+
log('');
|
|
784
|
+
log(' 1. All (' + allTranscriptFiles.length + ' files)');
|
|
785
|
+
log(' 2. Last 20 files');
|
|
786
|
+
log(' 3. Last 7 days');
|
|
787
|
+
log(' 4. Custom date range');
|
|
788
|
+
log('');
|
|
789
|
+
|
|
790
|
+
var selection = await askQuestion(' Enter choice (1-4): ');
|
|
791
|
+
|
|
792
|
+
// Handle custom date range
|
|
793
|
+
var transcriptFiles;
|
|
794
|
+
if (selection === '4') {
|
|
795
|
+
log('');
|
|
796
|
+
var daysInput = await askQuestion(' How many days back? ');
|
|
797
|
+
var days = parseInt(daysInput, 10);
|
|
798
|
+
if (isNaN(days) || days < 1) {
|
|
799
|
+
warn('Invalid input, using all transcripts');
|
|
800
|
+
transcriptFiles = allTranscriptFiles;
|
|
801
|
+
} else {
|
|
802
|
+
var cutoffDate = new Date();
|
|
803
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
804
|
+
transcriptFiles = allTranscriptFiles.slice().sort(function(a, b) {
|
|
805
|
+
return getTranscriptModTime(b) - getTranscriptModTime(a);
|
|
806
|
+
}).filter(function(f) {
|
|
807
|
+
return getTranscriptModTime(f) >= cutoffDate;
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
transcriptFiles = filterTranscriptsBySelection(allTranscriptFiles, selection);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (transcriptFiles.length === 0) {
|
|
815
|
+
warn('No transcripts match the selected criteria');
|
|
816
|
+
process.exit(1);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
log('');
|
|
820
|
+
success('Selected ' + transcriptFiles.length + ' transcript(s) for analysis');
|
|
821
|
+
|
|
822
|
+
// Step 3: Parse and calculate metrics
|
|
823
|
+
header(' ANALYZING TRANSCRIPTS');
|
|
824
|
+
status('Parsing messages and scrubbing secrets...');
|
|
825
|
+
|
|
826
|
+
var metrics = calculateMetrics(transcriptFiles);
|
|
827
|
+
|
|
828
|
+
success('Processed ' + metrics.totalMessages + ' messages');
|
|
829
|
+
if (metrics.secretsRedacted > 0) {
|
|
830
|
+
warn('Redacted ' + metrics.secretsRedacted + ' potential secret(s)');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Step 4: Generate Report ID
|
|
834
|
+
header(' GENERATING REPORT');
|
|
835
|
+
var reportId = generateReportId();
|
|
836
|
+
status('Report-ID: ' + reportId);
|
|
837
|
+
|
|
838
|
+
// Step 5: Create submission file (gzipped)
|
|
839
|
+
status('Creating compressed submission file...');
|
|
840
|
+
var outputDir = process.cwd();
|
|
841
|
+
var submission = createSubmissionFile(reportId, metrics, transcriptFiles, outputDir);
|
|
842
|
+
var compressionRatio = Math.round((1 - submission.compressedSize / submission.uncompressedSize) * 100);
|
|
843
|
+
success('Created: ' + path.basename(submission.path) + ' (' + formatBytes(submission.compressedSize) + ', ' + compressionRatio + '% smaller)');
|
|
844
|
+
|
|
845
|
+
// Step 6: Print CLI report
|
|
846
|
+
printReport(reportId, metrics);
|
|
847
|
+
|
|
848
|
+
log('');
|
|
849
|
+
log(' ● Generated submission file: ' + path.basename(submission.path));
|
|
850
|
+
log(' ' + COLORS.dim + 'Size: ' + formatBytes(submission.compressedSize) + ' (compressed from ' + formatBytes(submission.uncompressedSize) + ')' + COLORS.reset);
|
|
851
|
+
log(' ' + COLORS.dim + 'Upload to aci-metrics.com for full analysis' + COLORS.reset);
|
|
852
|
+
log('');
|
|
853
|
+
log(' ' + COLORS.dim + 'Note: AI-powered scoring requires model setup.' + COLORS.reset);
|
|
854
|
+
log(' ' + COLORS.dim + 'See: local-scorer/README.md' + COLORS.reset);
|
|
855
|
+
log('');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
main().catch(function(err) {
|
|
859
|
+
console.error('Error:', err.message);
|
|
860
|
+
process.exit(1);
|
|
861
|
+
});
|