1540 lines
56 KiB
TypeScript
1540 lines
56 KiB
TypeScript
// 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 PhysicalRect
|
|
} 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
|
|
/**
|
|
* 오버레이 앵커 — UIA 가 준 물리 픽셀 그대로다(사이드카가 DPI-aware 로 동작).
|
|
* DIP 변환은 창 배치(WindowManager → ScreenGeometryPort) 한 곳에서만 한다.
|
|
*/
|
|
anchor: PhysicalRect | null
|
|
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
|
|
anchorKind: AnchorKind
|
|
isPassword: boolean
|
|
isEditable: boolean
|
|
isComposing: boolean
|
|
hasSelection: boolean
|
|
available: boolean
|
|
appName: string | null
|
|
windowTitle: string | null
|
|
/**
|
|
* 입력창이 속한 최상위 창 핸들 (HWND). 알 수 없으면 null.
|
|
* 제안 수락 직전 "아직 같은 창인가" 를 확인하는 기준이다.
|
|
*/
|
|
windowHandle?: number | 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
|
|
/**
|
|
* 프로그램 삽입(제안 수락 등) 예고의 유효 시간.
|
|
*
|
|
* 수락은 수정자 키가 떼어지길 최대 1.5초 기다린 뒤 붙여넣고, 스냅샷은 디바운스(700ms)·
|
|
* settle·UIA 조회(최대 1.5초)를 거쳐 온다 — 그 합을 넉넉히 덮는다.
|
|
*/
|
|
const PROGRAMMATIC_INSERT_TTL_MS = 10_000
|
|
const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
const MAX_READABILITY_APPS = 64
|
|
|
|
/**
|
|
* "실제로 타이핑했다" 로 볼 키 종류.
|
|
*
|
|
* 화살표/단축키/기능키/엔터/탭 등은 제외한다 — 필드 이동이나 명령일 뿐 텍스트를
|
|
* 치는 게 아니다. IME 조합은 포함한다(한/일 입력은 조합 중이 곧 타이핑이다).
|
|
*/
|
|
const TYPING_KEY_CLASSES = new Set<KeyStrokeClass>([
|
|
'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<string, InputActivityBucket> = 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 _rebaseFocusText = false
|
|
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<string, AppReadabilityEvidence>()
|
|
/** 곧(또는 방금) 프로그램이 삽입한 텍스트 — 다음 diff 에서 "타이핑" 으로 학습하지 않는다. */
|
|
private _expectedInserts: Array<{ id: number; text: string; expiresAt: number }> = []
|
|
private _expectedInsertSeq = 0
|
|
|
|
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<K extends keyof InputTelemetryEvents>(event: K, listener: InputTelemetryEvents[K]): this {
|
|
return super.on(event, listener as (...args: unknown[]) => void)
|
|
}
|
|
|
|
override off<K extends keyof InputTelemetryEvents>(
|
|
event: K,
|
|
listener: InputTelemetryEvents[K]
|
|
): this {
|
|
return super.off(event, listener as (...args: unknown[]) => void)
|
|
}
|
|
|
|
override emit<K extends keyof InputTelemetryEvents>(
|
|
event: K,
|
|
...args: Parameters<InputTelemetryEvents[K]>
|
|
): 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
|
|
// 클릭은 다른 칸으로 옮겨 갔을 수 있다 — 다음 스냅샷의 텍스트를 새 기준으로 삼는다.
|
|
this._rebaseFocusText = true
|
|
}
|
|
|
|
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<void> {
|
|
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<TypingContext | null> {
|
|
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,
|
|
windowHandle: this._foreground.hwnd,
|
|
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.controlName ?? null)
|
|
const textForFocus = snapshot.isPassword ? '' : snapshot.text
|
|
if (focusKey !== this._lastFocusKey || this._rebaseFocusText) {
|
|
this._lastFocusKey = focusKey
|
|
this._textAtFocus = textForFocus
|
|
this._rebaseFocusText = false
|
|
}
|
|
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
|
|
|
|
const typedText = delta.insertedText ? this._withoutExpectedInsert(delta.insertedText) : ''
|
|
if (typedText) {
|
|
this._learnText(typedText, {
|
|
appName: this._foreground.appName,
|
|
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
|
|
source: 'typed'
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 곧 프로그램이 이 텍스트를 삽입한다 (제안 수락 · 받아쓰기 삽입).
|
|
*
|
|
* 삽입된 텍스트는 다음 스냅샷 diff 에 그대로 나타난다. 붙여넣기 판정 임계(200자)보다
|
|
* 짧으면 "타이핑" 으로 다시 학습돼, 삽입 경로가 이미 출처(source)를 붙여 기록한 문장이
|
|
* 두 번 쌓이고 제안이 자기 출력을 강화했다. 예고된 텍스트는 diff 학습에서 뺀다.
|
|
*
|
|
* 삽입이 실패했으면 돌려준 함수로 예고를 취소한다.
|
|
*/
|
|
expectProgrammaticInsert(text: string): () => void {
|
|
const trimmed = text.trim()
|
|
if (!trimmed) return () => undefined
|
|
const now = Date.now()
|
|
this._pruneExpectedInserts(now)
|
|
this._expectedInsertSeq += 1
|
|
const id = this._expectedInsertSeq
|
|
this._expectedInserts.push({ id, text: trimmed, expiresAt: now + PROGRAMMATIC_INSERT_TTL_MS })
|
|
return () => {
|
|
this._expectedInserts = this._expectedInserts.filter((entry) => entry.id !== id)
|
|
}
|
|
}
|
|
|
|
private _pruneExpectedInserts(now: number): void {
|
|
if (this._expectedInserts.length === 0) return
|
|
this._expectedInserts = this._expectedInserts.filter((entry) => entry.expiresAt > now)
|
|
}
|
|
|
|
/**
|
|
* diff 로 얻은 삽입 텍스트에서 예고된 프로그램 삽입을 뺀 "실제 타이핑" 부분.
|
|
*
|
|
* - 삽입 구간이 예고 텍스트를 품으면 그 부분만 빼고 나머지(앞뒤로 직접 친 글)는 남긴다.
|
|
* - diff 경계가 반복 글자에서 한 칸 밀려 회전된 형태로 잡힌 경우도 같은 텍스트로 본다.
|
|
* - 삽입 구간이 예고 텍스트의 앞부분이면(키보드 방식으로 치는 도중 잡힌 스냅샷) 전부
|
|
* 프로그램 삽입이고, 남은 뒷부분을 계속 기다린다.
|
|
*/
|
|
private _withoutExpectedInsert(inserted: string): string {
|
|
this._pruneExpectedInserts(Date.now())
|
|
const trimmed = inserted.trim()
|
|
if (!trimmed) return inserted
|
|
for (let i = 0; i < this._expectedInserts.length; i += 1) {
|
|
const expected = this._expectedInserts[i]
|
|
const index = inserted.indexOf(expected.text)
|
|
if (index >= 0) {
|
|
this._expectedInserts.splice(i, 1)
|
|
return `${inserted.slice(0, index)} ${inserted.slice(index + expected.text.length)}`.trim()
|
|
}
|
|
if (trimmed.length === expected.text.length && `${trimmed}${trimmed}`.includes(expected.text)) {
|
|
this._expectedInserts.splice(i, 1)
|
|
return ''
|
|
}
|
|
if (expected.text.startsWith(trimmed)) {
|
|
const remainder = expected.text.slice(trimmed.length).trim()
|
|
if (remainder) expected.text = remainder
|
|
else this._expectedInserts.splice(i, 1)
|
|
return ''
|
|
}
|
|
}
|
|
return inserted
|
|
}
|
|
|
|
/**
|
|
* 외부(음성 전사 등)에서 들어온 텍스트를 코퍼스에 넣는다.
|
|
*
|
|
* 음성 전사는 이미 사용자가 말한 문장이므로 제안 품질에 가장 강한 신호다.
|
|
* 학습 동의(`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<string, InputDailyStat>()
|
|
const hourly = new Map<number, InputHourlyStat>()
|
|
const apps = new Map<string, InputAppStat>()
|
|
|
|
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<InputDailyStat | null>(
|
|
(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<number>`count(*)`,
|
|
accepted: sql<number>`coalesce(sum(case when accepted then 1 else 0 end), 0)`,
|
|
avgLatency: sql<number | null>`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<number>`count(*)` })
|
|
.from(personalPhrases)
|
|
.get()
|
|
const sampleCount = db
|
|
.select({ value: sql<number>`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<number>`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 []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 학습 문구 삭제.
|
|
*
|
|
* 그래프 엣지(phrase_edges)는 문장 텍스트를 외래 키 없이 들고 있다 — 문구 행만 지우면
|
|
* follows 엣지를 따라 지운 문장이 제안 프롬프트와 그래프 통계에 계속 나온다.
|
|
* 문구와 그 문장을 가리키는 엣지를 한 트랜잭션에서 함께 지운다.
|
|
*/
|
|
deletePhrase(id: string): boolean {
|
|
try {
|
|
const db = getDatabase()
|
|
return db.transaction(() => {
|
|
const row = db
|
|
.select({ phrase: personalPhrases.phrase })
|
|
.from(personalPhrases)
|
|
.where(eq(personalPhrases.id, id))
|
|
.get()
|
|
const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run()
|
|
if (row) getPersonalGraphService().removePhrase(row.phrase)
|
|
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) + 컨트롤 종류 + 컨트롤 이름.
|
|
*
|
|
* 위치·크기는 쓰지 않는다 — 채팅 입력창은 글이 늘면 자라고 움직여, 넣으면 같은 칸인데도
|
|
* 칠 때마다 "새 칸" 으로 오인돼 기준선이 리셋되고 제안이 거의 막혔다(실측: Agent
|
|
* Switchboard 에서 not-typing 반복). 같은 창 안의 칸 이동은 클릭 재기준으로 잡는다.
|
|
*/
|
|
function computeFocusKey(hwnd: number | null, controlType: string | null, controlName: string | null): string {
|
|
return `${hwnd ?? ''}|${controlType ?? ''}|${controlName ?? ''}`
|
|
}
|
|
|
|
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 }
|