minimal-vibe-coding-kit 0.4.2 → 0.5.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/.agents/skills/claim/SKILL.md +82 -0
- package/.agents/skills/tutien/SKILL.md +74 -0
- package/.agents/skills/tutien/references/classification.md +64 -0
- package/.agents/skills/tutien/references/lore-sources.md +15 -0
- package/.agents/skills/tutien/references/privacy.md +33 -0
- package/.agents/skills/tutien/references/schema.md +75 -0
- package/.agents/skills/tutien/references/scoring-and-realms.md +37 -0
- package/.agents/skills/tutien/references/voice-and-mode.md +44 -0
- package/.agents/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
- package/.agents/skills/tutien/scripts/adapters/git.mjs +48 -0
- package/.agents/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
- package/.agents/skills/tutien/scripts/analyze-history.mjs +84 -0
- package/.agents/skills/tutien/scripts/catalog.mjs +130 -0
- package/.agents/skills/tutien/scripts/classify.mjs +311 -0
- package/.agents/skills/tutien/scripts/command.mjs +107 -0
- package/.agents/skills/tutien/scripts/compare.mjs +41 -0
- package/.agents/skills/tutien/scripts/metrics.mjs +306 -0
- package/.agents/skills/tutien/scripts/normalize-events.mjs +68 -0
- package/.agents/skills/tutien/scripts/redact.mjs +30 -0
- package/.agents/skills/tutien/scripts/render-report.mjs +423 -0
- package/.agents/skills/tutien/scripts/run-tutien.mjs +272 -0
- package/.agents/skills/tutien/scripts/score.mjs +97 -0
- package/.agents/skills/tutien/scripts/snapshot.mjs +76 -0
- package/.agents/skills/tutien/scripts/villains.mjs +196 -0
- package/.claude/skills/claim/SKILL.md +82 -0
- package/.claude/skills/tutien/SKILL.md +74 -0
- package/.claude/skills/tutien/references/classification.md +64 -0
- package/.claude/skills/tutien/references/lore-sources.md +15 -0
- package/.claude/skills/tutien/references/privacy.md +33 -0
- package/.claude/skills/tutien/references/schema.md +75 -0
- package/.claude/skills/tutien/references/scoring-and-realms.md +37 -0
- package/.claude/skills/tutien/references/voice-and-mode.md +44 -0
- package/.claude/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
- package/.claude/skills/tutien/scripts/adapters/git.mjs +48 -0
- package/.claude/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
- package/.claude/skills/tutien/scripts/analyze-history.mjs +84 -0
- package/.claude/skills/tutien/scripts/catalog.mjs +130 -0
- package/.claude/skills/tutien/scripts/classify.mjs +311 -0
- package/.claude/skills/tutien/scripts/command.mjs +107 -0
- package/.claude/skills/tutien/scripts/compare.mjs +41 -0
- package/.claude/skills/tutien/scripts/metrics.mjs +306 -0
- package/.claude/skills/tutien/scripts/normalize-events.mjs +68 -0
- package/.claude/skills/tutien/scripts/redact.mjs +30 -0
- package/.claude/skills/tutien/scripts/render-report.mjs +423 -0
- package/.claude/skills/tutien/scripts/run-tutien.mjs +272 -0
- package/.claude/skills/tutien/scripts/score.mjs +97 -0
- package/.claude/skills/tutien/scripts/snapshot.mjs +76 -0
- package/.claude/skills/tutien/scripts/villains.mjs +196 -0
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/claim/SKILL.md +82 -0
- package/.cursor/skills/tutien/SKILL.md +74 -0
- package/.cursor/skills/tutien/references/classification.md +64 -0
- package/.cursor/skills/tutien/references/lore-sources.md +15 -0
- package/.cursor/skills/tutien/references/privacy.md +33 -0
- package/.cursor/skills/tutien/references/schema.md +75 -0
- package/.cursor/skills/tutien/references/scoring-and-realms.md +37 -0
- package/.cursor/skills/tutien/references/voice-and-mode.md +44 -0
- package/.cursor/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
- package/.cursor/skills/tutien/scripts/adapters/git.mjs +48 -0
- package/.cursor/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
- package/.cursor/skills/tutien/scripts/analyze-history.mjs +84 -0
- package/.cursor/skills/tutien/scripts/catalog.mjs +130 -0
- package/.cursor/skills/tutien/scripts/classify.mjs +311 -0
- package/.cursor/skills/tutien/scripts/command.mjs +107 -0
- package/.cursor/skills/tutien/scripts/compare.mjs +41 -0
- package/.cursor/skills/tutien/scripts/metrics.mjs +306 -0
- package/.cursor/skills/tutien/scripts/normalize-events.mjs +68 -0
- package/.cursor/skills/tutien/scripts/redact.mjs +30 -0
- package/.cursor/skills/tutien/scripts/render-report.mjs +423 -0
- package/.cursor/skills/tutien/scripts/run-tutien.mjs +272 -0
- package/.cursor/skills/tutien/scripts/score.mjs +97 -0
- package/.cursor/skills/tutien/scripts/snapshot.mjs +76 -0
- package/.cursor/skills/tutien/scripts/villains.mjs +196 -0
- package/.grok/skills/claim/SKILL.md +82 -0
- package/.grok/skills/tutien/SKILL.md +74 -0
- package/.grok/skills/tutien/references/classification.md +64 -0
- package/.grok/skills/tutien/references/lore-sources.md +15 -0
- package/.grok/skills/tutien/references/privacy.md +33 -0
- package/.grok/skills/tutien/references/schema.md +75 -0
- package/.grok/skills/tutien/references/scoring-and-realms.md +37 -0
- package/.grok/skills/tutien/references/voice-and-mode.md +44 -0
- package/.grok/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
- package/.grok/skills/tutien/scripts/adapters/git.mjs +48 -0
- package/.grok/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
- package/.grok/skills/tutien/scripts/analyze-history.mjs +84 -0
- package/.grok/skills/tutien/scripts/catalog.mjs +130 -0
- package/.grok/skills/tutien/scripts/classify.mjs +311 -0
- package/.grok/skills/tutien/scripts/command.mjs +107 -0
- package/.grok/skills/tutien/scripts/compare.mjs +41 -0
- package/.grok/skills/tutien/scripts/metrics.mjs +306 -0
- package/.grok/skills/tutien/scripts/normalize-events.mjs +68 -0
- package/.grok/skills/tutien/scripts/redact.mjs +30 -0
- package/.grok/skills/tutien/scripts/render-report.mjs +423 -0
- package/.grok/skills/tutien/scripts/run-tutien.mjs +272 -0
- package/.grok/skills/tutien/scripts/score.mjs +97 -0
- package/.grok/skills/tutien/scripts/snapshot.mjs +76 -0
- package/.grok/skills/tutien/scripts/villains.mjs +196 -0
- package/.vibekit/docs/BACKBONE_REFERENCE.md +2 -0
- package/.vibekit/docs/INSTALL.md +3 -1
- package/.vibekit/init/CLAUDE-template.md +2 -0
- package/.vibekit/scripts/mvck.mjs +5 -5
- package/.vibekit/scripts/pack-dry-run.mjs +38 -3
- package/.vibekit/scripts/validate-kit.mjs +34 -24
- package/.vibekit/skills/claim/SKILL.md +82 -0
- package/.vibekit/skills/skills-manifest.json +27 -0
- package/.vibekit/skills/tutien/SKILL.md +74 -0
- package/.vibekit/skills/tutien/references/classification.md +64 -0
- package/.vibekit/skills/tutien/references/lore-sources.md +15 -0
- package/.vibekit/skills/tutien/references/privacy.md +33 -0
- package/.vibekit/skills/tutien/references/schema.md +75 -0
- package/.vibekit/skills/tutien/references/scoring-and-realms.md +37 -0
- package/.vibekit/skills/tutien/references/voice-and-mode.md +44 -0
- package/.vibekit/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
- package/.vibekit/skills/tutien/scripts/adapters/git.mjs +48 -0
- package/.vibekit/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
- package/.vibekit/skills/tutien/scripts/analyze-history.mjs +84 -0
- package/.vibekit/skills/tutien/scripts/catalog.mjs +130 -0
- package/.vibekit/skills/tutien/scripts/classify.mjs +311 -0
- package/.vibekit/skills/tutien/scripts/command.mjs +107 -0
- package/.vibekit/skills/tutien/scripts/compare.mjs +41 -0
- package/.vibekit/skills/tutien/scripts/metrics.mjs +306 -0
- package/.vibekit/skills/tutien/scripts/normalize-events.mjs +68 -0
- package/.vibekit/skills/tutien/scripts/redact.mjs +30 -0
- package/.vibekit/skills/tutien/scripts/render-report.mjs +423 -0
- package/.vibekit/skills/tutien/scripts/run-tutien.mjs +272 -0
- package/.vibekit/skills/tutien/scripts/score.mjs +97 -0
- package/.vibekit/skills/tutien/scripts/snapshot.mjs +76 -0
- package/.vibekit/skills/tutien/scripts/villains.mjs +196 -0
- package/CHANGELOG.md +30 -0
- package/README.md +51 -47
- package/docs/README.vi.md +12 -8
- package/docs/README.zh-CN.md +277 -0
- package/package.json +9 -3
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Parses a /tutien invocation. The skill is user-invoked only
|
|
2
|
+
// (disable-model-invocation: true), so nothing here runs unless the user
|
|
3
|
+
// typed /tutien. `off`/`on` toggle the session mode; a report action only
|
|
4
|
+
// runs when the mode is on.
|
|
5
|
+
|
|
6
|
+
const ACTIONS = new Set(['on', 'off', 'status', 'preview', 'analyze', 'compare', 'explain', 'classify']);
|
|
7
|
+
const TONE_ALIASES = new Map([
|
|
8
|
+
['serene', 'serene'],
|
|
9
|
+
['spirited', 'spirited'],
|
|
10
|
+
['neutral', 'neutral'],
|
|
11
|
+
// Backward-compatible aliases. They are accepted but no longer advertised,
|
|
12
|
+
// keeping the public vocabulary calm and unambiguous across agent surfaces.
|
|
13
|
+
['gentle', 'serene'],
|
|
14
|
+
['spicy', 'spirited']
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const TUTIEN_EXPERIENCE = Object.freeze({
|
|
18
|
+
kind: 'wholesome-coding-classification-game',
|
|
19
|
+
purpose: 'stress-relief-and-mindful-reflection',
|
|
20
|
+
narrativeStyle: 'refined-mystical-xianxia',
|
|
21
|
+
semanticNamespace: 'tutien-coding-cultivation-v1'
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const STOP_REQUESTS = [
|
|
25
|
+
/^(?:(?:please\s+)|(?:(?:can|could|would)\s+you\s+(?:please\s+)?))?(?:off|stop|end|exit|disable)(?:\s+(?:now|please))?[.!]?$/i,
|
|
26
|
+
/^(?:(?:please\s+)|(?:(?:can|could|would)\s+you\s+(?:please\s+)?))?(?:stop|end|exit|disable)\s+(?:\/?tutien|tu\s+tien)(?:\s+mode)?(?:\s+(?:now|please))?[.!]?$/i,
|
|
27
|
+
/^(?:(?:vui\s+lòng|hãy|giúp\s+tôi)\s+)?(?:dừng|tắt|kết\s+thúc|thoát)(?:\s+(?:nhé|ngay))?[.!]?$/iu,
|
|
28
|
+
/^(?:(?:vui\s+lòng|hãy|giúp\s+tôi)\s+)?(?:dừng|tắt|kết\s+thúc|thoát)\s+(?:\/?tutien|tu\s+tiên)(?:\s+(?:mode|chế\s+độ))?(?:\s+(?:nhé|ngay))?[.!]?$/iu
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function normalizeTone(value) {
|
|
32
|
+
return TONE_ALIASES.get(String(value ?? '').toLowerCase()) ?? 'serene';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isStopRequest(argsString = '') {
|
|
36
|
+
const normalized = String(argsString).trim().replace(/\s+/g, ' ');
|
|
37
|
+
return STOP_REQUESTS.some((re) => re.test(normalized));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseInvocation(argsString = '') {
|
|
41
|
+
const tokens = String(argsString).trim().split(/\s+/).filter(Boolean);
|
|
42
|
+
const options = {
|
|
43
|
+
scope: 'repo',
|
|
44
|
+
privacy: 'aggregate-only',
|
|
45
|
+
language: 'auto',
|
|
46
|
+
tone: 'serene',
|
|
47
|
+
villains: 'on',
|
|
48
|
+
score: 'hidden',
|
|
49
|
+
includeExcerpts: false,
|
|
50
|
+
range: 'all',
|
|
51
|
+
sources: []
|
|
52
|
+
};
|
|
53
|
+
let action = null;
|
|
54
|
+
|
|
55
|
+
// A direct request to end the experience always wins over report parsing.
|
|
56
|
+
// This keeps prose mode and approval state from lingering after the user
|
|
57
|
+
// asks to return to the kit's normal voice.
|
|
58
|
+
if (isStopRequest(argsString)) {
|
|
59
|
+
return { action: 'off', isModeToggle: true, explicitAction: true, options };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const tok of tokens) {
|
|
63
|
+
const eq = tok.indexOf('=');
|
|
64
|
+
if (eq === -1) {
|
|
65
|
+
if (!action && ACTIONS.has(tok)) action = tok;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const key = tok.slice(0, eq);
|
|
69
|
+
const value = tok.slice(eq + 1);
|
|
70
|
+
switch (key) {
|
|
71
|
+
case 'language': options.language = value; break;
|
|
72
|
+
case 'tone': options.tone = normalizeTone(value); break;
|
|
73
|
+
case 'villains': options.villains = value === 'off' ? 'off' : 'on'; break;
|
|
74
|
+
case 'score': options.score = value === 'show' ? 'show' : 'hidden'; break;
|
|
75
|
+
case 'scope': options.scope = value; break;
|
|
76
|
+
case 'privacy': options.privacy = value; break;
|
|
77
|
+
case 'range': options.range = value; break;
|
|
78
|
+
case 'include-excerpts': options.includeExcerpts = value === 'true'; break;
|
|
79
|
+
case 'sources': options.sources = value.split(',').filter(Boolean); break;
|
|
80
|
+
case 'previous': options.previous = value; break;
|
|
81
|
+
case 'metric': options.metric = value; break;
|
|
82
|
+
case 'approve': options.approve = value; break;
|
|
83
|
+
case 'snapshot': options.snapshot = value === 'true'; break;
|
|
84
|
+
case 'faction': options.faction = value; break;
|
|
85
|
+
case 'affiliation': options.affiliation = value; break;
|
|
86
|
+
case 'paths': options.paths = value.split(',').filter(Boolean); break;
|
|
87
|
+
case 'domains': options.domains = value.split(',').filter(Boolean); break;
|
|
88
|
+
case 'authorization': options.authorization = value; break;
|
|
89
|
+
default: break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Default action when the user typed only `/tutien`: a safe preview. A bare
|
|
94
|
+
// invocation (no explicit action word) also re-activates the mode; an
|
|
95
|
+
// explicit report action while the mode is off is refused by the runner.
|
|
96
|
+
const explicitAction = action !== null;
|
|
97
|
+
if (!action) action = 'preview';
|
|
98
|
+
const isModeToggle = action === 'on' || action === 'off' || action === 'status';
|
|
99
|
+
return { action, isModeToggle, explicitAction, options };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The livelier `spirited` voice is explicit opt-in. Safety-sensitive contexts
|
|
103
|
+
// always use neutral prose. Legacy tone names normalize to the new namespace.
|
|
104
|
+
export function resolveTone(requestedTone, { sensitive = false } = {}) {
|
|
105
|
+
if (sensitive) return 'neutral';
|
|
106
|
+
return normalizeTone(requestedTone);
|
|
107
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Phase 4 comparison + schema migration. Compares two aggregate snapshots and
|
|
2
|
+
// reports deltas and a trend. Never fabricates a change from missing data.
|
|
3
|
+
|
|
4
|
+
import { SNAPSHOT_SCHEMA } from './snapshot.mjs';
|
|
5
|
+
|
|
6
|
+
// Forward-migrate an older snapshot so comparisons don't crash on new fields.
|
|
7
|
+
export function migrateSnapshot(snap) {
|
|
8
|
+
if (!snap || typeof snap !== 'object') throw new Error('snapshot is not an object');
|
|
9
|
+
const s = { ...snap };
|
|
10
|
+
if ((s.snapshotSchema ?? 0) > SNAPSHOT_SCHEMA) {
|
|
11
|
+
throw new Error(`snapshot schema ${s.snapshotSchema} is newer than supported ${SNAPSHOT_SCHEMA}; upgrade tutien`);
|
|
12
|
+
}
|
|
13
|
+
s.counts = { exactRepeats: 0, nearRepeats: 0, retryLoops: 0, conflicts: 0, failures: 0, recoveries: 0, passes: 0, ...(s.counts ?? {}) };
|
|
14
|
+
s.tokens = { reportedTotal: 0, estimatedTotal: 0, unknownTurns: 0, ...(s.tokens ?? {}) };
|
|
15
|
+
s.villainState = s.villainState ?? [];
|
|
16
|
+
s.snapshotSchema = SNAPSHOT_SCHEMA;
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const num = (x) => (typeof x === 'number' ? x : null);
|
|
21
|
+
const diff = (a, b) => (num(a) == null || num(b) == null ? null : a - b);
|
|
22
|
+
|
|
23
|
+
export function compareSnapshots(prevRaw, currRaw) {
|
|
24
|
+
const prev = migrateSnapshot(prevRaw);
|
|
25
|
+
const curr = migrateSnapshot(currRaw);
|
|
26
|
+
const scoreDelta = diff(curr.score, prev.score);
|
|
27
|
+
const countDeltas = {};
|
|
28
|
+
for (const k of Object.keys(curr.counts)) countDeltas[k] = (curr.counts[k] ?? 0) - (prev.counts[k] ?? 0);
|
|
29
|
+
return {
|
|
30
|
+
from: prev.createdAt,
|
|
31
|
+
to: curr.createdAt,
|
|
32
|
+
scoreDelta,
|
|
33
|
+
realmChange: { from: prev.realm?.name ?? null, to: curr.realm?.name ?? null },
|
|
34
|
+
tokenDeltas: {
|
|
35
|
+
reported: (curr.tokens.reportedTotal ?? 0) - (prev.tokens.reportedTotal ?? 0),
|
|
36
|
+
estimated: (curr.tokens.estimatedTotal ?? 0) - (prev.tokens.estimatedTotal ?? 0)
|
|
37
|
+
},
|
|
38
|
+
countDeltas,
|
|
39
|
+
trend: scoreDelta == null ? 'unknown' : scoreDelta > 0 ? 'improving' : scoreDelta < 0 ? 'worsening' : 'stable'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { sha16 } from './normalize-events.mjs';
|
|
2
|
+
|
|
3
|
+
// Deterministic metric engine. Every function returns plain data derived only
|
|
4
|
+
// from its inputs; no randomness, no timestamps of its own, no raw text.
|
|
5
|
+
|
|
6
|
+
export function groupTasks(events, { gapMinutes = 60 } = {}) {
|
|
7
|
+
const tasks = new Map();
|
|
8
|
+
const counters = new Map();
|
|
9
|
+
const lastTs = new Map();
|
|
10
|
+
for (const ev of events) {
|
|
11
|
+
if (ev.eventType === 'commit') continue;
|
|
12
|
+
const session = ev.sessionId ?? 'unknown';
|
|
13
|
+
let key, grouping, confidence;
|
|
14
|
+
if (ev.taskId) {
|
|
15
|
+
key = `explicit:${session}:${ev.taskId}`;
|
|
16
|
+
grouping = 'explicit';
|
|
17
|
+
confidence = 'high';
|
|
18
|
+
} else {
|
|
19
|
+
if (!counters.has(session)) counters.set(session, 0);
|
|
20
|
+
let idx = counters.get(session);
|
|
21
|
+
const t = ev.timestamp ? Date.parse(ev.timestamp) : NaN;
|
|
22
|
+
const last = lastTs.get(session);
|
|
23
|
+
if (last != null && Number.isFinite(t) && t - last > gapMinutes * 60000) {
|
|
24
|
+
idx += 1;
|
|
25
|
+
counters.set(session, idx);
|
|
26
|
+
}
|
|
27
|
+
if (Number.isFinite(t)) lastTs.set(session, t);
|
|
28
|
+
key = `derived:${session}:${idx}`;
|
|
29
|
+
grouping = 'derived';
|
|
30
|
+
confidence = 'medium';
|
|
31
|
+
}
|
|
32
|
+
if (!tasks.has(key)) tasks.set(key, { taskId: key, sessionId: session, grouping, confidence, eventIds: [] });
|
|
33
|
+
tasks.get(key).eventIds.push(ev.eventId);
|
|
34
|
+
}
|
|
35
|
+
return [...tasks.values()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeExact(text) {
|
|
39
|
+
return text.normalize('NFC').replace(/\r\n/g, '\n').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Loose form for near-repeat comparison: lowercase and collapse whitespace
|
|
43
|
+
// outside fenced code blocks; strip a leading slash-command token.
|
|
44
|
+
export function normalizeLoose(text) {
|
|
45
|
+
return normalizeExact(text)
|
|
46
|
+
.replace(/^\/[\w-]+\s+/, '')
|
|
47
|
+
.split(/(```[\s\S]*?```)/)
|
|
48
|
+
.map((seg, i) => (i % 2 === 1 ? seg : seg.toLowerCase().replace(/\s+/g, ' ')))
|
|
49
|
+
.join('')
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function trigrams(s) {
|
|
54
|
+
const out = new Set();
|
|
55
|
+
for (let i = 0; i <= s.length - 3; i++) out.add(s.slice(i, i + 3));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function diceSimilarity(a, b) {
|
|
60
|
+
const ta = trigrams(a);
|
|
61
|
+
const tb = trigrams(b);
|
|
62
|
+
if (!ta.size || !tb.size) return 0;
|
|
63
|
+
let inter = 0;
|
|
64
|
+
for (const g of ta) if (tb.has(g)) inter += 1;
|
|
65
|
+
return (2 * inter) / (ta.size + tb.size);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function repetitionMetrics(events, tasks, { nearThreshold = 0.9, minLength = 30 } = {}) {
|
|
69
|
+
const byId = new Map(events.map((e) => [e.eventId, e]));
|
|
70
|
+
const order = new Map(events.map((e, i) => [e.eventId, i]));
|
|
71
|
+
const exactRepeats = [];
|
|
72
|
+
const nearRepeats = [];
|
|
73
|
+
const retryLoopCandidates = [];
|
|
74
|
+
|
|
75
|
+
for (const task of tasks) {
|
|
76
|
+
const users = task.eventIds
|
|
77
|
+
.map((id) => byId.get(id))
|
|
78
|
+
.filter((e) => e.role === 'user' && e.eventType === 'message' && e.text);
|
|
79
|
+
if (users.length < 2) continue;
|
|
80
|
+
|
|
81
|
+
const parent = new Map(users.map((e) => [e.eventId, e.eventId]));
|
|
82
|
+
const find = (x) => {
|
|
83
|
+
while (parent.get(x) !== x) {
|
|
84
|
+
parent.set(x, parent.get(parent.get(x)));
|
|
85
|
+
x = parent.get(x);
|
|
86
|
+
}
|
|
87
|
+
return x;
|
|
88
|
+
};
|
|
89
|
+
const union = (a, b) => parent.set(find(a), find(b));
|
|
90
|
+
|
|
91
|
+
const exactGroups = new Map();
|
|
92
|
+
for (const e of users) {
|
|
93
|
+
const key = normalizeExact(e.text);
|
|
94
|
+
if (!exactGroups.has(key)) exactGroups.set(key, []);
|
|
95
|
+
exactGroups.get(key).push(e);
|
|
96
|
+
}
|
|
97
|
+
for (const group of exactGroups.values()) {
|
|
98
|
+
for (let i = 1; i < group.length; i++) union(group[i].eventId, group[0].eventId);
|
|
99
|
+
if (group.length >= 2) {
|
|
100
|
+
exactRepeats.push({
|
|
101
|
+
taskId: task.taskId,
|
|
102
|
+
count: group.length,
|
|
103
|
+
eventIds: group.map((e) => e.eventId),
|
|
104
|
+
textDigest: sha16(normalizeExact(group[0].text))
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < users.length; i++) {
|
|
110
|
+
for (let j = i + 1; j < users.length; j++) {
|
|
111
|
+
const a = users[i];
|
|
112
|
+
const b = users[j];
|
|
113
|
+
if (normalizeExact(a.text) === normalizeExact(b.text)) continue;
|
|
114
|
+
const la = normalizeLoose(a.text);
|
|
115
|
+
const lb = normalizeLoose(b.text);
|
|
116
|
+
if (la.length < minLength || lb.length < minLength) continue;
|
|
117
|
+
const sim = diceSimilarity(la, lb);
|
|
118
|
+
if (sim >= nearThreshold) {
|
|
119
|
+
nearRepeats.push({
|
|
120
|
+
taskId: task.taskId,
|
|
121
|
+
eventIds: [a.eventId, b.eventId],
|
|
122
|
+
similarity: Math.round(sim * 1000) / 1000
|
|
123
|
+
});
|
|
124
|
+
union(a.eventId, b.eventId);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let run = [users[0]];
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (run.length >= 3) {
|
|
132
|
+
retryLoopCandidates.push({
|
|
133
|
+
taskId: task.taskId,
|
|
134
|
+
count: run.length,
|
|
135
|
+
eventIds: run.map((e) => e.eventId),
|
|
136
|
+
confidence: task.grouping === 'explicit' ? 0.85 : 0.6
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
for (let i = 1; i < users.length; i++) {
|
|
141
|
+
const prev = run[run.length - 1];
|
|
142
|
+
const cur = users[i];
|
|
143
|
+
const sameClass = find(prev.eventId) === find(cur.eventId);
|
|
144
|
+
const between = events
|
|
145
|
+
.slice(order.get(prev.eventId) + 1, order.get(cur.eventId))
|
|
146
|
+
.some((e) => e.role !== 'user');
|
|
147
|
+
if (sameClass && between) run.push(cur);
|
|
148
|
+
else {
|
|
149
|
+
flush();
|
|
150
|
+
run = [cur];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
}
|
|
155
|
+
return { exactRepeats, nearRepeats, retryLoopCandidates };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const NEGATION_RE = /\b(?:do not|don't|never)\s+([a-z][\w\s/-]{2,60})/gi;
|
|
159
|
+
const STOPWORDS = new Set(['the', 'a', 'an', 'to', 'this', 'that', 'of', 'in', 'on', 'for', 'any']);
|
|
160
|
+
|
|
161
|
+
// Conservative lexical detector: only a "never/do not X" instruction followed
|
|
162
|
+
// in the same task by a later prompt requesting X. Candidates only, with
|
|
163
|
+
// confidence — a later correction that replaces the earlier instruction is a
|
|
164
|
+
// revision, which is why confidence stays at 0.6 and review is expected.
|
|
165
|
+
export function conflictCandidates(events, tasks) {
|
|
166
|
+
const byId = new Map(events.map((e) => [e.eventId, e]));
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const task of tasks) {
|
|
169
|
+
const users = task.eventIds
|
|
170
|
+
.map((id) => byId.get(id))
|
|
171
|
+
.filter((e) => e.role === 'user' && e.eventType === 'message' && e.text);
|
|
172
|
+
for (let i = 0; i < users.length; i++) {
|
|
173
|
+
for (const match of users[i].text.matchAll(NEGATION_RE)) {
|
|
174
|
+
const words = match[1]
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.split(/[^\w/-]+/)
|
|
177
|
+
.filter((w) => w && !STOPWORDS.has(w))
|
|
178
|
+
.slice(0, 2);
|
|
179
|
+
if (words.length < 2) continue;
|
|
180
|
+
for (let j = i + 1; j < users.length; j++) {
|
|
181
|
+
const later = users[j].text.toLowerCase();
|
|
182
|
+
if (words.every((w) => later.includes(w)) && !/(?:do not|don't|never)\s/.test(later)) {
|
|
183
|
+
out.push({
|
|
184
|
+
category: 'user-user',
|
|
185
|
+
taskId: task.taskId,
|
|
186
|
+
eventIds: [users[i].eventId, users[j].eventId],
|
|
187
|
+
phraseDigest: sha16(words.join(' ')),
|
|
188
|
+
confidence: 0.6
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function issueMetrics(events, taskKeyByEvent = new Map()) {
|
|
199
|
+
const failures = [];
|
|
200
|
+
const recoveries = [];
|
|
201
|
+
const revertCandidates = [];
|
|
202
|
+
const failEvents = [];
|
|
203
|
+
const passEventIds = [];
|
|
204
|
+
let passes = 0;
|
|
205
|
+
events.forEach((e, idx) => {
|
|
206
|
+
if (e.eventType === 'test' || e.eventType === 'tool_result') {
|
|
207
|
+
if (e.outcome === 'fail') {
|
|
208
|
+
failures.push({ eventId: e.eventId, label: e.label ?? null, taskId: taskKeyByEvent.get(e.eventId) ?? null });
|
|
209
|
+
failEvents.push({ e, idx });
|
|
210
|
+
} else if (e.outcome === 'pass') {
|
|
211
|
+
passes += 1;
|
|
212
|
+
passEventIds.push(e.eventId);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (e.eventType === 'commit' && e.commit?.isRevert) {
|
|
216
|
+
revertCandidates.push({ eventId: e.eventId, hash: e.commit.hash ?? null, confidence: 0.5 });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
for (const { e: failEv, idx } of failEvents) {
|
|
220
|
+
const rec = events
|
|
221
|
+
.slice(idx + 1)
|
|
222
|
+
.find(
|
|
223
|
+
(e) =>
|
|
224
|
+
(e.eventType === 'test' || e.eventType === 'tool_result') &&
|
|
225
|
+
e.outcome === 'pass' &&
|
|
226
|
+
(e.label ?? null) === (failEv.label ?? null)
|
|
227
|
+
);
|
|
228
|
+
if (rec) {
|
|
229
|
+
recoveries.push({
|
|
230
|
+
issueEventId: failEv.eventId,
|
|
231
|
+
recoveryEventId: rec.eventId,
|
|
232
|
+
taskId: taskKeyByEvent.get(failEv.eventId) ?? null
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { passes, passEventIds, failures, recoveries, revertCandidates };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Reported, estimated, and unknown usage stay disjoint. Only records marked
|
|
240
|
+
// cumulative are streaming counters (keep the last one); independent chunks
|
|
241
|
+
// that merely share a requestId are summed. Totals come from the normalized
|
|
242
|
+
// usableTotal, so unusable usage records can never contribute.
|
|
243
|
+
export function tokenMetrics(events) {
|
|
244
|
+
const byRequest = new Map();
|
|
245
|
+
const singles = [];
|
|
246
|
+
let dedupedStreamUpdates = 0;
|
|
247
|
+
for (const e of events) {
|
|
248
|
+
if (!e.usage) continue;
|
|
249
|
+
const rid = e.usage.requestId;
|
|
250
|
+
if (rid) {
|
|
251
|
+
if (!byRequest.has(rid)) byRequest.set(rid, []);
|
|
252
|
+
byRequest.get(rid).push(e);
|
|
253
|
+
} else {
|
|
254
|
+
singles.push(e);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const counted = [];
|
|
258
|
+
for (const group of byRequest.values()) {
|
|
259
|
+
if (group.some((e) => e.usage.cumulative)) {
|
|
260
|
+
counted.push(group[group.length - 1]);
|
|
261
|
+
dedupedStreamUpdates += group.length - 1;
|
|
262
|
+
} else {
|
|
263
|
+
counted.push(...group);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
counted.push(...singles);
|
|
267
|
+
let reportedTotal = 0;
|
|
268
|
+
let estimatedTotal = 0;
|
|
269
|
+
for (const e of counted) {
|
|
270
|
+
const total = e.usage.usableTotal;
|
|
271
|
+
if (total == null) continue;
|
|
272
|
+
if (e.usage.accuracy === 'reported') reportedTotal += total;
|
|
273
|
+
else if (e.usage.accuracy === 'estimated') estimatedTotal += total;
|
|
274
|
+
}
|
|
275
|
+
const unknownTurns = events.filter(
|
|
276
|
+
(e) =>
|
|
277
|
+
e.eventType === 'message' &&
|
|
278
|
+
e.role === 'assistant' &&
|
|
279
|
+
(!e.usage || !['reported', 'estimated'].includes(e.usage.accuracy))
|
|
280
|
+
).length;
|
|
281
|
+
return { reportedTotal, estimatedTotal, unknownTurns, countedRequests: counted.length, dedupedStreamUpdates };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function coverageMetrics(events, tasks, tokens) {
|
|
285
|
+
const sessions = new Set(
|
|
286
|
+
events.filter((e) => e.eventType !== 'commit' && e.sessionId).map((e) => e.sessionId)
|
|
287
|
+
);
|
|
288
|
+
const userPrompts = events.filter((e) => e.eventType === 'message' && e.role === 'user').length;
|
|
289
|
+
const commits = events.filter((e) => e.eventType === 'commit').length;
|
|
290
|
+
const ts = events.map((e) => e.timestamp).filter(Boolean).sort();
|
|
291
|
+
const assistant = events.filter((e) => e.eventType === 'message' && e.role === 'assistant');
|
|
292
|
+
const pct = (n, d) => (d ? Math.round((n / d) * 1000) / 1000 : 0);
|
|
293
|
+
const reportedPct = pct(assistant.filter((e) => e.usage?.accuracy === 'reported').length, assistant.length);
|
|
294
|
+
const estimatedPct = pct(assistant.filter((e) => e.usage?.accuracy === 'estimated').length, assistant.length);
|
|
295
|
+
const explicitPct = pct(tasks.filter((t) => t.grouping === 'explicit').length, tasks.length);
|
|
296
|
+
const confidence =
|
|
297
|
+
reportedPct >= 0.7 && explicitPct >= 0.5 ? 'high' : reportedPct >= 0.4 || explicitPct >= 0.5 ? 'medium' : 'low';
|
|
298
|
+
return {
|
|
299
|
+
sessions: sessions.size,
|
|
300
|
+
userPrompts,
|
|
301
|
+
commits,
|
|
302
|
+
window: { start: ts[0] ?? null, end: ts[ts.length - 1] ?? null },
|
|
303
|
+
tokenCoverage: { reportedTurnsPct: reportedPct, estimatedTurnsPct: estimatedPct, unknownTurns: tokens.unknownTurns },
|
|
304
|
+
confidence
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { redactText } from './redact.mjs';
|
|
3
|
+
|
|
4
|
+
export const sha16 = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
5
|
+
|
|
6
|
+
const digestOrNull = (v) => (v == null ? null : sha16(String(v)));
|
|
7
|
+
|
|
8
|
+
// Timestamps are canonicalized to UTC ISO or dropped; free-form strings never
|
|
9
|
+
// pass through as metadata.
|
|
10
|
+
function canonicalTimestamp(ts) {
|
|
11
|
+
if (ts == null) return null;
|
|
12
|
+
const t = Date.parse(ts);
|
|
13
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A usage record is usable only when a positive, finite total can be
|
|
17
|
+
// computed. An export claiming accuracy=reported with no numbers must not
|
|
18
|
+
// unlock token coverage or a realm score.
|
|
19
|
+
function usableTotal(u) {
|
|
20
|
+
if (Number.isFinite(u.total) && u.total > 0) return u.total;
|
|
21
|
+
const sum = (u.input ?? 0) + (u.output ?? 0) + (u.reasoning ?? 0);
|
|
22
|
+
return Number.isFinite(sum) && sum > 0 ? sum : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Raw adapter events -> normalized events. The event source is owned by the
|
|
26
|
+
// caller (analyze overwrites `__source` per adapter batch), never by the
|
|
27
|
+
// export content. Free text is redacted once and kept in memory only.
|
|
28
|
+
// Session, task, and label identifiers are digested so metadata fields can
|
|
29
|
+
// never carry raw content into analysis output; commit subjects are dropped
|
|
30
|
+
// (their redacted form already travels as event text).
|
|
31
|
+
export function normalizeEvents(rawEvents) {
|
|
32
|
+
return rawEvents.map((raw, i) => {
|
|
33
|
+
const source = raw.__source ?? 'generic-jsonl';
|
|
34
|
+
const text = raw.text != null ? redactText(raw.text) : null;
|
|
35
|
+
const sessionId = digestOrNull(raw.session);
|
|
36
|
+
let usage = null;
|
|
37
|
+
if (raw.usage) {
|
|
38
|
+
const total = usableTotal(raw.usage);
|
|
39
|
+
usage = {
|
|
40
|
+
input: raw.usage.input ?? null,
|
|
41
|
+
cachedInput: raw.usage.cachedInput ?? null,
|
|
42
|
+
output: raw.usage.output ?? null,
|
|
43
|
+
reasoning: raw.usage.reasoning ?? null,
|
|
44
|
+
total: raw.usage.total ?? null,
|
|
45
|
+
usableTotal: total,
|
|
46
|
+
accuracy: total == null ? 'unknown' : (raw.usage.accuracy ?? 'unknown'),
|
|
47
|
+
requestId: raw.usage.requestId ?? null,
|
|
48
|
+
cumulative: raw.usage.cumulative === true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
eventId: sha16(`${source}:${sessionId ?? ''}:${i}:${raw.type}:${text ?? ''}`),
|
|
53
|
+
source,
|
|
54
|
+
sessionId,
|
|
55
|
+
taskId: digestOrNull(raw.task),
|
|
56
|
+
timestamp: canonicalTimestamp(raw.ts),
|
|
57
|
+
role: raw.role ?? (raw.type === 'commit' ? 'git' : raw.type === 'message' ? null : 'tool'),
|
|
58
|
+
eventType: raw.type,
|
|
59
|
+
text,
|
|
60
|
+
textDigest: text ? sha16(text) : null,
|
|
61
|
+
textLength: text ? text.length : 0,
|
|
62
|
+
usage,
|
|
63
|
+
outcome: raw.outcome ?? null,
|
|
64
|
+
label: digestOrNull(raw.label),
|
|
65
|
+
commit: raw.commit ? { hash: raw.commit.hash ?? null, isRevert: raw.commit.isRevert === true } : null
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Redaction layer: every piece of ingested text passes through here before
|
|
2
|
+
// any other module sees it. Raw secret-bearing input never leaves this file.
|
|
3
|
+
|
|
4
|
+
const SENSITIVE_QUERY_KEY = /^(token|key|secret|password|passwd|pwd|auth|authorization|signature|sig|code|apikey|api_key|access_token|session)$/i;
|
|
5
|
+
|
|
6
|
+
export function sanitizeUrl(url) {
|
|
7
|
+
try {
|
|
8
|
+
const u = new URL(url);
|
|
9
|
+
u.username = '';
|
|
10
|
+
u.password = '';
|
|
11
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
12
|
+
if (SENSITIVE_QUERY_KEY.test(k)) u.searchParams.set(k, 'REDACTED');
|
|
13
|
+
}
|
|
14
|
+
u.hash = '';
|
|
15
|
+
return u.toString();
|
|
16
|
+
} catch {
|
|
17
|
+
return '[unparseable-url-redacted]';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+/gi;
|
|
22
|
+
const BEARER_RE = /\b(bearer|token|apikey|api[-_]key|authorization)([:=]\s*)\S+/gi;
|
|
23
|
+
const AWS_KEY_RE = /\bAKIA[0-9A-Z]{16}\b/g;
|
|
24
|
+
|
|
25
|
+
export function redactText(text) {
|
|
26
|
+
return String(text)
|
|
27
|
+
.replace(URL_RE, (m) => sanitizeUrl(m))
|
|
28
|
+
.replace(BEARER_RE, (_m, k, sep) => `${k}${sep}[REDACTED]`)
|
|
29
|
+
.replace(AWS_KEY_RE, '[REDACTED]');
|
|
30
|
+
}
|