diff --git a/v2/src/lib/adapters/claude-messages.ts b/v2/src/lib/adapters/claude-messages.ts index 99f1139..2d75d07 100644 --- a/v2/src/lib/adapters/claude-messages.ts +++ b/v2/src/lib/adapters/claude-messages.ts @@ -72,15 +72,7 @@ export interface ErrorContent { message: string; } -/** Runtime guard — returns value if it's a string, fallback otherwise */ -function str(v: unknown, fallback = ''): string { - return typeof v === 'string' ? v : fallback; -} - -/** Runtime guard — returns value if it's a number, fallback otherwise */ -function num(v: unknown, fallback = 0): number { - return typeof v === 'number' ? v : fallback; -} +import { str, num } from '../utils/type-guards'; /** * Adapt a raw SDK stream-json message to our internal format. diff --git a/v2/src/lib/adapters/codex-messages.ts b/v2/src/lib/adapters/codex-messages.ts index e235286..9290335 100644 --- a/v2/src/lib/adapters/codex-messages.ts +++ b/v2/src/lib/adapters/codex-messages.ts @@ -13,13 +13,7 @@ import type { ErrorContent, } from './claude-messages'; -function str(v: unknown, fallback = ''): string { - return typeof v === 'string' ? v : fallback; -} - -function num(v: unknown, fallback = 0): number { - return typeof v === 'number' ? v : fallback; -} +import { str, num } from '../utils/type-guards'; export function adaptCodexMessage(raw: Record): AgentMessage[] { const timestamp = Date.now(); diff --git a/v2/src/lib/adapters/ollama-messages.ts b/v2/src/lib/adapters/ollama-messages.ts index af60e59..f5e6e48 100644 --- a/v2/src/lib/adapters/ollama-messages.ts +++ b/v2/src/lib/adapters/ollama-messages.ts @@ -11,13 +11,7 @@ import type { ErrorContent, } from './claude-messages'; -function str(v: unknown, fallback = ''): string { - return typeof v === 'string' ? v : fallback; -} - -function num(v: unknown, fallback = 0): number { - return typeof v === 'number' ? v : fallback; -} +import { str, num } from '../utils/type-guards'; /** * Adapt a raw Ollama runner event to AgentMessage[]. diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts index 1139651..bdfe91b 100644 --- a/v2/src/lib/stores/health.svelte.ts +++ b/v2/src/lib/stores/health.svelte.ts @@ -3,6 +3,7 @@ import { getAgentSession, type AgentSession } from './agents.svelte'; import { getProjectConflicts } from './conflicts.svelte'; +import { scoreAttention } from '../utils/attention-scorer'; // --- Types --- @@ -51,13 +52,6 @@ const MODEL_CONTEXT_LIMITS: Record = { }; const DEFAULT_CONTEXT_LIMIT = 200_000; -// Attention score weights (higher = more urgent) -const SCORE_STALLED = 100; -const SCORE_CONTEXT_CRITICAL = 80; // >90% context -const SCORE_CONTEXT_HIGH = 40; // >75% context -const SCORE_ERROR = 90; -const SCORE_FILE_CONFLICT = 70; // 2+ agents writing same file -const SCORE_IDLE_LONG = 20; // >2x stall threshold but not stalled (shouldn't happen, safety) // --- State --- @@ -251,28 +245,16 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { const fileConflictCount = conflicts.conflictCount; const externalConflictCount = conflicts.externalConflictCount; - // Attention scoring — highest-priority signal wins - let attentionScore = 0; - let attentionReason: string | null = null; - - if (session?.status === 'error') { - attentionScore = SCORE_ERROR; - attentionReason = `Error: ${session.error?.slice(0, 60) ?? 'Unknown'}`; - } else if (activityState === 'stalled') { - attentionScore = SCORE_STALLED; - const mins = Math.floor(idleDurationMs / 60_000); - attentionReason = `Stalled — ${mins} min since last activity`; - } else if (contextPressure !== null && contextPressure > 0.9) { - attentionScore = SCORE_CONTEXT_CRITICAL; - attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`; - } else if (fileConflictCount > 0) { - attentionScore = SCORE_FILE_CONFLICT; - const extNote = externalConflictCount > 0 ? ` (${externalConflictCount} external)` : ''; - attentionReason = `${fileConflictCount} file conflict${fileConflictCount > 1 ? 's' : ''}${extNote}`; - } else if (contextPressure !== null && contextPressure > 0.75) { - attentionScore = SCORE_CONTEXT_HIGH; - attentionReason = `Context ${Math.round(contextPressure * 100)}%`; - } + // Attention scoring — delegated to pure function + const attention = scoreAttention({ + sessionStatus: session?.status, + sessionError: session?.error, + activityState, + idleDurationMs, + contextPressure, + fileConflictCount, + externalConflictCount, + }); return { projectId: tracker.projectId, @@ -284,8 +266,8 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { contextPressure, fileConflictCount, externalConflictCount, - attentionScore, - attentionReason, + attentionScore: attention.score, + attentionReason: attention.reason, }; } diff --git a/v2/src/lib/utils/attention-scorer.ts b/v2/src/lib/utils/attention-scorer.ts new file mode 100644 index 0000000..10c59a7 --- /dev/null +++ b/v2/src/lib/utils/attention-scorer.ts @@ -0,0 +1,68 @@ +// Attention scoring — pure function extracted from health store +// Determines which project needs attention most urgently + +import type { ActivityState } from '../stores/health.svelte'; + +// Attention score weights (higher = more urgent) +const SCORE_STALLED = 100; +const SCORE_ERROR = 90; +const SCORE_CONTEXT_CRITICAL = 80; // >90% context +const SCORE_FILE_CONFLICT = 70; +const SCORE_CONTEXT_HIGH = 40; // >75% context + +export interface AttentionInput { + sessionStatus: string | undefined; + sessionError: string | undefined; + activityState: ActivityState; + idleDurationMs: number; + contextPressure: number | null; + fileConflictCount: number; + externalConflictCount: number; +} + +export interface AttentionResult { + score: number; + reason: string | null; +} + +/** Score how urgently a project needs human attention. Highest-priority signal wins. */ +export function scoreAttention(input: AttentionInput): AttentionResult { + if (input.sessionStatus === 'error') { + return { + score: SCORE_ERROR, + reason: `Error: ${input.sessionError?.slice(0, 60) ?? 'Unknown'}`, + }; + } + + if (input.activityState === 'stalled') { + const mins = Math.floor(input.idleDurationMs / 60_000); + return { + score: SCORE_STALLED, + reason: `Stalled — ${mins} min since last activity`, + }; + } + + if (input.contextPressure !== null && input.contextPressure > 0.9) { + return { + score: SCORE_CONTEXT_CRITICAL, + reason: `Context ${Math.round(input.contextPressure * 100)}% — near limit`, + }; + } + + if (input.fileConflictCount > 0) { + const extNote = input.externalConflictCount > 0 ? ` (${input.externalConflictCount} external)` : ''; + return { + score: SCORE_FILE_CONFLICT, + reason: `${input.fileConflictCount} file conflict${input.fileConflictCount > 1 ? 's' : ''}${extNote}`, + }; + } + + if (input.contextPressure !== null && input.contextPressure > 0.75) { + return { + score: SCORE_CONTEXT_HIGH, + reason: `Context ${Math.round(input.contextPressure * 100)}%`, + }; + } + + return { score: 0, reason: null }; +} diff --git a/v2/src/lib/utils/type-guards.ts b/v2/src/lib/utils/type-guards.ts new file mode 100644 index 0000000..8af4b72 --- /dev/null +++ b/v2/src/lib/utils/type-guards.ts @@ -0,0 +1,11 @@ +// Runtime type guards for safely extracting values from untyped wire formats + +/** Returns value if it's a string, fallback otherwise */ +export function str(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : fallback; +} + +/** Returns value if it's a number, fallback otherwise */ +export function num(v: unknown, fallback = 0): number { + return typeof v === 'number' ? v : fallback; +}