1301 lines
48 KiB
TypeScript
1301 lines
48 KiB
TypeScript
// 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
|