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