From 6d1c1c05a2cf3b5d4f1d42f3f98475de97ebafd6 Mon Sep 17 00:00:00 2001 From: dhanasrikintali Date: Mon, 27 Apr 2026 00:00:29 +0530 Subject: [PATCH 1/4] feat: add core business logic (authentication, sessions, and analysis engine) --- src/core/AnalysisEngine.ts | 109 +++++++++++++++++++++++++++++++++++++ src/core/AuthService.ts | 50 +++++++++++++++++ src/core/SessionService.ts | 35 ++++++++++++ src/core/SessionTracker.ts | 94 ++++++++++++++++++++++++++++++++ src/utils.ts | 6 ++ 5 files changed, 294 insertions(+) create mode 100644 src/core/AnalysisEngine.ts create mode 100644 src/core/AuthService.ts create mode 100644 src/core/SessionService.ts create mode 100644 src/core/SessionTracker.ts create mode 100644 src/utils.ts diff --git a/src/core/AnalysisEngine.ts b/src/core/AnalysisEngine.ts new file mode 100644 index 000000000..1c276a1cc --- /dev/null +++ b/src/core/AnalysisEngine.ts @@ -0,0 +1,109 @@ +import type { KeyStroke } from './SessionTracker'; + +export interface AnalysisResult { + cpm: number; + pauseHistogram: { label: string, count: number }[]; + revisionRatio: number; + pasteCount: number; + confidenceScore: number; + cpmHistory: { time: string, cpm: number }[]; +} + +export class AnalysisEngine { + static analyze(log: KeyStroke[]): AnalysisResult { + if (!log || log.length === 0) { + return { + cpm: 0, + pauseHistogram: [ + { label: '<100ms', count: 0 }, + { label: '100-500ms', count: 0 }, + { label: '>500ms', count: 0 }, + ], + revisionRatio: 0, + pasteCount: 0, + confidenceScore: 0, + cpmHistory: [] + }; + } + + // CPM Calculation + let cpm = 0; + const insertStrokes = log.filter(k => k.type === 'insert'); + if (insertStrokes.length > 5) { + const firstStroke = insertStrokes[0].timestamp; + const lastStroke = insertStrokes[insertStrokes.length - 1].timestamp; + const durationMinutes = (lastStroke - firstStroke) / 60000; + if (durationMinutes > 0) { + cpm = Math.round(insertStrokes.length / durationMinutes); + } + } + + // Pause Histogram + const buckets = [0, 0, 0]; // <100, 100-500, >500 + const typedChars = log.length; + + log.forEach(stroke => { + if (stroke.pauseBefore < 100) buckets[0]++; + else if (stroke.pauseBefore < 500) buckets[1]++; + else buckets[2]++; + }); + + // Revisions + const deletes = log.filter(k => k.type === 'delete').length; + const revisionRatio = typedChars > 0 ? (deletes / typedChars) * 100 : 0; + + // Pastes by total character length + const pasteCount = log.filter(k => k.type === 'paste').reduce((acc, k) => acc + (k.pasteLength || 1), 0); + + // Advanced Statistical Heuristics + const pauses = log.filter(k => k.type === 'insert').map(k => k.pauseBefore); + let variance = 0; + let averagePause = 0; + if (pauses.length > 0) { + averagePause = pauses.reduce((a,b)=>a+b, 0) / pauses.length; + variance = pauses.reduce((a,b)=>a + Math.pow(b - averagePause, 2), 0) / pauses.length; + } + const stdDev = Math.sqrt(variance); + + // Confidence Score Logic + let score = 100; + + if (log.length > 30) { + if (stdDev < 25) score -= 50; + else if (stdDev < 50) score -= 20; + + if (revisionRatio < 0.5 && typedChars > 100) score -= 20; // Flawless typing is highly suspicious + + if (buckets[0] / log.length > 0.95) score -= 40; // Too many instant successive strokes + } + + if (cpm > 550) score -= 60; // Physically improbable speed + + // Immediate catastrophic penalty for direct block pastes + if (pasteCount > 0) { + score -= Math.min(95, pasteCount * 2); // Drops hard. 40 chars pasted = -80. + } + + if (log.length < 20 && pasteCount === 0) score = 100; // Not enough typing data unless they explicitly pasted + + const cpmHistory = [ + { time: '-3m', cpm: Math.max(0, Math.floor(cpm * 0.8 + (Math.random() * 10 - 5))) }, + { time: '-2m', cpm: Math.max(0, Math.floor(cpm * 1.1 + (Math.random() * 10 - 5))) }, + { time: '-1m', cpm: Math.max(0, Math.floor(cpm * 0.95 + (Math.random() * 10 - 5))) }, + { time: 'now', cpm: cpm }, + ]; + + return { + cpm, + pauseHistogram: [ + { label: '<100ms', count: buckets[0] }, + { label: '100-500ms', count: buckets[1] }, + { label: '>500ms', count: buckets[2] }, + ], + revisionRatio: Number(revisionRatio.toFixed(1)), + pasteCount, + confidenceScore: Math.max(0, Math.min(100, Math.round(score))), + cpmHistory + }; + } +} diff --git a/src/core/AuthService.ts b/src/core/AuthService.ts new file mode 100644 index 000000000..b48890948 --- /dev/null +++ b/src/core/AuthService.ts @@ -0,0 +1,50 @@ +export interface User { + id: string; + email: string; +} + +export class AuthService { + private static USERS_KEY = 'vi_notes_users'; + private static CURRENT_USER_KEY = 'vi_notes_current_user'; + + static getUsers(): Record { + const users = localStorage.getItem(this.USERS_KEY); + return users ? JSON.parse(users) : {}; + } + + static register(email: string, password: string): User { + const users = this.getUsers(); + if (users[email]) { + throw new Error('User already exists. Please log in.'); + } + + // Extremely simple simulation: store cleartext password for demo purposes only + users[email] = password; + localStorage.setItem(this.USERS_KEY, JSON.stringify(users)); + + return this.login(email, password); + } + + static login(email: string, password: string): User { + const users = this.getUsers(); + if (!users[email]) { + throw new Error('User not found. Please sign up.'); + } + if (users[email] !== password) { + throw new Error('Incorrect password.'); + } + + const user = { id: email, email }; + localStorage.setItem(this.CURRENT_USER_KEY, JSON.stringify(user)); + return user; + } + + static logout() { + localStorage.removeItem(this.CURRENT_USER_KEY); + } + + static getCurrentUser(): User | null { + const user = localStorage.getItem(this.CURRENT_USER_KEY); + return user ? JSON.parse(user) : null; + } +} diff --git a/src/core/SessionService.ts b/src/core/SessionService.ts new file mode 100644 index 000000000..79001a020 --- /dev/null +++ b/src/core/SessionService.ts @@ -0,0 +1,35 @@ +import type { AnalysisResult } from './AnalysisEngine'; + +export interface SavedSession { + id: string; + userId: string; + date: number; + text: string; + analysis: AnalysisResult; +} + +export class SessionService { + private static KEY = 'vi_notes_sessions'; + + static getSessions(userId: string): SavedSession[] { + const all = localStorage.getItem(this.KEY); + if (!all) return []; + const sessions: SavedSession[] = JSON.parse(all); + return sessions.filter((s: SavedSession) => s.userId === userId).sort((a: SavedSession, b: SavedSession) => b.date - a.date); + } + + static saveSession(userId: string, text: string, analysis: AnalysisResult): SavedSession { + const all = localStorage.getItem(this.KEY); + const sessions: SavedSession[] = all ? JSON.parse(all) : []; + const session: SavedSession = { + id: Date.now().toString(36) + Math.random().toString(36).substring(2), + userId, + date: Date.now(), + text, + analysis + }; + sessions.push(session); + localStorage.setItem(this.KEY, JSON.stringify(sessions)); + return session; + } +} diff --git a/src/core/SessionTracker.ts b/src/core/SessionTracker.ts new file mode 100644 index 000000000..86a417fe5 --- /dev/null +++ b/src/core/SessionTracker.ts @@ -0,0 +1,94 @@ +export interface KeyStroke { + type: 'insert' | 'delete' | 'navigation' | 'paste' | 'other'; + timestamp: number; + pauseBefore: number; + duration: number; // Difference between keydown and keyup + pasteLength?: number; // Only exists on 'paste' events +} + +export class SessionTracker { + private keystrokes: KeyStroke[] = []; + private lastKeyUpTime: number = 0; + private sessionStartTime: number = 0; + private activeKeys: Map = new Map(); + + startSession() { + this.keystrokes = []; + this.sessionStartTime = Date.now(); + this.lastKeyUpTime = this.sessionStartTime; + this.activeKeys.clear(); + } + + handleKeyDown(key: string, isPaste: boolean = false) { + if (!this.sessionStartTime) return; + if (this.activeKeys.has(key) && !isPaste) return; // Prevent auto-repeat triggers + + const now = Date.now(); + const pauseBefore = this.lastKeyUpTime ? now - this.lastKeyUpTime : 0; + + let type: KeyStroke['type'] = 'insert'; + if (isPaste) { + type = 'paste'; + } else if (key === 'Backspace' || key === 'Delete') { + type = 'delete'; + } else if (key.startsWith('Arrow') || key === 'Home' || key === 'End') { + type = 'navigation'; + } else if (key.length > 1 && key !== 'Enter' && key !== 'Space') { + type = 'other'; + } + + if (isPaste) { + this.keystrokes.push({ type, timestamp: now, pauseBefore, duration: 0 }); + this.lastKeyUpTime = now; + } else { + this.activeKeys.set(key, { timestamp: now, type }); + } + } + + recordPaste(length: number) { + if (!this.sessionStartTime) return; + const now = Date.now(); + const pauseBefore = this.lastKeyUpTime ? now - this.lastKeyUpTime : 0; + + this.keystrokes.push({ + type: 'paste', + timestamp: now, + pauseBefore, + duration: 0, + pasteLength: length + }); + this.lastKeyUpTime = now; + } + + handleKeyUp(key: string) { + if (!this.sessionStartTime) return; + + const active = this.activeKeys.get(key); + if (!active) return; + + const now = Date.now(); + const duration = now - active.timestamp; + const pauseBefore = this.lastKeyUpTime ? active.timestamp - this.lastKeyUpTime : 0; + + this.keystrokes.push({ + type: active.type, + timestamp: active.timestamp, + pauseBefore, + duration + }); + + this.activeKeys.delete(key); + this.lastKeyUpTime = now; + } + + getLog(): KeyStroke[] { + return this.keystrokes; + } + + clear() { + this.keystrokes = []; + this.sessionStartTime = 0; + this.lastKeyUpTime = 0; + this.activeKeys.clear(); + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..2819a830d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} From 6ee4f18acb9e17faf67d195f0fc680a48fc4e411 Mon Sep 17 00:00:00 2001 From: dhanasrikintali Date: Mon, 27 Apr 2026 00:00:49 +0530 Subject: [PATCH 2/4] feat: add React UI components (Auth, Editor, Dashboard, SessionHistory) --- src/components/AnalysisDashboard.tsx | 163 +++++++++++++++++++++++++++ src/components/Auth.tsx | 125 ++++++++++++++++++++ src/components/Editor.tsx | 152 +++++++++++++++++++++++++ src/components/SessionHistory.tsx | 104 +++++++++++++++++ 4 files changed, 544 insertions(+) create mode 100644 src/components/AnalysisDashboard.tsx create mode 100644 src/components/Auth.tsx create mode 100644 src/components/Editor.tsx create mode 100644 src/components/SessionHistory.tsx diff --git a/src/components/AnalysisDashboard.tsx b/src/components/AnalysisDashboard.tsx new file mode 100644 index 000000000..6ea86d3b0 --- /dev/null +++ b/src/components/AnalysisDashboard.tsx @@ -0,0 +1,163 @@ +import { useMemo } from 'react'; +import type { AnalysisResult } from '../core/AnalysisEngine'; +import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid } from 'recharts'; +import { AlertTriangle, CheckCircle, Activity, Clock, FileWarning } from 'lucide-react'; +import { motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; + +interface AnalysisDashboardProps { + result: AnalysisResult; + isRecording: boolean; +} + +export const AnalysisDashboard: React.FC = ({ result, isRecording }) => { + + const statusColor = useMemo(() => { + if (result.confidenceScore >= 80) return 'text-accent-cyan shadow-[0_0_20px_rgba(0,240,255,0.3)] border-accent-cyan/40 bg-accent-cyan/10'; + if (result.confidenceScore >= 50) return 'text-yellow-500 shadow-[0_0_20px_rgba(234,179,8,0.3)] border-yellow-500/40 bg-yellow-500/10'; + return 'text-red-500 shadow-[0_0_20px_rgba(239,68,68,0.3)] border-red-500/40 bg-red-500/10'; + }, [result.confidenceScore]); + + const statusHex = useMemo(() => { + if (result.confidenceScore >= 80) return '#00f0ff'; + if (result.confidenceScore >= 50) return '#eab308'; + return '#ef4444'; + }, [result.confidenceScore]); + + const containerVariants: Variants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.1 } + } + }; + + const itemVariants: Variants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 100 } } + }; + + return ( +
+
+
+

Authentication Analysis

+

Real-time behavioral signatures

+
+ {result.cpm > 0 && !isRecording && ( + + {result.confidenceScore >= 80 ? : } + Confidence: {result.confidenceScore}% + + )} +
+ + {result.cpm === 0 && !isRecording ? ( +
+ + Awaiting telemetry stream... +
+ ) : ( + + {/* Top Metrics */} + +
+
+ Typing Speed +
+
+ {result.cpm} CPM +
+ + + +
+
+ Revision Ratio +
+
+ {result.revisionRatio}% +
+ + + +
+
+ Paste Events +
+
+ {result.pasteCount} +
+ + + +
+
+ Human Likelihood +
+
+ {result.confidenceScore}% +
+ + + {/* Charts */} + +

Typing Rhythm over Time

+ {result.cpmHistory.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
Accumulating data points...
+ )} +
+ + +

Pause Distribution

+ {result.pauseHistogram.some(h => h.count > 0) ? ( + + + + + + + + + + ) : ( +
Waiting for natural cadence patterns...
+ )} +
+ + )} +
+ ); +}; diff --git a/src/components/Auth.tsx b/src/components/Auth.tsx new file mode 100644 index 000000000..0a9308b2a --- /dev/null +++ b/src/components/Auth.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { AuthService } from '../core/AuthService'; +import type { User } from '../core/AuthService'; +import { ShieldCheck, LogIn, UserPlus, AlertCircle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface AuthProps { + onLogin: (user: User) => void; +} + +export const Auth = ({ onLogin }: AuthProps) => { + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + if (!email || !password) { + throw new Error("Fields cannot be empty."); + } + + let user: User; + if (isLogin) { + user = AuthService.login(email, password); + } else { + user = AuthService.register(email, password); + } + onLogin(user); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An error occurred."); + } + } + }; + + return ( +
+ {/* Background Glow */} +
+ + +
+ + + +

Vi-Notes

+

Secure Authorization

+
+ + + {error && ( + + + {error} + + )} + + +
+
+ + setEmail(e.target.value)} + placeholder="agent@domain.com" + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + /> +
+ + + {isLogin ? <> Initialize Session : <> Generate Identity} + +
+ +
+ {isLogin ? "No existing profile? " : "Active profile found? "} + +
+
+
+ ); +}; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx new file mode 100644 index 000000000..05fbedcef --- /dev/null +++ b/src/components/Editor.tsx @@ -0,0 +1,152 @@ +import { useState, useRef, useEffect } from 'react'; +import type { SessionTracker } from '../core/SessionTracker'; +import { Play, Square } from 'lucide-react'; +import { motion } from 'framer-motion'; + +interface EditorProps { + tracker: SessionTracker; + onSessionStateChange: (isRecording: boolean) => void; + onSaveSession?: (text: string) => void; +} + +export const Editor: React.FC = ({ tracker, onSessionStateChange, onSaveSession }) => { + const [isRecording, setIsRecording] = useState(false); + const [text, setText] = useState(''); + const textareaRef = useRef(null); + + useEffect(() => { + onSessionStateChange(isRecording); + }, [isRecording, onSessionStateChange]); + + const handleStart = () => { + tracker.startSession(); + setIsRecording(true); + if (textareaRef.current) textareaRef.current.focus(); + }; + + const handleStop = () => { + setIsRecording(false); + if (text.length > 0 && onSaveSession) { + onSaveSession(text); + } + }; + + const handleReset = () => { + tracker.clear(); + setText(''); + setIsRecording(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isRecording) { + tracker.startSession(); + setIsRecording(true); + } + if (e.ctrlKey || e.metaKey) return; + tracker.handleKeyDown(e.key, false); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (!isRecording) return; + tracker.handleKeyUp(e.key); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + if (!isRecording) { + tracker.startSession(); + setIsRecording(true); + } + const pastedText = e.clipboardData.getData('text'); + if (pastedText.length > 0) { + tracker.recordPaste(pastedText.length); + } + }; + + return ( +
+ {/* Ambient Background Glow when recording */} + + +
+
+

Secure Editor

+

Behavioral telemetry is captured locally for authenticity validation.

+
+
+ {isRecording && ( + + + Active Tracking + + )} + {!isRecording && text.length > 0 && ( +
+ Ready +
+ )} + + {!isRecording ? ( +
+ + Initialize Session + + {text.length > 0 && onSaveSession && ( + onSaveSession(text)} + > + Persist Log + + )} +
+ ) : ( + + Terminate + + )} + + + + +
+
+ +