// 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 의존 금지 (테스트 가능성 유지). // // 후보 텍스트 정제는 suggestion-text.ts, 터미널 입력 줄 파싱은 terminal-prompt.ts 가 // 정본이다. 기존 import 경로(@d3ro/core/input-intelligence)를 유지하려고 여기서 다시 export 한다. import { SENTENCE_TERMINATORS, SUGGESTION_MAX_OUTPUT_CHARS, normalizeRequestPrefix, normalizeSessionPrefix, type SuggestionJoint } from './suggestion-text' import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt' import type { AnchorKind, PhysicalRect } from './popup-placement' import { countLexicalWords } from './text-segmentation' export { SUGGESTION_CONTEXT_MAX_CHARS, SUGGESTION_MAX_OUTPUT_CHARS, buildLocalSuggestionCandidateEntries, buildLocalSuggestionCandidates, endsSentence, joinSuggestion, normalizeRequestPrefix, normalizeSessionPrefix, normalizeSuggestionText, parseSuggestionCandidates, sanitizeSuggestionLine, stripModelArtifacts } from './suggestion-text' export type { LocalSuggestionCandidate, LocalSuggestionHints, SuggestionJoint } from './suggestion-text' export { extractTerminalPromptPrefix } from './terminal-prompt' // ============================================================ // 기하 — 정본은 popup-placement.ts (좌표 공간 · 배치 정책). 기존 import 경로를 유지한다. // ============================================================ export { anchorFloatingPanel, anchorFloatingPanelInDips, asDipPoint, asDipRect } from './popup-placement' export type { AnchorKind, DipPoint, DipRect, PhysicalRect, ScreenGeometryPort, UiRect } from './popup-placement' /** 마우스 이동 누적 전에 무시할 최소 이동량(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) } // ============================================================ // 키스트로크 분류 (내용이 아니라 '종류'만 남긴다) // ============================================================ 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' } // ============================================================ // 스트 지표 // ============================================================ /** * 단어 수. * * 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다. * (중국어처럼 공백이 없는 언어는 과소 집계된다 — 활동 통계 전용. 학습 필터는 * text-segmentation.ts 의 countLexicalWords 로 센다) */ 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 } /** 케어 앞 텍스트만 잘라낸다 (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 /** 물리 픽셀 (UIA 는 DPI-aware 로 동작한다) — 배치 전에 ScreenGeometryPort 로 DIP 변환 */ caretRect: PhysicalRect | null elementRect: PhysicalRect | 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 검색창 // 클릭만 했는데 옛 검색어로 제안이 뜸). // // 단, 이미 떠 있는 제안은 지우지 않는다. 사용자가 타이핑을 멈추는 순간은 바로 // 후보를 읽고 고르는 순간이다 — 여기서 지우면 클릭·단축키 수락이 받을 후보가 // 없어 "골라도 안 들어가는" 상태가 된다(실측 2026-09-27: 후보 표시 2초 뒤 // not-typing 으로 닫힘). 새 요청만 막는다. if (!input.editedSinceFocus || !input.typedRecently) { if (input.overlayVisible) return { action: 'skip', reason: 'already-visible' } return { action: 'clear', reason: 'not-typing' } } const prefix = normalizeRequestPrefix(input.prefix) 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 = normalizeRequestPrefix(generatedPrefix) const current = normalizeRequestPrefix(currentPrefix) if (current.startsWith(generated)) return true return generated.length > 0 && current.startsWith(generated.slice(0, -1)) } /** * 표시 중인 제안 세션(페이지 넘기며 보는 후보 목록)이 여전히 이 접두에 유효한지. * * `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다" * 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다 * (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다). * * 세션은 마지막 SUGGESTION_CONTEXT_MAX_CHARS 자로 만든 후보이므로 양쪽 모두 * normalizeSessionPrefix 로 맞춘 뒤 비교한다 — 한쪽만 자르면 400자를 넘는 입력창에서 * 텍스트가 그대로여도 세션이 stale 로 닫힌다. */ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean { const generated = normalizeSessionPrefix(generatedPrefix) const current = normalizeSessionPrefix(currentPrefix) if (current === generated) return true if (generated.length === 0) return false return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1) } /** * 진행 중인 생성(아직 후보 없음)을 계속 둘지 결정한다 — 세션 규칙과 같은 기준이다. * * 결과는 요청한 접두로 게시되고, 게시된 세션은 성장을 허용하지 않는다(matchesSessionPrefix). * 그래서 요청 뒤 사용자가 몇 글자라도 더 쳤다면 결과는 도착하자마자 stale 로 닫힌다 — * 깜빡임 · 버려지는 모델/채우기 요청 · 옛 접두용 문장 수락을 막으려면 그 즉시 취소한다. * IME 마지막 글자 조합 변화만 예외다. */ export function decideInFlightGeneration(requestedPrefix: string, currentPrefix: string): 'keep' | 'stale' { return matchesSessionPrefix(requestedPrefix, currentPrefix) ? 'keep' : 'stale' } /** * 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다. * * 현재 접두가 생성 접두의 연속 확장이 아니면 이미 표시된 후보는 다른 문맥의 * 결과이므로 stale 이다. 연속 확장도 일정량 이상일 때만 재생성해 입력 중인 * 로컬 모델 요청이 반복되는 것을 막는다. */ export function decideSuggestionRefresh( generatedPrefix: string, currentPrefix: string, minimumGrowth = SUGGESTION_DEFAULTS.regenerateAfterChars ): SuggestionRefreshDecision { const generated = normalizeRequestPrefix(generatedPrefix) const current = normalizeRequestPrefix(currentPrefix) if (!generated) return 'regenerate' if (!extendsPrefix(generated, current)) return 'stale' return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep' } /** * 한 번의 입력 문맥에 대해 제안 서비스가 할 일 (판별 합집합). * * - `end-session`: 표시 중인 세션의 접두가 어긋났다 — 세션을 stale 로 닫는다. * - `generate`: 모델 생성을 시작한다. * - `settle`: 생성하지 않는다. `reason` 을 남기고, `tryLocalMemory` 면 로컬 기억 후보를 * 먼저 게시해 보고, 게시하지 못하면 `then` 을 수행한다. */ export type SuggestionStep = | { action: 'end-session'; reason: 'stale' } | { action: 'generate'; prefix: string } | { action: 'settle' reason: SuggestionSkipReason tryLocalMemory: boolean /** * record: 사유만 남긴다. dismiss: 표시 중이면 이 사유로 닫는다. * show-warming: "준비 중" 을 보여준다. warm-up: 워밍업을 시작하고 "준비 중" 을 보여준다. */ then: 'record' | 'dismiss' | 'show-warming' | 'warm-up' } export interface SuggestionStepInput { /** 정책 입력. overlayVisible 은 "생성/워밍업/후보 표시 중" 전체를 뜻한다. */ policy: SuggestionPolicyInput /** 후보 세션이 화면에 떠 있는가 (후보 1개 이상) */ sessionVisible: boolean /** 세션을 만든 접두 */ sessionPrefix: string /** 연속 실패 쿨다운 중인가 */ coolingDown: boolean /** 사용자가 X 로 닫은 직후의 조용한 구간인가 */ userDismissedQuiet: boolean /** 마지막으로 모델에 실제 요청한 접두 (normalizeRequestPrefix 적용) */ lastRequestedPrefix: string /** 지금 쓸 모델이 메모리에 떠 있다고 볼 수 있는가 */ modelWarm: boolean /** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */ warmUpBackoff: boolean /** * 모델 서버를 실제로 준비하는 작업(워밍업 루프)이 진행 중인가. * * 서버가 없을 때(model-unavailable) "준비 중" 은 이 작업이 있을 때만 보여준다 — 아무것도 * 준비하지 않는데 스피너를 띄우면 끝나지 않는 "준비 중" 이 옛 캐럿 위에 20초씩 반복된다 * (Ollama 미설치/중지). 생략하면 예전처럼 "준비 중" 을 보여준다(호환). 서비스는 항상 넘긴다. */ modelStarting?: boolean } /** * 제안 서비스의 판단 순서 정본: 세션 유효성 → 쿨다운 → 사용자 닫기 → 정책 → 모델 온기. * * 서비스는 이 결과를 실행만 한다. 쿨다운 경로도 decideSuggestion 의 'already-visible' * 규칙을 거친다 — 쿨다운 중 표시된 로컬 기억 세션을 매 스냅샷마다 다시 만들거나 * (선택 초기화 · 이력 중복) 닫지 않는다. */ export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep { const { policy } = input if (input.sessionVisible && !matchesSessionPrefix(input.sessionPrefix, policy.prefix)) { return { action: 'end-session', reason: 'stale' } } // 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. const localMemoryAllowed = decideSuggestion({ ...policy, modelAvailable: true, overlayVisible: false }).action === 'request' if (input.coolingDown) { const presentation = decideSuggestion({ ...policy, modelAvailable: true }) if (presentation.action === 'skip' && presentation.reason === 'already-visible') { return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' } } return { action: 'settle', reason: 'cooldown', tryLocalMemory: localMemoryAllowed, then: 'dismiss' } } if (input.userDismissedQuiet) { return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' } } const decision = decideSuggestion(policy) if (decision.action === 'clear') { return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'dismiss' } } if (decision.action === 'skip') { // 같은 텍스트로 이미 요청했다면 다시 만들지 않는다 (클릭만 했거나, 엔터로 보낸 뒤 // UIA 가 옛 텍스트를 돌려줄 때 제안이 반복 생성되던 문제). const current = normalizeRequestPrefix(policy.prefix) if (input.lastRequestedPrefix && current === input.lastRequestedPrefix) { return { action: 'settle', reason: 'unchanged', tryLocalMemory: false, then: 'record' } } if (decision.reason === 'model-unavailable') { return { action: 'settle', reason: 'model-unavailable', tryLocalMemory: localMemoryAllowed, // 준비 작업이 없으면 기능은 꺼진 것처럼 조용해야 한다 (설정 문구: "제안이 꺼진 상태로 동작"). then: input.modelStarting === false ? 'record' : 'show-warming' } } return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' } } // 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다 — 콜드 리로드는 // 요청 타임아웃보다 길어 그대로 요청하면 항상 시간 초과한다. if (!input.modelWarm) { if (input.warmUpBackoff) { return { action: 'settle', reason: 'generation-failed', tryLocalMemory: localMemoryAllowed, then: 'record' } } return { action: 'settle', reason: 'model-unavailable', tryLocalMemory: localMemoryAllowed, then: 'warm-up' } } return { action: 'generate', prefix: decision.prefix } } /** 제외 앱 판정 — 대소문자 무시, 실행 파일명(확장자 무관) 부분 일치. */ 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 ▕░░▏` 가 학습됨). * 그래서 학습에서는 뺀다. 제안은 화면에서 입력 줄만 골라 쓴다(extractTerminalPromptPrefix). */ export const TERMINAL_APPS: readonly string[] = Object.freeze([ 'WindowsTerminal', 'wt', 'OpenConsole', 'conhost', 'cmd', 'powershell', 'pwsh', 'mintty', 'alacritty', 'wezterm-gui', 'Hyper', 'Tabby', 'Warp' ]) export function isTerminalApp(appName: string | null | undefined): boolean { return !!appName && isAppExcluded(appName, TERMINAL_APPS) } /** * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. * * 개인 문구 코퍼스는 사용자의 자연어 문체를 배우는 곳이다. 코드와 에이전트에게 * 보낸 개발 지시가 섞이면 카카오톡에서도 개발 문장이 제안된다(실측: 코퍼스 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' ]) /** * 제안 이력에 입력 문맥(접두) 원문을 남겨도 되는가. * * 접두는 사용자가 친 글 그 자체다 — 학습 동의(inputLearnTypedText)가 있고, 학습 제외 앱 * (터미널·에디터·에이전트 허브)이 아닐 때만 저장한다. 텔레메트리 학습과 같은 규칙을 쓴다. * 그 밖에는 원문 대신 빈 문자열을 남겨 이력 개수·수락률 통계만 유지한다. */ export function shouldPersistSuggestionPrefix(input: { learnTypedText: boolean appName: string | null }): boolean { if (!input.learnTypedText) return false return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS)) } /** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ 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 } } // ============================================================ // 집계 버킷 / 리포트 // ============================================================ /** 카운터 묶음. 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 // 띄어쓰기 없는 문자 체계(중·일·태)도 문구로 인정한다 — 공백 단어 수로는 항상 1이다. if (countLexicalWords(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 /** 접두와의 이음새 — 생략하면 'separate' (joinSuggestion 참고) */ joint?: SuggestionJoint } /** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */ export interface SuggestionProvenance { mode: 'local-model' | 'local-memory' continuationCount: number relatedCount: number phraseCount: number appPhraseCount: number } 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 — 물리 픽셀 (케어렛 → 요소 → 마우스 폴백은 메인이 계산, DIP 변환은 창 배치 한 곳에서) */ anchor: PhysicalRect | 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, /** * 멈춤 판단의 하한. 이보다 짧으면 글자 사이의 순간 멈춤에도 생성이 시작돼, * 치는 도중 창이 떴다 사라지기를 반복한다(실측 2026-09-27: 100ms 설정에서 * 5~6초마다 생성→소멸 반복). */ minTriggerDelayMs: 500, 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