d3ro-voice/packages/core/src/input-intelligence.ts

1301 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<InputActivityBucket>): void {
for (const key of Object.keys(target) as Array<keyof InputActivityBucket>) {
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