// src/main/services/InputTelemetryService.ts // // 입력 텔레메트리 수집기 — 키보드/마우스 집계 + 포커스 입력창 텍스트 학습. // // 설계 근거 (조사 기반): // - 수집 항목·집계·flush 주기는 ActivityWatch `aw-watcher-input` 따른다: // 카운트(keystrokes/clicks/scroll) + 마우스 이동량(축별 절대값 합)만 남기고 // **키 내용은 저장하지 않는다**. 5초 heartbeat 로 집계를 확정한다. // - 키 이벤트는 uiohook(이미 사용 중) 글로벌 후킹에서 받는다. 후킹은 프로세스 // 전역 글턴이므로 global-input-hook 의 참조 카운트로 KeyBindingService 와 공존한다. // - 이핑한 텍스트는 키코드로 복원하지 않는다 (한/일 IME 는 조합 결과가 텍스트라 // 원리적으로 불가). 대신 UIA 로 커밋된 텍스트를 읽어 스냅샷 diff 로 계산한다. // - 비밀번호 필드(IsPassword)와 IME 조합 중에는 스트를 쓰지 않는다 (fail-closed). // // 동의(consent) 기본값은 모두 OFF 다. `inputTelemetryEnabled` 없이는 아무것도 하지 않는다. import { EventEmitter } from 'events' import { screen } from 'electron' import { uIOhook } from 'uiohook-napi' import type { UiohookKeyboardEvent, UiohookMouseEvent, UiohookWheelEvent } from 'uiohook-napi' import { sql, gte, eq, desc, inArray } from 'drizzle-orm' import { INPUT_TELEMETRY_DEFAULTS, SUGGESTION_CONTEXT_MAX_CHARS, SUGGESTION_DEFAULTS, calculateFrictionInsight, classifyKeyStroke, computeTypedDelta, countWords, emptyActivityBucket, extractPhrases, isAppExcluded, isLearnablePhrase, LEARNING_EXCLUDED_APPS, manhattanDistance, mergeActivityBucket, rankFlowWindows, recommendAppExclusion, summarizeActivity, textBeforeCaret, withoutPlaceholderText, type AnchorKind, type FocusSnapshot, type InputActivityBucket, type InputAppStat, type InputAppSuggestionStat, type InputDailyStat, type InputHourlyStat, type InputInsightsSummary, type InputPrivacyReceipt, type InputTelemetryState, type InputSnapshotSummary, type AppReadabilityEvidence, type KeyStrokeClass, type PersonalPhrase, type PhraseSource, type UiRect } from '@d3ro/core/input-intelligence' import { getDatabase } from '../db' import { inputActivity, personalPhrases, suggestions as suggestionsTable, typingSamples } from '../db/schema' import { configGet, configSet } from './ConfigService' import { getLogger } from './LoggerService' import { acquireGlobalInputHook } from './global-input-hook' import { uiohookCodeToVk } from './KeyBindingService' import { getUiaContextService } from './UiaContextService' import { getForegroundWindowInfo } from '../utils/win32-foreground' import { getPersonalGraphService } from './PersonalGraphService' const logger = getLogger('InputTelemetryService') /** 제안 서비스가 소비하는 "지금 치고 있는 것" 스냅샷. */ export interface TypingContext { /** 어렛 앞 텍스트 (>제안 프롬프트 입력) */ prefix: string fullText: string caretOffset: number | null anchor: UiRect | null /** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */ anchorKind: AnchorKind isPassword: boolean isEditable: boolean isComposing: boolean hasSelection: boolean available: boolean appName: string | null windowTitle: string | null idleMs: number capturedAt: number /** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */ editedSinceFocus: boolean /** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */ typedRecently: boolean } interface InputTelemetryEvents { 'state-changed': (state: InputTelemetryState) => void 'typed-context': (context: TypingContext) => void activity: (payload: { totals: InputActivityBucket; appName: string | null }) => void } interface ForegroundInfo { appName: string | null windowTitle: string | null hwnd: number | null } const MAX_SAMPLE_CHARS = 500 const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000 const MAX_READABILITY_APPS = 64 /** * "실제로 타이핑했다" 로 볼 키 종류. * * 화살표/단축키/기능키/엔터/탭 등은 제외한다 — 필드 이동이나 명령일 뿐 텍스트를 * 치는 게 아니다. IME 조합은 포함한다(한/일 입력은 조합 중이 곧 타이핑이다). */ const TYPING_KEY_CLASSES = new Set([ 'letter', 'digit', 'symbol', 'space', 'backspace', 'delete', 'ime' ]) class InputTelemetryService extends EventEmitter { private _running = false private _releaseHook: (() => void) | null = null /** 아직 DB에 쓰지 않은 버킷 (key = date|hour|appName) */ private _pending: Map = new Map() /** 세션 누적 (라이브 UI 용) */ private _sessionTotals: InputActivityBucket = emptyActivityBucket() private _flushTimer: NodeJS.Timeout | null = null private _textTimer: NodeJS.Timeout | null = null /** 디바운스 이후의 settle 스냅샷 — 제안 게이트는 더 늦게 열린다 */ private _settleTimer: NodeJS.Timeout | null = null /** 백그라운드 주기 샘플러 — 타이핑이 이어져도 계속 관측한다 */ private _sampleTimer: NodeJS.Timeout | null = null /** 주기 샘플 스냅샷 진행 중 (중복 방지) */ private _sampling = false /** 제안 오버레이가 표시 중일 때만 포커스 이탈/선택을 계속 확인한다. */ private _suggestionPresentationActive = false private _presentationValidationTimer: NodeJS.Timeout | null = null private _lastEventAt = 0 /** * 마지막 키보드 입력 시각. * * 제안 문맥은 키보드 기준으로만 판단한다 — 마우스 클릭만으로 스냅샷이 돌면 * 필드에 이미 있던 텍스트로 제안이 만들어졌다(실측 신고). */ private _lastKeyAt = 0 /** * 마지막 "실제 타이핑" 시각 (TYPING_KEY_CLASSES 에 속하는 키만). * * `_lastKeyAt` 은 화살표/단축키를 포함한 모든 키를 기록해 필드 이동만으로도 * 갱신된다. 제안 게이트는 진짜 타이핑만 기준으로 삼아야 한다(실측: 필드에 * 이미 있던 텍스트를 마우스로 클릭만 했는데 제안이 뜸). */ private _lastTypedAt = 0 /** 현재 포커스된 (창, 컨트롤 종류, 위치)를 식별하는 키 — 바뀌면 새 필드로 본다. */ private _lastFocusKey = '' /** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */ private _textAtFocus = '' private _lastActiveTickAt = 0 private _lastMouse: { x: number; y: number } | null = null private _lastClickAt = 0 private _lastClickButton = -1 private _lastClickPos: { x: number; y: number } | null = null private _modifiers = { ctrl: false, alt: false, shift: false, meta: false } private _foreground: ForegroundInfo = { appName: null, windowTitle: null, hwnd: null } private _foregroundSampledAt = 0 private _lastSnapshot: FocusSnapshot | null = null /** 마지막 스냅샷이 caret 폴백(tail 가정)을 썠는지 — 진단용 */ private _caretFallback = false private _lastSnapshotKey = '' private _lastContext: TypingContext | null = null /** UI 진단용 스냅샷 요약 */ private _lastSnapshotSummary: InputSnapshotSummary | null = null /** 원문 없이 앱별 UIA 읽기 가능성만 누적한다. */ private _appReadability = new Map() private _prunedAt = 0 private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null private _onMouseMove: ((e: UiohookMouseEvent) => void) | null = null private _onWheel: ((e: UiohookWheelEvent) => void) | null = null // ── 타입 안전한 이벤트 오버로드 ──────────────────────── override on(event: K, listener: InputTelemetryEvents[K]): this { return super.on(event, listener as (...args: unknown[]) => void) } override off( event: K, listener: InputTelemetryEvents[K] ): this { return super.off(event, listener as (...args: unknown[]) => void) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } get isRunning(): boolean { return this._running } get lastContext(): TypingContext | null { return this._lastContext } setSuggestionPresentationActive(active: boolean): void { this._suggestionPresentationActive = active if (!active && this._presentationValidationTimer) { clearTimeout(this._presentationValidationTimer) this._presentationValidationTimer = null } } // ─ 수명 주기 ────────────────────────────────────────── /** 설정이 허용하면 수집을 시작한다. 실패해도 앱은 계속 돌아간다. */ start(): void { if (!this._isEnabledByConfig()) { logger.info('입력 텔레메트리 비활성 (설정 또는 일시정지)') return } if (this._running) return try { this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e) this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e) this._onMouseDown = (e: UiohookMouseEvent) => this._handleMouseDown(e) this._onMouseUp = (e: UiohookMouseEvent) => this._handleMouseUp(e) this._onMouseMove = (e: UiohookMouseEvent) => this._handleMouseMove(e) this._onWheel = (e: UiohookWheelEvent) => this._handleWheel(e) uIOhook.on('keydown', this._onKeyDown) uIOhook.on('keyup', this._onKeyUp) uIOhook.on('mousedown', this._onMouseDown) uIOhook.on('mouseup', this._onMouseUp) uIOhook.on('mousemove', this._onMouseMove) uIOhook.on('wheel', this._onWheel) this._releaseHook = acquireGlobalInputHook() this._running = true this._flushTimer = setInterval( () => this._flush(), INPUT_TELEMETRY_DEFAULTS.flushIntervalMs ) this._flushTimer.unref?.() // 주기 샘플러. // // 스냅샷이 "타이핑을 멈춘 뒤" 에만 돌면, 계속 치는 동안에는 디바운스 타이머가 // 계속 리셋돼 샘플이 하나도 만들어지지 않는다(실측: 에이전트 개발처럼 // 끊김 없이 치는 상황에서 제안이 전혀 안 뜸). 최근 입력이 있었으면 // 주기적으로 스냅샷을 만들어, 타이핑 중에도 제안이 계속 갱신되게 한다. this._sampleTimer = setInterval( () => this._sampleWhileTyping(), INPUT_TELEMETRY_DEFAULTS.sampleIntervalMs ) this._sampleTimer.unref?.() this._pruneOldData() logger.info('입력 텔레메트리 시작 (키/마우스 집계)') this._emitState() } catch (error) { logger.error( `입력 텔레메트리 시작 실패: ${error instanceof Error ? error.message : String(error)}` ) this._detachListeners() } } stop(): void { if (!this._running) { this.setSuggestionPresentationActive(false) return } this._detachListeners() this._releaseHook?.() this._releaseHook = null this._running = false if (this._flushTimer) { clearInterval(this._flushTimer) this._flushTimer = null } if (this._sampleTimer) { clearInterval(this._sampleTimer) this._sampleTimer = null } if (this._textTimer) { clearTimeout(this._textTimer) this._textTimer = null } if (this._settleTimer) { clearTimeout(this._settleTimer) this._settleTimer = null } this.setSuggestionPresentationActive(false) this._flush() this._lastSnapshot = null this._lastSnapshotKey = '' logger.info('입력 텔레메트리 중지') this._emitState() } /** 설정 변경 반영 (enable/pause/excludedApps). */ applyConfig(): void { const shouldRun = this._isEnabledByConfig() if (shouldRun && !this._running) this.start() else if (!shouldRun && this._running) this.stop() this._emitState() } dispose(): void { this.stop() this.removeAllListeners() } private _detachListeners(): void { if (this._onKeyDown) { uIOhook.removeListener('keydown', this._onKeyDown) this._onKeyDown = null } if (this._onKeyUp) { uIOhook.removeListener('keyup', this._onKeyUp) this._onKeyUp = null } if (this._onMouseDown) { uIOhook.removeListener('mousedown', this._onMouseDown) this._onMouseDown = null } if (this._onMouseUp) { uIOhook.removeListener('mouseup', this._onMouseUp) this._onMouseUp = null } if (this._onMouseMove) { uIOhook.removeListener('mousemove', this._onMouseMove) this._onMouseMove = null } if (this._onWheel) { uIOhook.removeListener('wheel', this._onWheel) this._onWheel = null } } private _isEnabledByConfig(): boolean { return configGet('inputTelemetryEnabled') === true && configGet('inputTelemetryPaused') !== true } // ── 이벤트 수집 ──────────────────────────────────────── private _handleKeyDown(event: UiohookKeyboardEvent): void { this._syncModifiers(event) this._registerInput(true) const bucket = this._bucket() // uiohook 키코드는 VK 와 다른 표계다 — 정본(VK)으로 변환해 분류한다. const vk = uiohookCodeToVk(event.keycode) const keyClass = vk === null ? 'other' : classifyKeyStroke(vk, this._modifiers) if (keyClass === 'shortcut') bucket.shortcuts += 1 else bucket.keystrokes += 1 if (keyClass === 'backspace') bucket.backspaces += 1 this._lastKeyAt = Date.now() if (TYPING_KEY_CLASSES.has(keyClass)) this._lastTypedAt = this._lastKeyAt this._scheduleTextSnapshot() } private _handleKeyUp(event: UiohookKeyboardEvent): void { this._syncModifiers(event) } private _handleMouseDown(event: UiohookMouseEvent): void { this._registerInput(false) const now = Date.now() const position = { x: event.x, y: event.y } const button = Number(event.button) const isDoubleClick = now - this._lastClickAt < 400 && this._lastClickButton === button && this._lastClickPos !== null && manhattanDistance(this._lastClickPos, position) < 6 const bucket = this._bucket() if (isDoubleClick) bucket.doubleClicks += 1 else bucket.clicks += 1 this._lastClickAt = now this._lastClickButton = button this._lastClickPos = position } private _handleMouseUp(event: UiohookMouseEvent): void { this._lastMouse = { x: event.x, y: event.y } if (!this._suggestionPresentationActive) return if (this._presentationValidationTimer) clearTimeout(this._presentationValidationTimer) this._presentationValidationTimer = setTimeout(() => { this._presentationValidationTimer = null void this._captureTextSnapshot() }, 120) this._presentationValidationTimer.unref?.() } private _handleMouseMove(event: UiohookMouseEvent): void { const position = { x: event.x, y: event.y } if (this._lastMouse) { const distance = manhattanDistance(this._lastMouse, position) // 픽셀 단위 미세抖动 제거 (ActivityWatch 는 전량 누적하지만 노이즈가 크다) if (distance >= 2) { this._bucket().mouseDistancePx += distance this._registerInput(false) } } this._lastMouse = position } private _handleWheel(event: UiohookWheelEvent): void { const bucket = this._bucket() bucket.scrollTicks += Math.max(1, Math.abs(Number(event.rotation) || 1)) this._registerInput(false) } /** * 활성 시간 누적 + 포그라운드 샘플링. * * ActivityWatch 처럼 "입력이 있으면 활성" 으로 판단한다. 연속 입력 간격이 * activeIdleTimeoutMs 를 넘으면 그 구간은 활성 시간에서 제외한다. */ private _registerInput(isKeyboard: boolean): void { const now = Date.now() const gap = this._lastEventAt === 0 ? 0 : now - this._lastEventAt if (gap > 0 && gap <= INPUT_TELEMETRY_DEFAULTS.activeIdleTimeoutMs) { this._bucket().activeMs += gap } else if (isKeyboard && this._lastActiveTickAt > 0) { this._bucket().activeMs += 250 } if (isKeyboard) this._lastActiveTickAt = now this._lastEventAt = now this._sampleForeground(now) } /** * 수정자 상태 — uiohook 이 모든 키 이벤트에 실어 보내는 플래그를 그대로 다. * (별도 상태 머신을 두면 키 반복/포커스 이동에서 어긋난다) */ private _syncModifiers(event: UiohookKeyboardEvent): void { this._modifiers = { ctrl: event.ctrlKey === true, alt: event.altKey === true, shift: event.shiftKey === true, meta: event.metaKey === true } } /** 포그라운드 창 정보 — 1초에 한 번만 갱신 (FFI 호출 비용) */ private _sampleForeground(now = Date.now()): void { if (now - this._foregroundSampledAt < INPUT_TELEMETRY_DEFAULTS.activeWindowSampleMinIntervalMs) { return } this._foregroundSampledAt = now const info = getForegroundWindowInfo() if (info) { this._foreground = { appName: info.appName || null, windowTitle: info.title || null, hwnd: info.hwnd } } } /** 이 시각의 (날짜, 시간, 앱) 버킷 */ private _bucket(now = new Date()): InputActivityBucket { const date = formatLocalDate(now) const hour = now.getHours() const appName = this._foreground.appName ?? '' const key = `${date}|${hour}|${appName}` let bucket = this._pending.get(key) if (!bucket) { bucket = emptyActivityBucket() this._pending.set(key, bucket) } return bucket } // ── 텍스트 학습 (UIA 스냅샷 diff) ────────────────────── /** * 최근 입력이 있었으면 주기적으로 스냅샷을 만든다 (백그라운드 관측). * * 입력이 끊긴 뒤에는 굳이 돌리지 않는다 — 그 시점은 디바운스/ settle 타이머가 * 이미 담당한다. */ private async _sampleWhileTyping(): Promise { if (!this._running || this._sampling) return const hasRecentKeyboardInput = this._lastKeyAt > 0 && Date.now() - this._lastKeyAt <= INPUT_TELEMETRY_DEFAULTS.sampleActiveWindowMs if (!hasRecentKeyboardInput && !this._suggestionPresentationActive) return this._sampling = true try { await this._captureTextSnapshot() } finally { this._sampling = false } } private _scheduleTextSnapshot(): void { if (this._textTimer) clearTimeout(this._textTimer) this._textTimer = setTimeout(() => { this._textTimer = null void this._captureTextSnapshot() }, INPUT_TELEMETRY_DEFAULTS.textSnapshotDebounceMs) this._textTimer.unref?.() // 제안 트리거 지연(기본 1000ms)이 스냅샷 디바운스(700ms)보다 크다. 스냅샷이 // 디바운스에만 돌면 "타이핑을 멈춘 뒤 N ms" 조건이 결코 충족되지 않아 // 제안이 영원히 요청되지 않는다(실측: debounce 로그만 반복). 그래서 // 트리거 지연을 넘긴 시점에 한 번 더 스냅샷을 돌려 게이트를 연다. if (this._settleTimer) clearTimeout(this._settleTimer) const settleDelay = Math.max( INPUT_TELEMETRY_DEFAULTS.textSnapshotDebounceMs + 300, (configGet('suggestionTriggerDelayMs') || 1000) + 400 ) this._settleTimer = setTimeout(() => { this._settleTimer = null void this._captureTextSnapshot() }, settleDelay) this._settleTimer.unref?.() } /** * 포커스 입력창 스냅샷을 읽어 (1) 타이핑 통계를 갱신하고 * (2) 제안 서비스에 컨텍스트 이벤트를 보낸다. */ private async _captureTextSnapshot(): Promise { if (!this._running) return null const uia = getUiaContextService() const snapshot = withoutPlaceholderText(await uia.getSnapshot()) const now = Date.now() // 유휴 시간은 키보드 기준이다 (마우스 이동/클릭으로는 제안하지 않는다). const idleMs = this._lastKeyAt > 0 ? now - this._lastKeyAt : Number.MAX_SAFE_INTEGER const typedRecently = this._lastTypedAt > 0 && now - this._lastTypedAt <= SUGGESTION_DEFAULTS.recentTypingWindowMs const context: TypingContext = { prefix: '', fullText: '', caretOffset: null, anchor: null, anchorKind: null, isPassword: snapshot.isPassword, isEditable: snapshot.isEditable, isComposing: snapshot.isComposing, hasSelection: snapshot.hasSelection, available: snapshot.available, appName: this._foreground.appName, windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle, idleMs, capturedAt: snapshot.capturedAt, editedSinceFocus: false, typedRecently } if (snapshot.available) { const usesCaretRect = snapshot.caretRect !== null context.anchor = snapshot.caretRect ?? snapshot.elementRect context.anchorKind = usesCaretRect ? 'caret' : snapshot.elementRect ? 'element' : null context.caretOffset = snapshot.caretOffset context.fullText = snapshot.text // 케어렛 오프셋을 못 얻는 제공자가 많다(실측: Notepad — src=value, caret=null). // 그때는 "문서 마지막 = 커서 앞" 으로 가정해 tail 만 쓴다. 문서 전체를 접두로 // 쓰면 프롬프트가 실제 입력 위치와 무관해진다. const caretKnown = snapshot.caretOffset !== null // 문서형 컨트롤(터미널 버퍼 등)은 tail 이 직전 출력/이전 줄까지 포함한다. // "지금 치는 줄" 만 접두로 써야 한다 — 마지막 줄을 취한다. const documentLike = snapshot.textSource === 'text' const tail = snapshot.text.slice(-SUGGESTION_CONTEXT_MAX_CHARS) context.prefix = caretKnown ? textBeforeCaret(snapshot.text, snapshot.caretOffset) : documentLike ? lastLineOf(tail) : tail this._caretFallback = !caretKnown // 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다). // 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가 // 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데 // 옛 검색어로 제안이 뜸). const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect) const textForFocus = snapshot.isPassword ? '' : snapshot.text if (focusKey !== this._lastFocusKey) { this._lastFocusKey = focusKey this._textAtFocus = textForFocus } context.editedSinceFocus = textForFocus !== this._textAtFocus if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) { this._applyTypedDelta(snapshot) } } this._recordReadabilityEvidence(snapshot) // 진단: 왜 제안이 안 뜨는지 로그만으로 판단할 수 있게 스냅샷 요약을 남긴다. logger.debug( `입력 스냅샷: avail=${snapshot.available} edit=${snapshot.isEditable} ` + `pw=${snapshot.isPassword} comp=${snapshot.isComposing} src=${snapshot.textSource} ` + `len=${snapshot.text.length} caret=${snapshot.caretOffset ?? -1} tail=${this._caretFallback} ` + `anchor=${context.anchor ? 'yes' : 'no'} app=${this._foreground.appName ?? '-'} ` + `reason=${snapshot.reason ?? '-'}` ) this._lastSnapshotSummary = { at: context.capturedAt, appName: this._foreground.appName, windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle, editable: snapshot.isEditable, isPassword: snapshot.isPassword, composing: snapshot.isComposing, textSource: snapshot.textSource, textLength: snapshot.text.length, caretFallback: this._caretFallback } this._lastContext = context this.emit('typed-context', context) // 진단 줄이 즉시 갱신되도록 상태 변경을 알린다 (키 입력마다가 아니라 스냅샷마다). this._emitState() return context } private _recordReadabilityEvidence(snapshot: FocusSnapshot): void { const appName = this._foreground.appName?.trim() if (!appName || snapshot.isPassword) return let evidence = this._appReadability.get(appName) if (!evidence) { if (this._appReadability.size >= MAX_READABILITY_APPS) { const oldest = this._appReadability.keys().next().value if (oldest) this._appReadability.delete(oldest) } evidence = { appName, samples: 0, readable: 0, unreadable: 0, empty: 0 } this._appReadability.set(appName, evidence) } evidence.samples += 1 if (snapshot.isEditable && snapshot.textSource !== 'none' && snapshot.text.length > 0) { evidence.readable += 1 } else if (snapshot.isEditable && snapshot.textSource !== 'none' && snapshot.text.length === 0) { evidence.empty += 1 } else { evidence.unreadable += 1 } } private _applyTypedDelta(snapshot: FocusSnapshot): void { const key = `${snapshot.windowTitle ?? ''}|${snapshot.controlName ?? ''}|${snapshot.controlType ?? ''}` const previous = this._lastSnapshot const focusChanged = key !== this._lastSnapshotKey this._lastSnapshot = snapshot this._lastSnapshotKey = key // 필드가 바뀌면 이전 텍스트와 diff 할 수 없다 — 기준선만 저장하고 통계는 건너뛴다. if (focusChanged || !previous) return const delta = computeTypedDelta(previous.text, snapshot.text, { keepText: configGet('inputLearnTypedText') === true }) if (delta.replaced) return if (delta.insertedChars === 0 && delta.insertedWords === 0) return const bucket = this._bucket() bucket.chars += delta.insertedChars bucket.words += delta.insertedWords bucket.sentences += delta.insertedSentences if (delta.insertedText) { this._learnText(delta.insertedText, { appName: this._foreground.appName, windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle, source: 'typed' }) } } /** * 외부(음성 전사 등)에서 들어온 텍스트를 코퍼스에 넣는다. * * 음성 전사는 이미 사용자가 말한 문장이므로 제안 품질에 가장 강한 신호다. * 학습 동의(`inputLearnTypedText`)가 없으면 아무것도 저장하지 않는다. */ recordExternalText( text: string, meta: { appName?: string | null; windowTitle?: string | null; source: PhraseSource } ): void { if (configGet('inputLearnTypedText') !== true) return this._learnText(text, { appName: meta.appName ?? null, windowTitle: meta.windowTitle ?? null, source: meta.source }) } private _learnText( raw: string, meta: { appName: string | null; windowTitle: string | null; source: PhraseSource } ): void { if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) { return } const text = sanitizeSample( raw .split(/\r?\n/u) .filter((line) => isLearnablePhrase(line)) .join('\n') ) if (!text) return try { const db = getDatabase() const now = Date.now() const wordCount = countWords(text) const charCount = text.length db.insert(typingSamples) .values({ id: crypto.randomUUID(), text: text.slice(0, MAX_SAMPLE_CHARS), wordCount, charCount, appName: meta.appName, windowTitle: meta.windowTitle, source: meta.source, createdAt: now }) .run() // 개인 그래프에 반영 (문장 노드 + follows/용어공유 엣지) getPersonalGraphService().indexText(text, { source: meta.source, appName: meta.appName, at: now }) for (const phrase of extractPhrases(text).filter(isLearnablePhrase)) { db.insert(personalPhrases) .values({ id: crypto.randomUUID(), phrase, count: 1, source: meta.source, lastUsedAt: now, createdAt: now, appName: meta.appName }) .onConflictDoUpdate({ target: personalPhrases.phrase, set: { count: sql`${personalPhrases.count} + 1`, lastUsedAt: now, appName: meta.appName } }) .run() } } catch (error) { logger.warn( `텍스트 학습 저장 실패: ${error instanceof Error ? error.message : String(error)}` ) } } // ─ 영속화 ───────────────────────────────────────────── /** 대기 중인 버킷을 DB로 확정한다 (5초 heartbeat). */ flushNow(): void { this._flush() } private _flush(): void { if (this._pending.size === 0) return const entries = [...this._pending.entries()] this._pending.clear() const now = Date.now() try { const db = getDatabase() for (const [key, bucket] of entries) { const [date, hourRaw, appName] = key.split('|') const hour = Number(hourRaw) if (!date || Number.isNaN(hour)) continue mergeActivityBucket(this._sessionTotals, bucket) db.insert(inputActivity) .values({ id: crypto.randomUUID(), date, hour, appName: appName ?? '', ...bucket, updatedAt: now }) .onConflictDoUpdate({ target: [inputActivity.date, inputActivity.hour, inputActivity.appName], set: { keystrokes: sql`${inputActivity.keystrokes} + ${bucket.keystrokes}`, shortcuts: sql`${inputActivity.shortcuts} + ${bucket.shortcuts}`, backspaces: sql`${inputActivity.backspaces} + ${bucket.backspaces}`, clicks: sql`${inputActivity.clicks} + ${bucket.clicks}`, doubleClicks: sql`${inputActivity.doubleClicks} + ${bucket.doubleClicks}`, scrollTicks: sql`${inputActivity.scrollTicks} + ${bucket.scrollTicks}`, mouseDistancePx: sql`${inputActivity.mouseDistancePx} + ${bucket.mouseDistancePx}`, chars: sql`${inputActivity.chars} + ${bucket.chars}`, words: sql`${inputActivity.words} + ${bucket.words}`, sentences: sql`${inputActivity.sentences} + ${bucket.sentences}`, activeMs: sql`${inputActivity.activeMs} + ${bucket.activeMs}`, updatedAt: now } }) .run() } this.emit('activity', { totals: { ...this._sessionTotals }, appName: this._foreground.appName }) } catch (error) { logger.warn( `입력 텔레메트리 저장 실패(다음 주기에 재시도): ${ error instanceof Error ? error.message : String(error) }` ) // 저장 실패 시 버킷을 되돌려 유실을 막는다. for (const [key, bucket] of entries) { const existing = this._pending.get(key) if (existing) mergeActivityBucket(existing, bucket) else this._pending.set(key, bucket) } } } // ── 조회 / 관리 ──────────────────────────────────────── getState(): InputTelemetryState { const uia = getUiaContextService() const currentApp = this._foreground.appName const excludedApps = [...configGet('inputExcludedApps')] const evidence = currentApp ? this._appReadability.get(currentApp) : undefined return { enabled: configGet('inputTelemetryEnabled') === true, paused: configGet('inputTelemetryPaused') === true, running: this._running, learnTypedText: configGet('inputLearnTypedText') === true, excludedApps, appName: currentApp, windowTitle: this._foreground.windowTitle, bridgeReason: uia.lastReason, bridgeAvailable: uia.isAvailable(), lastSnapshotAt: uia.lastSuccessAt, lastSnapshot: this._lastSnapshotSummary, exclusionRecommendation: evidence && !excludedApps.some((app) => app.toLowerCase() === currentApp?.toLowerCase()) ? recommendAppExclusion(evidence) : null } } /** 최근 N일 인사이트 (일별/시간대별/앱별). */ getSummary(days = 7): InputInsightsSummary { const safeDays = Math.max(1, Math.min(days, 365)) const since = new Date() since.setHours(0, 0, 0, 0) since.setDate(since.getDate() - (safeDays - 1)) const sinceDate = formatLocalDate(since) const totals = emptyActivityBucket() const daily = new Map() const hourly = new Map() const apps = new Map() try { const db = getDatabase() const rows = db .select() .from(inputActivity) .where(gte(inputActivity.date, sinceDate)) .all() for (const row of rows) { mergeActivityBucket(totals, { keystrokes: row.keystrokes, shortcuts: row.shortcuts, backspaces: row.backspaces, clicks: row.clicks, doubleClicks: row.doubleClicks, scrollTicks: row.scrollTicks, mouseDistancePx: row.mouseDistancePx, chars: row.chars, words: row.words, sentences: row.sentences, activeMs: row.activeMs }) const day = daily.get(row.date) ?? { date: row.date, keystrokes: 0, clicks: 0, words: 0, sentences: 0, mouseDistancePx: 0, activeMs: 0 } day.keystrokes += row.keystrokes day.clicks += row.clicks day.words += row.words day.sentences += row.sentences day.mouseDistancePx += row.mouseDistancePx day.activeMs += row.activeMs daily.set(row.date, day) const hour = hourly.get(row.hour) ?? { hour: row.hour, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 } hour.keystrokes += row.keystrokes hour.clicks += row.clicks hour.chars += row.chars hour.backspaces += row.backspaces hour.activeMs += row.activeMs hourly.set(row.hour, hour) if (row.appName) { const app = apps.get(row.appName) ?? { appName: row.appName, keystrokes: 0, clicks: 0, activeMs: 0 } app.keystrokes += row.keystrokes app.clicks += row.clicks app.activeMs += row.activeMs apps.set(row.appName, app) } } // 24시간 전체 분포 (topHours 는 상위만 잘라 쓴다) const hourlyAll: InputHourlyStat[] = Array.from({ length: 24 }, (_, hour) => ({ hour, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 })) for (const stat of hourly.values()) { if (stat.hour >= 0 && stat.hour < 24) hourlyAll[stat.hour] = stat } const dailySorted = [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)) const activeDays = dailySorted.length const peakDay = dailySorted.reduce( (best, day) => (!best || day.keystrokes > best.keystrokes ? day : best), null ) // 연속 기록 일수 (최장) let longestStreakDays = 0 let currentStreak = 0 let previousDate: string | null = null for (const day of dailySorted) { if (previousDate === null) { currentStreak = 1 } else { const prev = new Date(`${previousDate}T00:00:00`) const gapDays = Math.round( (new Date(`${day.date}T00:00:00`).getTime() - prev.getTime()) / 86400000 ) currentStreak = gapDays === 1 ? currentStreak + 1 : 1 } longestStreakDays = Math.max(longestStreakDays, currentStreak) previousDate = day.date } const suggestionTotals = db .select({ total: sql`count(*)`, accepted: sql`coalesce(sum(case when accepted then 1 else 0 end), 0)`, avgLatency: sql`avg(latency_ms)` }) .from(suggestionsTable) .where(gte(suggestionsTable.createdAt, Date.now() - safeDays * 86400000)) .get() const suggestionStats: InputInsightsSummary['suggestions'] = { total: Number(suggestionTotals?.total ?? 0), accepted: Number(suggestionTotals?.accepted ?? 0), acceptRate: Number(suggestionTotals?.total ?? 0) > 0 ? Number(suggestionTotals?.accepted ?? 0) / Number(suggestionTotals?.total ?? 1) : 0, avgLatencyMs: suggestionTotals?.avgLatency === null || suggestionTotals?.avgLatency === undefined ? null : Math.round(Number(suggestionTotals.avgLatency)) } const suggestionRows = db .select({ appName: suggestionsTable.appName, accepted: suggestionsTable.accepted, latencyMs: suggestionsTable.latencyMs }) .from(suggestionsTable) .where(gte(suggestionsTable.createdAt, Date.now() - safeDays * 86400000)) .all() const suggestionAppsByName = new Map< string, { total: number; accepted: number; latencyTotal: number; latencyCount: number } >() for (const row of suggestionRows) { const appName = row.appName?.trim() if (!appName) continue const stat = suggestionAppsByName.get(appName) ?? { total: 0, accepted: 0, latencyTotal: 0, latencyCount: 0 } stat.total += 1 if (row.accepted) stat.accepted += 1 if (row.latencyMs !== null && row.latencyMs !== undefined) { stat.latencyTotal += row.latencyMs stat.latencyCount += 1 } suggestionAppsByName.set(appName, stat) } const suggestionApps: InputAppSuggestionStat[] = [...suggestionAppsByName.entries()] .map(([appName, stat]) => ({ appName, total: stat.total, accepted: stat.accepted, acceptRate: stat.total > 0 ? stat.accepted / stat.total : 0, avgLatencyMs: stat.latencyCount > 0 ? Math.round(stat.latencyTotal / stat.latencyCount) : null })) .sort((a, b) => b.total - a.total || b.acceptRate - a.acceptRate || a.appName.localeCompare(b.appName)) .slice(0, 12) const phraseCount = db .select({ value: sql`count(*)` }) .from(personalPhrases) .get() const sampleCount = db .select({ value: sql`count(*)` }) .from(typingSamples) .get() return { days: safeDays, totals, daily: dailySorted, topHours: [...hourly.values()].sort((a, b) => b.keystrokes - a.keystrokes).slice(0, 6), topApps: [...apps.values()].sort((a, b) => b.keystrokes - a.keystrokes).slice(0, 12), phraseCount: Number(phraseCount?.value ?? 0), sampleCount: Number(sampleCount?.value ?? 0), averages: summarizeActivity(totals, safeDays, getDisplayScaleFactor()), hourly: hourlyAll, activeDays, longestStreakDays, peakDay, suggestions: suggestionStats, friction: calculateFrictionInsight(totals.chars, totals.backspaces), flowWindows: rankFlowWindows(hourlyAll, activeDays), suggestionApps } } catch (error) { logger.warn( `입력 인사이트 조회 실패: ${error instanceof Error ? error.message : String(error)}` ) return { days: safeDays, totals, daily: [], topHours: [], topApps: [], phraseCount: 0, sampleCount: 0, averages: summarizeActivity(totals, safeDays), hourly: Array.from({ length: 24 }, (_, hour) => ({ hour, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 })), activeDays: 0, longestStreakDays: 0, peakDay: null, suggestions: { total: 0, accepted: 0, acceptRate: 0, avgLatencyMs: null }, friction: calculateFrictionInsight(0, 0), flowWindows: [], suggestionApps: [] } } } /** * 과거에 이 접두 꼬리 뒤에 실제로 이어 쓴 텍스트를 찾는다 (개인 기억). * * 자주 쓰는 문구 목록보다 강한 개인화 신호다: 사용자가 그 문맥에서 실제로 * 어떤 문장을 이어 썼는지가 그대로 들어간다. 임베딩 검색이 아니라 로컬 * 문자열 관계 조회이므로 추가 의존성이나 네트워크가 없다. * * 학습 동의(inputLearnTypedText)가 없으면 저장된 표본이 없어 자연히 빈 배열이다. */ findContinuationHints(prefixTail: string, limit = 3): string[] { const tail = prefixTail.trim() if (tail.length < 4) return [] try { const db = getDatabase() const pattern = `%${tail.replace(/[%_\\]/gu, '')}%` const rows = db .select({ text: typingSamples.text }) .from(typingSamples) .where(sql`${typingSamples.text} LIKE ${pattern}`) .orderBy(desc(typingSamples.createdAt)) .limit(40) .all() const out: string[] = [] for (const row of rows) { const index = row.text.indexOf(tail) if (index < 0) continue const continuation = row.text .slice(index + tail.length) .replace(/\s+/gu, ' ') .trim() // 바로 뒤가 비어 있으면(문장이 그대로 끝) 참고 가치가 없다 if (continuation.length < 3) continue const clipped = continuation.slice(0, 60) if (out.some((existing) => existing === clipped)) continue out.push(clipped) if (out.length >= limit) break } return out } catch (error) { logger.warn( `과거 이어쓰기 조회 실패: ${error instanceof Error ? error.message : String(error)}` ) return [] } } getPrivacyReceipt(): InputPrivacyReceipt { try { const db = getDatabase() const countRows = (table: typeof inputActivity | typeof typingSamples | typeof personalPhrases | typeof suggestionsTable): number => Number(db.select({ value: sql`count(*)` }).from(table).get()?.value ?? 0) const counts = { activityBuckets: countRows(inputActivity), typingSamples: countRows(typingSamples), personalPhrases: countRows(personalPhrases), suggestions: countRows(suggestionsTable) } return { localOnly: true, rawKeyContentStored: false, retention: { activityDays: INPUT_TELEMETRY_DEFAULTS.retentionDays, typingSamplesDays: INPUT_TELEMETRY_DEFAULTS.retentionDays, suggestionDays: INPUT_TELEMETRY_DEFAULTS.retentionDays, personalPhrases: 'until-deleted' }, counts } } catch (error) { logger.warn(`입력 개인정보 영수증 조회 실패: ${error instanceof Error ? error.message : String(error)}`) throw error } } listPhrases(limit = 100): PersonalPhrase[] { try { const db = getDatabase() const rows = db .select() .from(personalPhrases) .orderBy(desc(personalPhrases.count), desc(personalPhrases.lastUsedAt)) .limit(Math.max(1, Math.min(limit, 500))) .all() return rows.map((row) => ({ id: row.id, phrase: row.phrase, count: row.count, source: row.source, appName: row.appName, lastUsedAt: row.lastUsedAt, createdAt: row.createdAt })) } catch (error) { logger.warn(`개인 문구 조회 실패: ${error instanceof Error ? error.message : String(error)}`) return [] } } deletePhrase(id: string): boolean { try { const db = getDatabase() const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run() return Number(result.changes ?? 0) > 0 } catch (error) { logger.warn(`개인 문구 제 실패: ${error instanceof Error ? error.message : String(error)}`) return false } } /** 수집된 모든 입력 데이터 삭제 (동의 철회/초기화). */ clearAll(): void { try { const db = getDatabase() db.delete(inputActivity).run() db.delete(typingSamples).run() db.delete(personalPhrases).run() db.delete(suggestionsTable).run() getPersonalGraphService().clearAll() this._pending.clear() this._appReadability.clear() this._sessionTotals = emptyActivityBucket() this._lastSnapshot = null this._lastSnapshotKey = '' logger.info('입력 텔레메트리 데이터 전체 삭제') } catch (error) { logger.warn(`입력 데이터 삭제 실패: ${error instanceof Error ? error.message : String(error)}`) throw error } } /** * 지금의 학습 규칙(제외 앱·문장 판정)에 맞지 않는 기존 코퍼스를 걷어낸다. * * 규칙이 생기기 전에 쌓인 터미널 상태줄·개발 지시가 그래프를 개발 쪽으로 끌고 * 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다. */ private _pruneUnlearnableCorpus(): void { const excluded = (source: string, appName: string | null): boolean => source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS) try { const db = getDatabase() const phraseIds = db .select({ id: personalPhrases.id, phrase: personalPhrases.phrase, source: personalPhrases.source, appName: personalPhrases.appName }) .from(personalPhrases) .all() .filter((row) => excluded(row.source, row.appName) || !isLearnablePhrase(row.phrase)) .map((row) => row.id) const sampleIds = db .select({ id: typingSamples.id, text: typingSamples.text, source: typingSamples.source, appName: typingSamples.appName }) .from(typingSamples) .all() .filter( (row) => excluded(row.source, row.appName) || !row.text.split(/\r?\n/u).some((line) => isLearnablePhrase(line)) ) .map((row) => row.id) for (let i = 0; i < phraseIds.length; i += 200) { db.delete(personalPhrases).where(inArray(personalPhrases.id, phraseIds.slice(i, i + 200))).run() } for (let i = 0; i < sampleIds.length; i += 200) { db.delete(typingSamples).where(inArray(typingSamples.id, sampleIds.slice(i, i + 200))).run() } db.run( sql`DELETE FROM phrase_edges WHERE from_phrase NOT IN (SELECT phrase FROM personal_phrases) OR to_phrase NOT IN (SELECT phrase FROM personal_phrases)` ) if (phraseIds.length > 0 || sampleIds.length > 0) { logger.info(`학습 규칙에 맞지 않는 코퍼스 정리: 문구 ${phraseIds.length}개, 표본 ${sampleIds.length}개`) } } catch (error) { logger.warn(`코퍼스 정리 실패: ${error instanceof Error ? error.message : String(error)}`) } } /** 보존 기간 초과 데이터 정리 (ActivityWatch 와 동일한 로컬 보존 정책). */ private _pruneOldData(now = Date.now()): void { if (now - this._prunedAt < RETENTION_PRUNE_INTERVAL_MS) return this._prunedAt = now this._pruneUnlearnableCorpus() // 그래프 유지보수도 같은 주기에 돌린다 (용어 공유 엣지 백필). getPersonalGraphService().runMaintenance() const cutoff = now - INPUT_TELEMETRY_DEFAULTS.retentionDays * 24 * 60 * 60 * 1000 try { const db = getDatabase() db.delete(typingSamples).where(sql`${typingSamples.createdAt} < ${cutoff}`).run() db.delete(inputActivity).where(sql`${inputActivity.updatedAt} < ${cutoff}`).run() db.delete(suggestionsTable).where(sql`${suggestionsTable.createdAt} < ${cutoff}`).run() logger.info(`입력 데이터 보존 정리 완료 (${INPUT_TELEMETRY_DEFAULTS.retentionDays}일 초과)`) } catch (error) { logger.warn(`보존 정리 실패: ${error instanceof Error ? error.message : String(error)}`) } } /** 일시정지 토글 (동의는 유지). */ setPaused(paused: boolean): void { configSet('inputTelemetryPaused', paused) this.applyConfig() } /** 수집 동의 설정. 끄면 즉시 중지하고 수집분도 지운다. */ setEnabled(enabled: boolean): void { configSet('inputTelemetryEnabled', enabled) if (!enabled) { this.clearAll() configSet('inputLearnTypedText', false) } this.applyConfig() } private _emitState(): void { this.emit('state-changed', this.getState()) } } /** * 마지막 비어 있지 않은 줄을 돌려준다 (터미널/문서형 컨트롤용). * * 터미널 버퍼 전체를 프롬프트로 넘기면 모델이 과거 출력을 이어 쓰려 한다. */ function lastLineOf(text: string): string { const lines = text.split(/\r?\n/u) for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i].trim() if (line.length > 0) return line.slice(-SUGGESTION_CONTEXT_MAX_CHARS) } return text.slice(-SUGGESTION_CONTEXT_MAX_CHARS) } /** * 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 위치. * * 높이는 뺀다 — 여러 줄 입력창은 타이핑에 따라 높이가 자라, 높이를 포함하면 * 같은 필드인데도 칠 때마다 "새 포커스" 로 오인돼 기준선이 계속 리셋된다. */ function computeFocusKey(hwnd: number | null, controlType: string | null, elementRect: UiRect | null): string { const rect = elementRect ? `${Math.round(elementRect.x)},${Math.round(elementRect.y)},${Math.round(elementRect.width)}` : 'none' return `${hwnd ?? ''}|${controlType ?? ''}|${rect}` } function formatLocalDate(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } /** 제어문자 제거 + 공백 정규화. 2자 미만은 버린다. */ function sanitizeSample(raw: string): string { let stripped = '' for (const char of raw) { const code = char.codePointAt(0) ?? 0 // 제어문자(개행/탭 포함)는 공백으로 바다 — 학습 표본에 제어문자를 남기지 않는다. stripped += code < 0x20 || code === 0x7f ? ' ' : char } const cleaned = stripped.replace(/\s+/gu, ' ').trim() if (cleaned.length < 2) return '' return cleaned.slice(0, MAX_SAMPLE_CHARS) } function getDisplayScaleFactor(): number { try { return screen.getPrimaryDisplay().scaleFactor || 1 } catch { return 1 } } let instance: InputTelemetryService | null = null export function getInputTelemetryService(): InputTelemetryService { if (!instance) instance = new InputTelemetryService() return instance } export function resetInputTelemetryServiceForTests(): void { instance?.dispose() instance = null } export { formatLocalDate, sanitizeSample } export type { InputTelemetryState }