// packages/core — 입력 인텔리전스 도메인 정본. // // 두 가지를 담는다: // 1) 입력 텔레메트리 계약 (키스트로크 분류 · 집계 버킷 · 타이핑 타) // 2) 다음 문장 제안 정책 (언제 요청하고 언제 지우는지, 후보 정제/랭킹) // // 설계 근거 (뇌피셜 금지 — 조사 결과를 그대로 따른다): // - 수집 항목·집계 방식은 ActivityWatch `aw-watcher-input`(presses/clicks/deltaX/deltaY/ // scrollX/scrollY, 5초 heartbeat, 키 내용 미저장)을 따른다. // - 다음 문장 제안의 디바운스/취소/출력 토큰 한도는 인라인 컴플리션 실측값 // (Continue 350ms, Tabby 250ms adaptive, twinny 300ms, Zed p50<200ms p90<500ms, // Tabby max_decoding_tokens=64, KeyType max 4~16)을 기본값으로 반영한다. // - 커서 rect 폴백 순서(케어렛 → 포커스 요소 → 마우스)는 KeyType.Windows 의 // CaretGeometryQuality.Estimated 경로와 같다. // // 순수 함수만 둔다 — Electron/Node 의존 금지 (테스트 가능성 유지). // ============================================================ // 기하 // ============================================================ export interface UiRect { x: number y: number width: number height: number } /** * 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지. * * 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는 * elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그 * 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을 * 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다. */ export type AnchorKind = 'caret' | 'element' | null /** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */ export const MOUSE_MOVE_NOISE_FLOOR_PX = 2 /** 맨해튼 거리 — ActivityWatch 와 동일하게 축별 절대값 합을 쓴다. */ export function manhattanDistance(from: { x: number; y: number }, to: { x: number; y: number }): number { return Math.abs(to.x - from.x) + Math.abs(to.y - from.y) } /** * 오버레이 커 좌표 계산. * * 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과 * 같은 관례다. 아래 공간이 없으면 위로 뒤집는다. * 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 — * 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다. * 마지막으로 항상 작업영역 안으로 클램프한다. */ export function anchorFloatingPanel( anchor: UiRect | null, anchorKind: AnchorKind, cursor: { x: number; y: number }, size: { width: number; height: number }, workArea: UiRect, gap = 6 ): { x: number; y: number } { if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) { return anchorOutsideElement(anchor, size, workArea, gap) } const rect: UiRect = anchor && anchor.width >= 0 && anchor.height >= 0 ? anchor : { x: cursor.x, y: cursor.y, width: 0, height: 0 } const anchorBottom = rect.y + Math.max(rect.height, 0) const desiredY = anchorBottom + gap const fitsBelow = desiredY + size.height <= workArea.y + workArea.height const above = rect.y - gap - size.height const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY return { x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width), y: clamp(y, workArea.y, workArea.y + workArea.height - size.height) } } function fitsInWorkArea( x: number, y: number, size: { width: number; height: number }, workArea: UiRect ): boolean { return ( x >= workArea.x && y >= workArea.y && x + size.width <= workArea.x + workArea.width && y + size.height <= workArea.y + workArea.height ) } /** * 요소 앵커를 요소 "바깥" 에 배치한다. * * 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을 * 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다 * (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에 * 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다. */ function anchorOutsideElement( element: UiRect, size: { width: number; height: number }, workArea: UiRect, gap: number ): { x: number; y: number } { const candidates: Array<{ x: number; y: number }> = [ { x: element.x, y: element.y + element.height + gap }, // 아래 { x: element.x, y: element.y - gap - size.height }, // 위 { x: element.x + element.width + gap, y: element.y }, // 오른쪽 { x: element.x - gap - size.width, y: element.y } // 왼쪽 ] for (const candidate of candidates) { if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate } const fallback = { x: element.x + element.width - size.width - gap, y: element.y + element.height - size.height - gap } return { x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width), y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height) } } function clamp(value: number, min: number, max: number): number { if (max < min) return min return Math.max(min, Math.min(value, max)) } // ============================================================ // 키스트로크 분류 (내용이 아니라 '종류'만 남긴다) // ============================================================ export type KeyStrokeClass = | 'letter' | 'digit' | 'symbol' | 'space' | 'enter' | 'tab' | 'backspace' | 'delete' | 'arrow' | 'navigation' | 'function' | 'modifier' | 'shortcut' | 'ime' | 'other' /** Windows VK 코드 → 키 종류. 키 자체는 저장하지 않는다. */ export function classifyKeyStroke( code: number, modifiers: { ctrl: boolean; alt: boolean; meta: boolean } ): KeyStrokeClass { if (modifiers.ctrl || modifiers.alt || modifiers.meta) return 'shortcut' if (code >= 0x41 && code <= 0x5a) return 'letter' if (code >= 0x30 && code <= 0x39) return 'digit' if (code >= 0x60 && code <= 0x69) return 'digit' if (code === 0x20) return 'space' if (code === 0x0d) return 'enter' if (code === 0x09) return 'tab' if (code === 0x08) return 'backspace' if (code === 0x2e) return 'delete' if (code >= 0x25 && code <= 0x28) return 'arrow' if (code >= 0x21 && code <= 0x24) return 'navigation' if (code >= 0x70 && code <= 0x87) return 'function' if (code >= 0xa0 && code <= 0xa5) return 'modifier' if (code === 0x5b || code === 0x5c || code === 0x10 || code === 0x11 || code === 0x12) return 'modifier' if (code === 0x1b) return 'navigation' if (code >= 0xba && code <= 0xc0) return 'symbol' if (code >= 0xdb && code <= 0xde) return 'symbol' if (code === 0xbe || code === 0xbc || code === 0xbd) return 'symbol' if (code === 0xe5) return 'ime' return 'other' } /** 단어 경계로 볼 수 있는 키 종류인지 (단어 수 추정용 보조 지표). */ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean { return keyClass === 'space' || keyClass === 'enter' || keyClass === 'tab' } // ============================================================ // 스트 지표 // ============================================================ const SENTENCE_TERMINATORS = /[.!?。!?…]/ /** * 단어 수. * * 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다. * (중국어처럼 공백이 없는 언어는 과소 집계된다 — GAP-SUGGEST-03 에 기록) */ export function countWords(text: string): number { if (!text) return 0 const tokens = text.split(/\s+/) let count = 0 for (const token of tokens) { if (/[\p{L}\p{N}]/u.test(token)) count += 1 } return count } /** 문장 수 — 종결 부호 + 개행 기준. */ export function countSentences(text: string): number { if (!text) return 0 let count = 0 for (const char of text) { if (SENTENCE_TERMINATORS.test(char) || char === '\n') count += 1 } return count } /** 마지막 문장 종결 여부 — 참이면 직전 문장이 끝난 것이므로 제안을 지운다. */ export function endsSentence(text: string): boolean { const trimmed = text.replace(/\s+$/u, '') if (!trimmed) return false const last = trimmed[trimmed.length - 1] return SENTENCE_TERMINATORS.test(last) || last === '\n' } /** 케어 앞 텍스트만 잘라낸다 (UIA 스냅샷 → 컨텍스트). */ export function textBeforeCaret(text: string, caretOffset: number | null): string { if (caretOffset === null || caretOffset < 0) return text return text.slice(0, Math.min(caretOffset, text.length)) } // ============================================================ // 타이핑 델타 (UIA 스냅샷 diff — IME 커밋 텍스트까지 반영된다) // ============================================================ export interface TypedDelta { insertedChars: number insertedWords: number insertedSentences: number /** 학습이 켜져 있을 때만 실제 삽입 텍스트를 담는다 */ insertedText: string /** 필드 전환/붙여넣기로 판단해 통계에서 제외했는지 */ replaced: boolean } /** 이 길이를 넘는 삽입은 타이핑이 아니라 붙여넣기/필드 전환으로 본다. */ export const PASTE_INSERTION_THRESHOLD_CHARS = 200 /** * 두 UIA 스냅샷의 최장 공통 접두/접미 diff 로 "새로 입력된 구간"을 뽑는다. * * 키코드로 텍스트를 복원하지 않는 이유: 한/일 IME 는 물리 키가 아니라 조합 결과가 * 텍스트가 되므로 키코드 재구성은 원리적으로 불가능하다. 커밋된 텍스트를 읽어 * diff 하는 방식만이 CJK 를 포함해 정확하다. */ export function computeTypedDelta( prevText: string, nextText: string, options: { keepText: boolean } ): TypedDelta { const empty: TypedDelta = { insertedChars: 0, insertedWords: 0, insertedSentences: 0, insertedText: '', replaced: false } if (!nextText) return { ...empty } if (nextText === prevText) return { ...empty } let prefix = 0 const maxPrefix = Math.min(prevText.length, nextText.length) while (prefix < maxPrefix && prevText[prefix] === nextText[prefix]) prefix += 1 let suffix = 0 const maxSuffix = Math.min(prevText.length - prefix, nextText.length - prefix) while ( suffix < maxSuffix && prevText[prevText.length - 1 - suffix] === nextText[nextText.length - 1 - suffix] ) { suffix += 1 } const inserted = nextText.slice(prefix, nextText.length - suffix) if (!inserted) return { ...empty } if (inserted.length > PASTE_INSERTION_THRESHOLD_CHARS) { return { ...empty, replaced: true } } return { insertedChars: inserted.length, insertedWords: countWords(inserted), insertedSentences: countSentences(inserted), insertedText: options.keepText ? inserted : '', replaced: false } } // ============================================================ // UIA 포커스 스냅샷 // ============================================================ export type FocusTextSource = 'value' | 'text' | 'legacy' | 'none' /** * 포커스된 입력 요소의 스냅샷. * * `available === false` 는 "지 못했다"는 뜻이고, 이때는 텍스트/케어렛을 쓰지 않는다 * (fail-closed). `isPassword` 는 UIA `IsPassword` 프로퍼티(30019) 결과다. */ export interface FocusSnapshot { available: boolean /** 사용 불가 사유 (진단용) */ reason?: string isPassword: boolean isEditable: boolean /** IME 조합 진행 중 — 조합 중에는 제안을 요청하지 않는다 */ isComposing: boolean /** 비축소 텍스트 선택이 있으면 제안을 표시하지 않는다. */ hasSelection: boolean controlType?: string controlName?: string className?: string textSource: FocusTextSource text: string caretOffset: number | null caretRect: UiRect | null elementRect: UiRect | null windowTitle: string | null appName: string | null processId: number | null capturedAt: number } export function emptyFocusSnapshot(reason: string, now: number): FocusSnapshot { return { available: false, reason, isPassword: false, isEditable: false, isComposing: false, hasSelection: false, textSource: 'none', text: '', caretOffset: null, caretRect: null, elementRect: null, windowTitle: null, appName: null, processId: null, capturedAt: now } } // ============================================================ // 텔레메트리 상태 (렌더러/프리로드와 공유하는 계약) // ============================================================ /** 마지막 입력 스냅샷 요약 — UI 가 "왜 제안이 안 뜨는지" 를 보여주는 근거. */ export interface InputSnapshotSummary { at: number appName: string | null windowTitle: string | null editable: boolean isPassword: boolean composing: boolean textSource: string textLength: number /** 케어렛 오프셋이 없어 문서 tail 을 접두로 쓴 경우 */ caretFallback: boolean } /** * 입력 텔레메트리 수집 상태. * * main 서비스가 만들고 렌더러 설정 화면이 읽는다. 계약이므로 core 에 둔다 * (렌더러는 main 모듈을 import 할 수 없다). */ export interface InputTelemetryState { enabled: boolean paused: boolean running: boolean learnTypedText: boolean excludedApps: string[] appName: string | null windowTitle: string | null /** UIA 브리지 상태 (진단용) */ bridgeReason: string bridgeAvailable: boolean lastSnapshotAt: number /** 마지막 스냅샷 요약 (없으면 null) */ lastSnapshot: InputSnapshotSummary | null /** 반복적으로 읽을 수 없는 앱을 발견했을 때만 제안한다. */ exclusionRecommendation: InputExclusionRecommendation | null } export interface AppReadabilityEvidence { appName: string | null samples: number readable: number unreadable: number empty: number } export interface InputExclusionRecommendation { appName: string reason: 'repeated-unreadable' | 'repeated-empty' samples: number } /** 반복적으로 읽히지 않는 앱만 제외 후보로 제안한다. */ export function recommendAppExclusion( evidence: AppReadabilityEvidence, minSamples = 4 ): InputExclusionRecommendation | null { const appName = evidence.appName?.trim() const samples = Math.max(0, evidence.samples) const requiredSamples = Math.max(1, minSamples) const readable = Math.max(0, evidence.readable) const unreadable = Math.max(0, evidence.unreadable) const empty = Math.max(0, evidence.empty) const problematicRatio = samples === 0 ? 0 : (unreadable + empty) / samples if (!appName || samples < requiredSamples || readable > 0 || problematicRatio < 0.75) return null return { appName, reason: unreadable >= empty ? 'repeated-unreadable' : 'repeated-empty', samples } } // ============================================================ // 제안 정책 // ============================================================ export type SuggestionSkipReason = | 'disabled' | 'not-editable' | 'password-field' | 'composing' | 'selection-active' | 'excluded-app' | 'empty-prefix' | 'sentence-complete' | 'prefix-too-short' | 'debounce' | 'rate-limited' | 'budget-exhausted' | 'model-unavailable' | 'already-visible' /** 사용자가 명시적으로 닫았다 */ | 'dismissed' /** 제안을 수락해 삽입했다 */ | 'accepted' /** 제안 후 사용자가 텍스트를 바꿔 더 이상 유효하지 않다 */ | 'stale' /** 생성 실패/결과 없음 */ | 'generation-failed' /** 같은 텍스트로 이미 요청했다 (클릭만 했거나 엔터로 보낸 뒤 재요청 방지) */ | 'unchanged' /** 연속 실패 후 쿨다운 중 (모델이 다른 작업으로 바쁠 수 있다) */ | 'cooldown' /** 포커스만 옮겨 왔을 뿐 이 필드에서 실제로 타이핑하지 않았다 (마우스 클릭 등) */ | 'not-typing' export type SuggestionDecision = | { action: 'request'; prefix: string } | { action: 'skip'; reason: SuggestionSkipReason } | { action: 'clear'; reason: SuggestionSkipReason } export type SuggestionRefreshDecision = 'keep' | 'regenerate' | 'stale' export interface SuggestionPolicyInput { enabled: boolean modelAvailable: boolean overlayVisible: boolean composing: boolean hasSelection: boolean isPassword: boolean isEditable: boolean appName: string | null excludedApps: readonly string[] /** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 필드에 들어오기만 한 경우 false) */ editedSinceFocus: boolean /** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */ typedRecently: boolean /** 어렛 앞 스트 */ prefix: string idleMs: number triggerDelayMs: number minPrefixChars: number sinceLastRequestMs: number minIntervalMs: number requestsThisMinute: number maxRequestsPerMinute: number requestsToday: number dailyBudget: number } /** * 제안 요청/스킵/삭제 결정. * * 순서가 중요하다: 비활성·보안(비밀번호)·IME 조합은 다른 어떤 조건보다 먼저 차단한다. * `clear` 는 "오버레이를 지워야 한다"는 뜻이고, `skip` 은 "이번엔 넘어간다"이다. */ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecision { if (!input.enabled) return { action: 'clear', reason: 'disabled' } if (input.isPassword) return { action: 'clear', reason: 'password-field' } if (input.hasSelection) return { action: 'clear', reason: 'selection-active' } if (!input.isEditable) return { action: 'clear', reason: 'not-editable' } if (input.appName && isAppExcluded(input.appName, input.excludedApps)) { return { action: 'clear', reason: 'excluded-app' } } // 포커스만 옮겨 왔을 뿐(마우스 클릭 등) 이 필드에서 아무것도 치지 않았으면 제안하지 않는다. // // 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막 // 키 입력이 오래전이라) idleMs 조건을 통과해 제안이 뜨던 문제(실측: YouTube 검색창 // 클릭만 했는데 옛 검색어로 제안이 뜸). if (!input.editedSinceFocus || !input.typedRecently) { return { action: 'clear', reason: 'not-typing' } } const prefix = input.prefix.replace(/\s+$/u, '') if (!prefix) return { action: 'clear', reason: 'empty-prefix' } // 문장이 끝났다고 막지 않는다. // // 이 기능은 "다음 문장 제안" 이다 — 마침표/물음표 뒤야말로 다음 문장이 필요한 // 지점이고, 채팅에서는 문장을 이어 쓰는 것이 정상이다. 처음에는 종결 부호를 // 차단 사유로 뒀는데, 실측 로그(Discord)에서 sentence-complete 가 모든 시도를 // 막아 "한 번도 안 뜨는" 결과가 됐다. if (prefix.length < input.minPrefixChars) return { action: 'clear', reason: 'prefix-too-short' } if (input.overlayVisible) return { action: 'skip', reason: 'already-visible' } // IME 조합 중에는 더 오래 기다린다. // // 조합을 하드 차단하면 한국어/일본어에서 기능이 죽는다 — 이핑 대부분이 조합 안에서 // 일어나므로 매 스냅샷이 조합 중으로 잡혀 영원히 제안이 안 나온다(실측 로그: // comp=true 반복 → "딱 한 번 나오고 이후 안 나옴"). 대신 조합 중에는 트리거 // 지연을 배수로 늘려, 타이핑이 이어지는 동안에는 조용하고 손을 멈춘 순간에만 // 제안을 만든다. const requiredIdle = input.composing ? input.triggerDelayMs * COMPOSING_IDLE_MULTIPLIER : input.triggerDelayMs if (input.idleMs < requiredIdle) { return { action: 'skip', reason: input.composing ? 'composing' : 'debounce' } } if (!input.modelAvailable) return { action: 'skip', reason: 'model-unavailable' } if (input.sinceLastRequestMs < input.minIntervalMs) return { action: 'skip', reason: 'rate-limited' } if (input.requestsThisMinute >= input.maxRequestsPerMinute) { return { action: 'skip', reason: 'rate-limited' } } if (input.requestsToday >= input.dailyBudget) return { action: 'skip', reason: 'budget-exhausted' } return { action: 'request', prefix } } /** * 생성 접두가 현재 접두의 연속 확장인지 판정한다. * * 마지막 글자는 아직 조합 중인 IME 음절일 수 있어, 그 한 글자를 뺀 접두까지도 * 연속 확장으로 인정한다 (예: 생성 시점 "하" → 현재 "한"). */ export function extendsPrefix(generatedPrefix: string, currentPrefix: string): boolean { const generated = generatedPrefix.replace(/\s+$/u, '') const current = currentPrefix.replace(/\s+$/u, '') if (current.startsWith(generated)) return true return generated.length > 0 && current.startsWith(generated.slice(0, -1)) } /** * 표시 중인 제안 세션(페이지 넘기며 보는 후보 목록)이 여전히 이 접두에 유효한지. * * `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다" * 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다 * (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다). */ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean { const generated = generatedPrefix.replace(/\s+$/u, '') const current = currentPrefix.replace(/\s+$/u, '') if (current === generated) return true if (generated.length === 0) return false return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1) } /** * 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다. * * 현재 접두가 생성 접두의 연속 확장이 아니면 이미 표시된 후보는 다른 문맥의 * 결과이므로 stale 이다. 연속 확장도 일정량 이상일 때만 재생성해 입력 중인 * 로컬 모델 요청이 반복되는 것을 막는다. */ export function decideSuggestionRefresh( generatedPrefix: string, currentPrefix: string, minimumGrowth = SUGGESTION_DEFAULTS.regenerateAfterChars ): SuggestionRefreshDecision { const generated = generatedPrefix.replace(/\s+$/u, '') const current = currentPrefix.replace(/\s+$/u, '') if (!generated) return 'regenerate' if (!extendsPrefix(generated, current)) return 'stale' return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep' } /** 제외 앱 판정 — 대소문자 무시, 실행 파일명(확장자 무관) 부분 일치. */ export function isAppExcluded(appName: string, excludedApps: readonly string[]): boolean { const target = appName.trim().toLowerCase() if (!target) return false const base = target.replace(/\.exe$/u, '') for (const raw of excludedApps) { const entry = raw.trim().toLowerCase().replace(/\.exe$/u, '') if (!entry) continue if (target === entry || base === entry) return true } return false } /** * 터미널 — 화면 버퍼가 곧 "입력창" 으로 읽혀 상태줄·명령·출력이 친 글로 잡힌다 * (실측: Claude Code 상태줄 `◑ OPUS 5`, `5 medium │ CTX ▕░░▏` 가 학습됨). * 제안도 셸 프롬프트 위에 뜨므로 여기서는 제안과 학습을 모두 하지 않는다. */ export const TERMINAL_APPS: readonly string[] = Object.freeze([ 'WindowsTerminal', 'wt', 'OpenConsole', 'conhost', 'cmd', 'powershell', 'pwsh', 'mintty', 'alacritty', 'wezterm-gui', 'Hyper', 'Tabby', 'Warp' ]) /** * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. * * 개인 문구 코퍼스는 사용자의 자연어 문체를 배우는 곳이다. 코드와 에이전트에게 * 보낸 개발 지시가 섞이면 카카오톡에서도 개발 문장이 제안된다(실측: 코퍼스 138개 중 * 대부분이 터미널·Agent Switchboard 발). 제안 자체는 에디터/에이전트에서도 허용한다. */ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([ ...TERMINAL_APPS, 'Code', 'Code - Insiders', 'Cursor', 'Windsurf', 'Antigravity', 'Zed', 'devenv', 'idea64', 'pycharm64', 'webstorm64', 'rider64', 'clion64', 'goland64', 'studio64', 'sublime_text', 'Agent Switchboard' ]) /** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */ const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u /** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ const MIN_LETTER_RATIO = 0.6 /** * 개인 코퍼스에 넣어도 되는 문장인가. * * 문장이 아닌 것(타임스탬프 `5 분 5`, 상태줄 `00 ◷9`, 박스 선)을 거른다. * 앱 단위 제외(LEARNING_EXCLUDED_APPS)와 별개로 모든 출처에 적용한다. */ export function isLearnablePhrase(text: string): boolean { if (NON_PROSE_GLYPH_PATTERN.test(text)) return false const compact = text.replace(/\s+/gu, '') if (compact.length < 2) return false const letters = compact.match(/\p{L}/gu)?.length ?? 0 return letters / compact.length >= MIN_LETTER_RATIO } /** * 빈 입력창의 안내 문구(placeholder)를 텍스트로 돌려주는 제공자가 있다 * (실측: KakaoTalk "메시지 입력", ChatGPT "ChatGPT에 메시지 보내기" 가 친 글로 학습됨). * 텍스트가 컨트롤 이름과 같으면 빈 칸으로 취급한다. */ export function withoutPlaceholderText(snapshot: FocusSnapshot): FocusSnapshot { const name = snapshot.controlName?.trim() if (!name || snapshot.text.trim() !== name) return snapshot return { ...snapshot, text: '', caretOffset: null } } /** * 프롬프트에 넣을 컨텍스트 길이 상한. * * 길이가 곧 로컬 추론 지연이므로 짧게 유지한다. */ export const SUGGESTION_CONTEXT_MAX_CHARS = 400 /** 후보 문자열 길이 상한. */ export const SUGGESTION_MAX_OUTPUT_CHARS = 160 /** * 모델 출력에서 후보 목록을 뽑아 정제한다. * * gemma 계열 소형 모델은 번호/따옴표/머리말을 붙이기 쉬우므로 여기서 걷어낸다. * 접두를 그대로 되풀이하는 후보는 버린다 (ghost text 로 쓸 수 없음). */ export function parseSuggestionCandidates( raw: string, prefix: string, maxCandidates = 3, maxChars = SUGGESTION_MAX_OUTPUT_CHARS ): string[] { if (!raw) return [] const prefixTail = prefix.replace(/\s+$/u, '').slice(-24).toLowerCase() const out: string[] = [] for (const line of raw.split(/\r?\n/u)) { const cleaned = sanitizeSuggestionLine(line, maxChars) if (!cleaned) continue if (prefixTail && cleaned.toLowerCase().startsWith(prefixTail)) continue if (out.some((existing) => existing.toLowerCase() === cleaned.toLowerCase())) continue out.push(cleaned) if (out.length >= maxCandidates) break } return out } /** 한 줄 정제: 번호/불릿/따옴표/마크다운 제거 + 길이 제한 + 접두 반복 제거. */ export function sanitizeSuggestionLine(line: string, maxChars = SUGGESTION_MAX_OUTPUT_CHARS): string | null { let text = line.trim() if (!text) return null text = text.replace(/^[-*•\d]+[.)\]]?\s+/u, '') text = text.replace(/^["'“”‘’`]+|["'“”‘’`]+$/gu, '') text = text.replace(/\s+/gu, ' ').trim() if (text.length < 2) return null // 모델이 지시문을 되풀이한 경우 방어 (llm-prompts 회귀와 같은 부류) if (/^(suggestion|completion|candidate|output|answer|다음 문장)\s*[::]/iu.test(text)) return null if (/^\{\{.*\}\}$/u.test(text)) return null if (text.length > maxChars) { text = text.slice(0, maxChars) const lastSpace = text.lastIndexOf(' ') if (lastSpace > maxChars * 0.6) text = text.slice(0, lastSpace) text = text.trim() } return text.length >= 2 ? text : null } // ============================================================ // 집계 버킷 / 리포트 // ============================================================ /** 카운터 묶음. DB `input_activity` 한 행(시간·앱 단위)과 1:1 대응. */ export interface InputActivityBucket { keystrokes: number shortcuts: number backspaces: number clicks: number doubleClicks: number scrollTicks: number mouseDistancePx: number chars: number words: number sentences: number activeMs: number } export function emptyActivityBucket(): InputActivityBucket { return { keystrokes: 0, shortcuts: 0, backspaces: 0, clicks: 0, doubleClicks: 0, scrollTicks: 0, mouseDistancePx: 0, chars: 0, words: 0, sentences: 0, activeMs: 0 } } export function mergeActivityBucket(target: InputActivityBucket, delta: Partial): void { for (const key of Object.keys(target) as Array) { target[key] += delta[key] ?? 0 } } export interface InputDailyStat { date: string keystrokes: number clicks: number words: number sentences: number mouseDistancePx: number activeMs: number } export interface InputHourlyStat { hour: number keystrokes: number clicks: number chars: number backspaces: number activeMs: number } export interface InputAppStat { appName: string keystrokes: number clicks: number activeMs: number } /** 제안(ghost text) 지표 — 수락률/지연은 모델·설정 판단 근거. */ export interface InputSuggestionStats { total: number accepted: number /** 0~1 */ acceptRate: number avgLatencyMs: number | null } /** 앱별 제안 품질 — 전역 통계와 같은 산식으로 집계한다. */ export interface InputAppSuggestionStat extends InputSuggestionStats { appName: string } /** 편집 되돌림 비율. 원문은 저장하지 않고 수량만 쓴다. */ export interface InputFrictionInsight { /** 입력·되돌림 전체에서 되돌림이 차지하는 비율 (0~1) */ rate: number /** 입력 문자 100자당 되돌림 횟수 */ editsPer100Chars: number band: 'steady' | 'watch' | 'high' } /** 시간대별 입력 밀도와 편집 안정성으로 계산한 로컬 플로우 신호. */ export interface InputFlowWindow { hour: number score: number /** 기록일 기준 하루 평균 활성 분 */ activeMinutes: number chars: number frictionRate: number } /** 로컬에 어떤 데이터가 남는지 보여 주는 수집 영수증. */ export interface InputPrivacyReceipt { localOnly: true rawKeyContentStored: false retention: { activityDays: number typingSamplesDays: number suggestionDays: number personalPhrases: 'until-deleted' } counts: { activityBuckets: number typingSamples: number personalPhrases: number suggestions: number } } export interface InputInsightsSummary { /** 조회 구간 (일) */ days: number totals: InputActivityBucket daily: InputDailyStat[] topHours: InputHourlyStat[] topApps: InputAppStat[] phraseCount: number sampleCount: number /** 일 평균 */ averages: { keystrokes: number clicks: number words: number sentences: number chars: number backspaces: number shortcuts: number scrollTicks: number mouseDistanceMeters: number activeMinutes: number } /** 0~23 전체 시간대 분포 (topHours 는 상위 6개만) */ hourly: InputHourlyStat[] /** 기록이 있는 날 수 */ activeDays: number /** 연속 기록 일수 (최장) */ longestStreakDays: number /** 가장 많이 친 날 */ peakDay: InputDailyStat | null /** 제안 지표 */ suggestions: InputSuggestionStats /** 전체 편집 되돌림 신호 */ friction: InputFrictionInsight /** 시간대별 로컬 플로우 신호 상위 항목 */ flowWindows: InputFlowWindow[] /** 앱별 제안 품질 */ suggestionApps: InputAppSuggestionStat[] } /** 되돌림이 전체 입력에서 차지하는 비율을 사람이 읽을 수 있는 등급으로 바꾼다. */ export function calculateFrictionInsight(chars: number, backspaces: number): InputFrictionInsight { const safeChars = Math.max(0, chars) const safeBackspaces = Math.max(0, backspaces) const total = safeChars + safeBackspaces const rate = total === 0 ? 0 : safeBackspaces / total const band = rate < 0.08 ? 'steady' : rate < 0.18 ? 'watch' : 'high' return { rate, editsPer100Chars: Number(((safeBackspaces / Math.max(1, safeChars)) * 100).toFixed(1)), band } } /** 기록일 기준 시간대별 입력 밀도와 안정성을 점수화한다. */ export function rankFlowWindows( hourly: readonly InputHourlyStat[], activeDays: number, limit = 3 ): InputFlowWindow[] { const safeDays = Math.max(1, activeDays) const safeLimit = Math.max(0, Math.floor(limit)) if (safeLimit === 0) return [] return hourly .filter((entry) => entry.keystrokes > 0 || entry.clicks > 0 || entry.chars > 0 || entry.backspaces > 0 || entry.activeMs > 0 ) .map((entry) => { const activeMs = Math.max(0, entry.activeMs) const chars = Math.max(0, entry.chars) const friction = calculateFrictionInsight(chars, entry.backspaces) const activityDensity = Math.min(1, activeMs / (safeDays * 60 * 60 * 1000)) const characterDensity = Math.min(1, chars / (safeDays * 1200)) return { hour: entry.hour, score: Math.round((activityDensity * 0.45 + characterDensity * 0.4 + (1 - friction.rate) * 0.15) * 100), activeMinutes: activeMs / safeDays / 60000, chars, frictionRate: friction.rate, activeMs } }) .sort((a, b) => b.score - a.score || b.activeMs - a.activeMs || a.hour - b.hour) .slice(0, safeLimit) .map(({ activeMs: _activeMs, ...window }) => window) } /** 사람이 읽는 거리 단위 (m). */ export function pixelsToMeters(px: number, dpiScale = 1): number { const DPI = 96 * (dpiScale || 1) const METERS_PER_INCH = 0.0254 return (px / DPI) * METERS_PER_INCH } /** 리포트 평균 계산 (순수). */ export function summarizeActivity( totals: InputActivityBucket, days: number, dpiScale = 1 ): InputInsightsSummary['averages'] { const safeDays = Math.max(1, days) return { keystrokes: Math.round(totals.keystrokes / safeDays), clicks: Math.round(totals.clicks / safeDays), words: Math.round(totals.words / safeDays), sentences: Math.round(totals.sentences / safeDays), chars: Math.round(totals.chars / safeDays), backspaces: Math.round(totals.backspaces / safeDays), shortcuts: Math.round(totals.shortcuts / safeDays), scrollTicks: Math.round(totals.scrollTicks / safeDays), mouseDistanceMeters: Number((pixelsToMeters(totals.mouseDistancePx, dpiScale) / safeDays).toFixed(2)), activeMinutes: Math.round(totals.activeMs / 60000 / safeDays) } } // ============================================================ // 개인 문구 · 제안 상태 // ============================================================ export type PhraseSource = 'typed' | 'voice' | 'suggestion' | 'clipboard' export interface PersonalPhrase { id: string phrase: string count: number source: PhraseSource appName: string | null lastUsedAt: number | null createdAt: number } export interface PhraseHintOptions { appName?: string | null now?: number halfLifeDays?: number } /** * 문장에서 개인화 후보 문구를 는다. * * 문장 종결 부호로 쪼 뒤 공백 정규화, 최소 길이/단어 수 필터. * 학습은 사용자가 명시 동의한 경우에만 호출된다. */ export function extractPhrases(text: string, maxPhrases = 4, minChars = 6): string[] { const out: string[] = [] const segments = text.split(/[.!?。!?…\n]+/u) for (const segment of segments) { const phrase = segment.replace(/\s+/gu, ' ').trim() if (phrase.length < minChars) continue if (phrase.length > SUGGESTION_MAX_OUTPUT_CHARS) continue if (countWords(phrase) < 2) continue if (out.includes(phrase)) continue out.push(phrase) if (out.length >= maxPhrases) break } return out } /** 프롬프트에 넣을 개인 문구 선택 — 최근 사용 + 길이 적합 순. */ export function selectPhraseHints( phrases: readonly PersonalPhrase[], prefix: string, limit = 5, options: PhraseHintOptions = {} ): string[] { const tail = prefix.replace(/\s+$/u, '').toLowerCase() const now = Number.isFinite(options.now) ? (options.now as number) : Date.now() const halfLifeDays = Number.isFinite(options.halfLifeDays) && (options.halfLifeDays as number) > 0 ? (options.halfLifeDays as number) : 30 const requestedApp = options.appName?.trim().toLowerCase() ?? null const candidates = phrases .filter((p) => p.phrase.length <= SUGGESTION_MAX_OUTPUT_CHARS) .filter((p) => !tail || !p.phrase.toLowerCase().endsWith(tail)) .map((phrase) => { const timestamp = phrase.lastUsedAt ?? phrase.createdAt const ageDays = Math.max(0, (now - timestamp) / (24 * 60 * 60 * 1000)) const appMatches = requestedApp !== null && phrase.appName !== null && phrase.appName.trim().toLowerCase() === requestedApp const score = (1 + Math.log2(Math.max(1, phrase.count))) * Math.pow(0.5, ageDays / halfLifeDays) * (appMatches ? 1.75 : 1) return { phrase, score, timestamp } }) .sort( (a, b) => b.score - a.score || b.phrase.count - a.phrase.count || b.timestamp - a.timestamp || a.phrase.id.localeCompare(b.phrase.id) ) return candidates.slice(0, Math.max(0, Math.floor(limit))).map(({ phrase }) => phrase.phrase) } export interface SuggestionCandidate { text: string /** 0 = 최상위 */ rank: number } /** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */ export interface SuggestionProvenance { mode: 'local-model' | 'local-memory' continuationCount: number relatedCount: number phraseCount: number appPhraseCount: number } export interface LocalSuggestionHints { continuationHints: readonly string[] relatedHints: readonly string[] phraseHints: readonly string[] } function suffixPrefixOverlap(prefix: string, candidate: string): number { const normalizedPrefix = prefix.toLowerCase() const normalizedCandidate = candidate.toLowerCase() const maximum = Math.min(normalizedPrefix.length, normalizedCandidate.length) for (let length = maximum; length >= 2; length -= 1) { if (normalizedPrefix.slice(-length) === normalizedCandidate.slice(0, length)) return length } return 0 } /** * 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다. * 모델 실패를 성공처럼 숨기지 않고, 호출부가 provenance.mode 로 출처를 명시한다. */ export function buildLocalSuggestionCandidates( prefix: string, hints: LocalSuggestionHints, limit = 3, maxChars = SUGGESTION_MAX_OUTPUT_CHARS ): string[] { const safeLimit = Math.max(0, Math.floor(limit)) const safeMaxChars = Math.max(0, Math.floor(maxChars)) if (safeLimit === 0 || safeMaxChars < 2) return [] const normalizedPrefix = prefix.replace(/\s+$/u, '') const normalizedPrefixLower = normalizedPrefix.toLowerCase() const acceptsWholePhrase = endsSentence(normalizedPrefix) const out: string[] = [] const append = (candidate: string | null): void => { if (candidate === null || candidate.length === 0) return if (candidate.toLowerCase() === normalizedPrefixLower) return if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return out.push(candidate) } for (const raw of hints.continuationHints) { if (out.length >= safeLimit) break const candidate = sanitizeSuggestionLine(raw, safeMaxChars) if (candidate === null) continue if (normalizedPrefixLower && candidate.toLowerCase().startsWith(normalizedPrefixLower)) continue append(candidate) } for (const source of [hints.relatedHints, hints.phraseHints]) { for (const raw of source) { if (out.length >= safeLimit) break const candidate = sanitizeSuggestionLine(raw, safeMaxChars) if (candidate === null) continue const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) if (overlap >= 2) { append(sanitizeSuggestionLine(candidate.slice(overlap), safeMaxChars)) } else if (acceptsWholePhrase) { append(candidate) } } } return out } export interface SuggestionState { enabled: boolean modelId: string | null modelAvailable: boolean visible: boolean /** 생성 중(후보 도착 전) — 오버레이가 로딩 상태를 보여주는 근거 */ generating: boolean /** 모델을 메모리에 올리는 중 — 오버레이가 "준비 중" 을 보여주는 근거 */ warmingUp: boolean /** 스트리밍 중인 부분 텍스트 — 도착하는 대로 오버레이에 흘려보낸다 */ partialText: string | null candidates: SuggestionCandidate[] activeIndex: number /** * 이 세션이 채우려는 후보 총량 — 모델 세션은 maxCandidatesTotal(12), * 로컬 기억 세션은 더 생성되지 않으므로 현재 candidates 수와 같다. * UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거. */ targetTotal: number /** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */ anchor: UiRect | null /** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */ anchorKind: AnchorKind appName: string | null updatedAt: number lastSkipReason: SuggestionSkipReason | null requestsToday: number dailyBudget: number /** 마지막 생성 지연 (ms). 하드웨어/모델이 감당 가능한지 UI 가 판단하는 근거 */ lastLatencyMs: number | null /** 실제 적용 중인 값 (설정 파일에 굳은 값 포함) — 설정 UI 가 이걸 표시한다 */ triggerDelayMs: number minPrefixChars: number /** 응답 제한 (ms) — 넘기면 이번 요청을 버린다 (모델이 느린 하드웨어 보호) */ requestTimeoutMs: number /** 연속 실패로 잠시 쉬는 중인가 */ coolingDown: boolean /** 학습 동의 상태 요약 (UI 표시) */ learnTypedText: boolean telemetryEnabled: boolean /** 오버레이 클릭 허용 여부 (false → 완전 클릭 통과) */ overlayInteractive: boolean /** 후보를 만든 로컬 근거의 수량만 노출한다. */ provenance: SuggestionProvenance | null } /** 제안 기본값 — 인라인 컴플리션 실측 중앙값 기준. */ export const SUGGESTION_DEFAULTS = { /** * 타이핑 정지 후 요청까지 (ms). * * 결과는 스트리밍으로 흘려보내되, 로컬 모델 요청이 입력 중 반복되는 것을 막기 위해 * 자동 요청 자체는 보수적으로 600ms를 기다린다. */ triggerDelayMs: 600, /** * 제안 요청 최소 접두 길이. * * CJK 는 공백이 없어 12자면 너무 늦으므로 8자를 기본으로 쓴다. 라틴 문자도 * 8자면 충분히 맥락이 생긴다. Continue 의 selectedCompletionInfo 하한(4자)보다는 * 보수적이다. */ minPrefixChars: 8, minIntervalMs: 5000, maxRequestsPerMinute: 6, dailyBudget: 500, /** 로컬 기억 경로(폴백)에서 한 번에 만드는 후보 수 — 채우기 루프가 없다. */ maxCandidates: 3, /** * 한 세션(모델 경로)이 채우기 루프로 쌓을 수 있는 후보 총량. * * 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다. */ maxCandidatesTotal: 12, maxOutputTokens: 64, /** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */ regenerateAfterChars: 12, temperature: 0.3, /** * 응답 제한 (ms). * * 로컬 모델 큐 지연이 사용자 입력 경험을 오래 막지 않도록 제한한다. 시간 초과된 * 요청은 버리고, 다음 정상 타이핑 문맥에서 정책 게이트를 다시 통과해야 한다. */ requestTimeoutMs: 8000, /** * 표시된 뒤 이 시간 동안 갱신이 없으면 스스로 사라진다. * * 오버레이는 "다음 문맥" 이 와야 지워지는데, 타이핑을 멈추면 문맥이 오지 않아 * 창이 무한정 남는다(실측 신고: 아무것도 안 치는데 제안창이 떠 있음). */ visibleTtlMs: 20000, /** * 요청 시작 후 이 시간이 지나면 결과를 폐기한다. * * 타이핑을 멈추고 다른 곳으로 이동한 뒤 늦게 도착한 제안이 표시되던 문제를 막는다. */ resultMaxStalenessMs: 6000, /** * 사용자가 X 로 닫은 뒤 이 시간 동안은 다시 띄우지 않는다. * * 닫아도 입력이 조금만 바뀌면 곧바로 새 생성이 시작돼 "X 가 안 먹는 것처럼" * 보였다(실측 신고). 사람이 명시적으로 거부한 뒤에는 잠깐 조용해야 한다. */ userDismissQuietMs: 10000, /** 연속 실패가 이 횟수에 도달하면 잠시 요청을 멈춘다 */ failureCooldownThreshold: 2, /** 쿨다운 시간 (ms) */ failureCooldownMs: 60000, /** * 이 시간 안에 실제 타이핑(letter/digit/symbol/space/backspace/delete/ime)이 * 있어야 "지금 타이핑 중" 으로 본다. * * 마우스로 필드에 들어오기만 해도 (마지막 키 입력은 오래전이라) 유휴 조건을 * 통과해 필드에 이미 있던 텍스트로 제안이 뜨던 문제를 막는다. */ recentTypingWindowMs: 8000 } as const /** * IME 조합 중 트리거 지연 배수. * * 조합 중에는 손을 멈춘 것으로 보이려면 기본 지연의 이 배수만큼 조용해야 한다. */ export const COMPOSING_IDLE_MULTIPLIER = 1.5 /** * 접두가 이만큼 더 자라면 재생성한다. * * 실제 규칙은 "사용자가 멈췄고 내용이 바뀌었으면 재생성" 이고, 이 값은 그보다 * 훨씬 큰 성장이 있었을 때의 표현일 뿐이다(문서/테스트 기준값). */ export const SUGGESTION_REGENERATE_GROWTH_CHARS = 12 /** * 문맥 기억 조회에 쓰는 접두 꼬리 길이. * * 과거에 같은 꼬리 뒤에 무엇을 이어 썼는지 찾는 열쇠다(개인 n-gram 관계). */ export const SUGGESTION_MEMORY_TAIL_CHARS = 14 /** 접두의 마지막 n자를 꼬리로 뽑는다 (공백 정규화 포함). */ export function prefixTail(prefix: string, length = SUGGESTION_MEMORY_TAIL_CHARS): string { const normalized = prefix.replace(/\s+/gu, ' ').trim() if (normalized.length <= length) return normalized return normalized.slice(-length) } export const INPUT_TELEMETRY_DEFAULTS = { flushIntervalMs: 5000, activeWindowSampleMinIntervalMs: 1000, /** 마지막 입력 후 이 시간이 지나면 활성 시간 누적을 멈춘다 */ activeIdleTimeoutMs: 60000, /** 텍스트 스냅샷(학습/컨텍스트) 디바운스 */ textSnapshotDebounceMs: 700, /** 백그라운드 샘플 주기 (ms) — 타이핑이 이어져도 관측을 멈추지 않는다 */ sampleIntervalMs: 800, /** 마지막 입력이 이 시간 안에 있었으면 주기 샘플을 돌린다 (ms) */ sampleActiveWindowMs: 5000, retentionDays: 30 } as const