refactor(frontend): extract attention scorer and shared type guards
This commit is contained in:
parent
30c21256bc
commit
4d93b77f6a
6 changed files with 95 additions and 54 deletions
|
|
@ -72,15 +72,7 @@ export interface ErrorContent {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runtime guard — returns value if it's a string, fallback otherwise */
|
import { str, num } from '../utils/type-guards';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt a raw SDK stream-json message to our internal format.
|
* Adapt a raw SDK stream-json message to our internal format.
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,7 @@ import type {
|
||||||
ErrorContent,
|
ErrorContent,
|
||||||
} from './claude-messages';
|
} from './claude-messages';
|
||||||
|
|
||||||
function str(v: unknown, fallback = ''): string {
|
import { str, num } from '../utils/type-guards';
|
||||||
return typeof v === 'string' ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function num(v: unknown, fallback = 0): number {
|
|
||||||
return typeof v === 'number' ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adaptCodexMessage(raw: Record<string, unknown>): AgentMessage[] {
|
export function adaptCodexMessage(raw: Record<string, unknown>): AgentMessage[] {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,7 @@ import type {
|
||||||
ErrorContent,
|
ErrorContent,
|
||||||
} from './claude-messages';
|
} from './claude-messages';
|
||||||
|
|
||||||
function str(v: unknown, fallback = ''): string {
|
import { str, num } from '../utils/type-guards';
|
||||||
return typeof v === 'string' ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function num(v: unknown, fallback = 0): number {
|
|
||||||
return typeof v === 'number' ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt a raw Ollama runner event to AgentMessage[].
|
* Adapt a raw Ollama runner event to AgentMessage[].
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { getAgentSession, type AgentSession } from './agents.svelte';
|
import { getAgentSession, type AgentSession } from './agents.svelte';
|
||||||
import { getProjectConflicts } from './conflicts.svelte';
|
import { getProjectConflicts } from './conflicts.svelte';
|
||||||
|
import { scoreAttention } from '../utils/attention-scorer';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
|
|
@ -51,13 +52,6 @@ const MODEL_CONTEXT_LIMITS: Record<string, number> = {
|
||||||
};
|
};
|
||||||
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
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 ---
|
// --- State ---
|
||||||
|
|
||||||
|
|
@ -251,28 +245,16 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
const fileConflictCount = conflicts.conflictCount;
|
const fileConflictCount = conflicts.conflictCount;
|
||||||
const externalConflictCount = conflicts.externalConflictCount;
|
const externalConflictCount = conflicts.externalConflictCount;
|
||||||
|
|
||||||
// Attention scoring — highest-priority signal wins
|
// Attention scoring — delegated to pure function
|
||||||
let attentionScore = 0;
|
const attention = scoreAttention({
|
||||||
let attentionReason: string | null = null;
|
sessionStatus: session?.status,
|
||||||
|
sessionError: session?.error,
|
||||||
if (session?.status === 'error') {
|
activityState,
|
||||||
attentionScore = SCORE_ERROR;
|
idleDurationMs,
|
||||||
attentionReason = `Error: ${session.error?.slice(0, 60) ?? 'Unknown'}`;
|
contextPressure,
|
||||||
} else if (activityState === 'stalled') {
|
fileConflictCount,
|
||||||
attentionScore = SCORE_STALLED;
|
externalConflictCount,
|
||||||
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)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: tracker.projectId,
|
projectId: tracker.projectId,
|
||||||
|
|
@ -284,8 +266,8 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
contextPressure,
|
contextPressure,
|
||||||
fileConflictCount,
|
fileConflictCount,
|
||||||
externalConflictCount,
|
externalConflictCount,
|
||||||
attentionScore,
|
attentionScore: attention.score,
|
||||||
attentionReason,
|
attentionReason: attention.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
68
v2/src/lib/utils/attention-scorer.ts
Normal file
68
v2/src/lib/utils/attention-scorer.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
11
v2/src/lib/utils/type-guards.ts
Normal file
11
v2/src/lib/utils/type-guards.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue