131 lines
4.3 KiB
JavaScript
131 lines
4.3 KiB
JavaScript
// Pure logic for the Brain Dashboard. Browser-safe ES module (no node: APIs)
|
|
// so it loads both in the browser and under Vitest's node environment.
|
|
|
|
export function normalizeEpisode(raw) {
|
|
const v2 = raw.schema_version === 2;
|
|
const pr = raw.primary_rationale || {};
|
|
const events = Array.isArray(raw.events) ? raw.events : [];
|
|
const tools = {};
|
|
for (const ev of events) {
|
|
if (ev.kind === 'tool_summary' && ev.counts) {
|
|
for (const [k, n] of Object.entries(ev.counts)) tools[k] = (tools[k] || 0) + n;
|
|
}
|
|
}
|
|
const started = raw.timestamps?.started_at || null;
|
|
const ended = raw.timestamps?.ended_at || null;
|
|
return {
|
|
schemaVersion: v2 ? 2 : 1,
|
|
taskId: raw.task_id || null,
|
|
taskRef: raw.task_ref || raw.task_id || null,
|
|
startedAt: started,
|
|
endedAt: ended,
|
|
durationMs: started && ended ? Date.parse(ended) - Date.parse(started) : null,
|
|
pathType: raw.path_type || null,
|
|
outcome: raw.outcome || 'unknown',
|
|
promptSignal: v2 ? raw.prompt_signal || null : null,
|
|
decisionProvenance: v2 ? raw.decision_provenance || null : null,
|
|
environment: v2 ? raw.environment || null : null,
|
|
taskSize: v2 ? raw.task_size || null : null,
|
|
taskClassification: pr.task_classification || null,
|
|
nodeChosen: pr.node_chosen || null,
|
|
hardFloor: pr.hard_floor || { invoked: false, rules: [] },
|
|
skills: events.filter((e) => e.kind === 'skill_invoked').map((e) => e.skill),
|
|
tools,
|
|
errorCount: events.filter((e) => e.kind === 'error').length,
|
|
retryCount: events.filter((e) => e.kind === 'retry').length,
|
|
interruptCount: events.filter((e) => e.kind === 'interrupt').length,
|
|
events,
|
|
raw,
|
|
};
|
|
}
|
|
|
|
// episode skill name → automation-graph node id (see tools/observer-known-nodes.txt
|
|
// for the routable vocabulary; only skills that have a graph node are listed).
|
|
export const SKILL_TO_NODE = {
|
|
brainstorming: 'sk_brainstorm',
|
|
'writing-plans': 'sk_wplans',
|
|
'executing-plans': 'sk_eplans',
|
|
'subagent-driven-development': 'sk_subagent',
|
|
'test-driven-development': 'sk_tdd',
|
|
'systematic-debugging': 'sk_debug',
|
|
'verification-before-completion': 'sk_verify',
|
|
'requesting-code-review': 'sk_coderev',
|
|
'using-git-worktrees': 'sk_worktree',
|
|
'finishing-a-development-branch': 'sk_pr',
|
|
'writing-skills': 'sk_wskills',
|
|
'discovery-interview': 'discovery_interview',
|
|
'audit-portal': 'sk_audit_portal',
|
|
regression: 'sk_regression',
|
|
'process-modeling': 'process_modeling',
|
|
'process-analysis': 'process_analysis',
|
|
ccpm: 'ccpm',
|
|
'security-review': 'sk_security_review',
|
|
'claude-md-management': 'claude_md_mgmt',
|
|
};
|
|
|
|
// mcp__<server>__<tool> → automation-graph node id.
|
|
export const MCP_SERVER_TO_NODE = {
|
|
github: 'mcp_gh',
|
|
playwright: 'mcp_pw',
|
|
'laravel-boost': 'mcp_boost',
|
|
redis: 'mcp_redis',
|
|
sentry: 'mcp_sentry',
|
|
semgrep: 'mcp_semgrep',
|
|
openapi: 'mcp_openapi',
|
|
magic: 'mcp_21st',
|
|
'universal-icons': 'mcp_icons',
|
|
};
|
|
|
|
// "superpowers:systematic-debugging" → "systematic-debugging"
|
|
function skillBase(name) {
|
|
const s = String(name || '');
|
|
return s.includes(':') ? s.split(':').pop() : s;
|
|
}
|
|
|
|
// Returns { nodeIds: string[], signals: number, attributed: number }.
|
|
// A "signal" is an episode datum that names a routable node (a skill id or an
|
|
// mcp__ tool). Builtin Claude tools are not signals.
|
|
export function attributeNodes(episode) {
|
|
const ids = new Set();
|
|
let signals = 0;
|
|
let attributed = 0;
|
|
const consider = (nodeId) => {
|
|
signals++;
|
|
if (nodeId) {
|
|
ids.add(nodeId);
|
|
attributed++;
|
|
}
|
|
};
|
|
if (episode.nodeChosen && episode.nodeChosen !== 'direct') {
|
|
consider(SKILL_TO_NODE[skillBase(episode.nodeChosen)]);
|
|
}
|
|
for (const s of episode.skills) consider(SKILL_TO_NODE[skillBase(s)]);
|
|
for (const toolName of Object.keys(episode.tools)) {
|
|
const m = /^mcp__(.+?)__/.exec(toolName);
|
|
if (m) consider(MCP_SERVER_TO_NODE[m[1]]);
|
|
}
|
|
return { nodeIds: [...ids], signals, attributed };
|
|
}
|
|
|
|
export function parseEpisodes(text) {
|
|
const episodes = [];
|
|
let skipped = 0;
|
|
for (const line of String(text).split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(trimmed);
|
|
} catch {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (!raw || typeof raw !== 'object' || raw.observer_error) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
episodes.push(normalizeEpisode(raw));
|
|
}
|
|
return { episodes, skipped };
|
|
}
|