d3ro-voice/packages/core/src/input-intelligence.ts
Yun Chan 4b0f685941 feat(caption): let live captions use their own speech model
The speech engine now keeps an auxiliary model next to the dictation model
and transcribes with whichever the request names, reloading it once if the
engine restarted. Settings > STT gains a live-caption model so captions can
run on large-v3-turbo while dictation keeps its own model. The runtime
minimum rises to 1.7.0 because older engines would silently ignore the
model choice.

Suggestion paging moves to Up/Down: the page follows the selection and the
last item waits while more candidates are being generated. The Left/Right
page shortcuts are removed; they did nothing until a page had filled and
clash with Intel's display-rotation hotkeys.
2026-09-24 21:46:54 +09:00

1324 lines
47 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 의존 금지 (테스트 가능성 유지).
// ============================================================
// 기하
// ============================================================
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<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
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