// src/main/services/SuggestionService.ts // // 다음 문장 제안(ghost text) — 입력 맥락을 받아 gemma(Ollama)로 후보를 만들고, // 수락 시 대상 앱에 삽입한다. // // 설계 근거 (2025~2026 인라인 컴플리션 실측): // - 디바운스는 타이핑 정지 후 600ms(기본)로, 모델 호출 빈도를 보수적으로 제한한다. // - 출력 토큰은 극단적으로 작게(기본 64, Tabby 64 / KeyType 4~16) — // 인라인 제안은 스트리밍으로 빨리 보여주고 짧게 끊는 것이 정석이다. // - 문맥이 stale 이 된 진행 중 생성만 호출자 시그널로 취소하고, 다음 입력 문맥에서 재평가한다. // - 후보가 나온 접두와 현재 접두가 달라지면 그 즉시 오버레이를 지운다 (stale). import { EventEmitter } from 'events' import { SUGGESTION_DEFAULTS, SUGGESTION_MAX_OUTPUT_CHARS, buildLocalSuggestionCandidates, decideSuggestion, decideSuggestionRefresh, decideSuggestionStep, isAppExcluded, normalizeRequestPrefix, normalizeSessionPrefix, parseSuggestionCandidates, sanitizeSuggestionLine, selectPhraseHints, isTerminalApp, extractTerminalPromptPrefix, type AnchorKind, type SuggestionCandidate, type SuggestionPolicyInput, type SuggestionProvenance, type SuggestionSkipReason, type SuggestionState, type SuggestionStep, type UiRect } from '@d3ro/core/input-intelligence' import { configGet, configSet } from './ConfigService' import { getLogger } from './LoggerService' import { getLocalLLMService } from './LocalLLMService' import { buildSuggestionPrompt } from './llm-prompts' import { getInputTelemetryService, type TypingContext } from './InputTelemetryService' import { getPersonalGraphService } from './PersonalGraphService' import { getTextInsertService } from './TextInsertService' import { waitForModifiersReleased } from './modifier-state' import { createSqliteSuggestionRepository, type SuggestionHistoryEntry, type SuggestionRecordInput, type SuggestionRepository } from './SuggestionRepository' import { SuggestionBudget } from './suggestion/SuggestionBudget' import { ModelWarmTracker } from './suggestion/ModelWarmTracker' import { getForegroundWindowInfo } from '../utils/win32-foreground' const logger = getLogger('SuggestionService') export type { SuggestionHistoryEntry } /** 지금 포그라운드 창을 알려 주는 포트 — 수락 직전 대상 창 확인에 쓴다. */ export interface SuggestionForegroundProbe { currentWindowHandle(): number | null } /** 수락한 텍스트를 학습 쪽에 알리는 포트. */ export interface SuggestionLearningPort { /** 곧 프로그램이 삽입할 텍스트 — 다음 스냅샷 diff 가 "타이핑" 으로 다시 학습하지 않게 한다. 취소 함수를 돌려준다. */ expectProgrammaticInsert(text: string): () => void /** 수락한 문장을 사용자 문체 표본으로 기록한다 (학습 동의 시에만 저장됨). */ recordAccepted(text: string, meta: { appName: string | null; windowTitle: string | null }): void } /** 서비스 협력자 — 기본값은 getSuggestionService() 가 연결한다 (DIP). */ export interface SuggestionServiceDeps { repository: SuggestionRepository budget: SuggestionBudget warmth: ModelWarmTracker foreground: SuggestionForegroundProbe learning: SuggestionLearningPort } export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps { return { repository: createSqliteSuggestionRepository(), budget: new SuggestionBudget(), warmth: new ModelWarmTracker(), foreground: { currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null }, learning: { expectProgrammaticInsert: (text) => getInputTelemetryService().expectProgrammaticInsert(text), recordAccepted: (text, meta) => getInputTelemetryService().recordExternalText(text, { ...meta, source: 'suggestion' }) } } } /** 설정에서 읽은 정책 값 (SSOT 기본값과 상한 적용 후). */ interface PolicyConfig { triggerDelayMs: number minPrefixChars: number maxRequestsPerMinute: number dailyBudget: number maxCandidates: number maxChars: number excludedApps: string[] } export interface SuggestionRequestResult { ok: boolean reason?: SuggestionSkipReason } interface SuggestionEvents { updated: (state: SuggestionState) => void cleared: (payload: { reason: SuggestionSkipReason }) => void 'state-changed': (state: SuggestionState) => void } interface LocalMemoryContext { continuationHints: string[] relatedHints: string[] phraseHints: string[] appPhraseCount: number } /** 이 길이를 넘는 모델 출력은 버린다 (스트리밍 폭주 방지) */ const MAX_RAW_OUTPUT_CHARS = 1200 /** * 모델을 메모리에 유지하는 시간. * * 콜드 리로드 실측(11.4s)이 요청 타임아웃(8s)보다 길어, 짧은 유휴 유지 시간은 * 유휴 후 첫 요청을 항상 타임아웃시켰다 — 충분히 길게 유지한다. */ const SUGGESTION_KEEP_ALIVE = '10m' const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000 /** keep-alive 만료 직전 여유 — 경계에서 콜드 모델로 요청하지 않는다. */ const SUGGESTION_KEEP_ALIVE_MARGIN_MS = 30_000 /** 워밍업 실패 뒤 같은 모델 재시도를 미루는 시간 — 멈출 때마다 실패 요청을 쏘지 않는다. */ const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs export class SuggestionService extends EventEmitter { private _candidates: SuggestionCandidate[] = [] private _activeIndex = 0 private _anchor: UiRect | null = null /** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */ private _anchorKind: AnchorKind = null private _appName: string | null = null private _windowTitle: string | null = null /** 후보를 만든 시점의 접두 — 접두가 달라지면 stale */ private _generatedForPrefix = '' /** 이 세션이 채우려는 후보 총량 (모델 세션 12 / 로컬 기억 세션은 현재 개수) */ private _targetTotal = 0 private _lastSkipReason: SuggestionSkipReason | null = null private _lastDecisionSignature = '' /** 생성 중 — 후보 도착 전에도 오버레이를 띄운다 */ private _generating = false /** 모델 적재 중 — UI 가 "준비 중" 을 표시한다 */ private _warmingUp = false /** * 생성 세대 토큰. * * 사용자가 제안을 닫거나 새로 시작하면 증가한다. 진행 중이던 요청이 나중에 * 끝나도 토큰이 달라졌으면 결과를 버린다 — "X 로 닫았는데 잠시 뒤 다시 뜨던" * 문제를 막는다. */ private _generationToken = 0 /** 마지막 문맥 수신 시각 — 늦게 도착한 결과를 판단한다 */ private _lastContextAt = 0 /** 표시 수명 타이머 (TTL) */ private _ttlTimer: NodeJS.Timeout | null = null /** 마지막으로 실제 요청한 접두 (같은 텍스트 반복 요청 방지) */ private _lastRequestedPrefix = '' /** 사용자가 X 로 닫은 뒤 재표시를 막는 시각 (ms) */ private _userDismissedUntil = 0 /** 스트리밍 누적 텍스트 */ private _partialText = '' /** 마지막 스트리밍 방출 시각 (IPC 과다 방출 방지) */ private _lastPartialEmitAt = 0 private _lastContext: TypingContext | null = null private _provenance: SuggestionProvenance | null = null /** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */ private _windowHandle: number | null = null /** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */ private readonly _budget: SuggestionBudget /** 모델별 온기 (keep-alive) · 워밍업 실패 기록 */ private readonly _warmth: ModelWarmTracker private readonly _repository: SuggestionRepository private readonly _foreground: SuggestionForegroundProbe private readonly _learning: SuggestionLearningPort private _inFlight = false private _abort: AbortController | null = null /** 채우기 루프(2번째 이후 후보) 진행 중 — _inFlight 와 별개다 (예산/타임아웃 카운트 제외). */ private _filling = false private _fillAbort: AbortController | null = null private _timeoutTimer: NodeJS.Timeout | null = null private _lastLatencyMs: number | null = null private _warmUpPromise: Promise | null = null private _warmUpAbort: AbortController | null = null private _warmUpRetryTimer: NodeJS.Timeout | null = null constructor(deps: SuggestionServiceDeps = createDefaultSuggestionServiceDeps()) { super() this._budget = deps.budget this._warmth = deps.warmth this._repository = deps.repository this._foreground = deps.foreground this._learning = deps.learning } // ── 진단 접근자 (기존 진단·테스트가 읽는 예산 필드 이름을 유지한다) ── // 예산 상태의 정본은 SuggestionBudget 이다. 새 코드는 _budget 을 직접 쓴다. /** @internal */ get _lastRequestAt(): number { return this._budget.lastRequestAt } /** @internal 진행 플래그 누수(watchdog) 재현용 */ set _lastRequestAt(at: number) { this._budget.overrideLastRequestAt(at) } /** @internal */ get _prevRequestAt(): number { return this._budget.prevRequestAt } /** @internal */ get _minuteCount(): number { return this._budget.minuteCount } /** @internal */ get _dayCount(): number { return this._budget.dayCount } /** @internal */ get _consecutiveFailures(): number { return this._budget.consecutiveFailures } /** @internal */ get _cooldownUntil(): number { return this._budget.cooldownUntil } override on(event: K, listener: SuggestionEvents[K]): this { return super.on(event, listener as (...args: unknown[]) => void) } override off(event: K, listener: SuggestionEvents[K]): this { return super.off(event, listener as (...args: unknown[]) => void) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } // ── 상태 ────────────────────────────────────────────── isEnabled(): boolean { return configGet('suggestionEnabled') === true } get isVisible(): boolean { return this._candidates.length > 0 } get isPresentationActive(): boolean { return this.isVisible || this._generating || this._warmingUp || this._partialText.length > 0 } get activeText(): string | null { return this._candidates[this._activeIndex]?.text ?? null } getState(): SuggestionState { const now = Date.now() return { enabled: this.isEnabled(), modelId: this.resolveModel(), modelAvailable: getLocalLLMService().isAvailable(), visible: this.isVisible, generating: this._generating, warmingUp: this._warmingUp, partialText: this._partialText || null, candidates: [...this._candidates], activeIndex: this._activeIndex, targetTotal: this._targetTotal, anchor: this._anchor, anchorKind: this._anchorKind, appName: this._appName, updatedAt: now, lastSkipReason: this._lastSkipReason, requestsToday: this._budget.requestsToday(now), coolingDown: this._budget.isCoolingDown(now), dailyBudget: configGet('suggestionDailyBudget') ?? SUGGESTION_DEFAULTS.dailyBudget, lastLatencyMs: this._lastLatencyMs, triggerDelayMs: this.readPolicyConfig().triggerDelayMs, minPrefixChars: this.readPolicyConfig().minPrefixChars, requestTimeoutMs: this.readTimeoutMs(), learnTypedText: configGet('inputLearnTypedText') === true, telemetryEnabled: configGet('inputTelemetryEnabled') === true, overlayInteractive: configGet('suggestionOverlayInteractive') !== false, provenance: this._provenance } } /** 제안 전용 모델 (설정 없으면 기본 LLM 모델). */ resolveModel(): string | null { const dedicated = configGet('suggestionModelId') if (dedicated) return dedicated return configGet('llmModelId') ?? null } /** 설정 변경 반영 — 꺼지면 즉시 오버레이를 내린다. */ applyConfig(): void { if (!this.isEnabled()) { this.dismiss('disabled') } this.emit('state-changed', this.getState()) } /** * 제안에 쓸 모델이 바뀌었다 (전용 모델 설정 또는 기본 LLM 모델 변경). * * 새 모델은 콜드일 수 있다 — 첫 멈춤에서 콜드 로드가 타임아웃되기 전에 미리 올린다. * 온기는 모델별로 추적하므로 이 호출이 없어도 첫 요청은 워밍업 경로로 간다. */ handleModelChanged(): void { if (!this.isEnabled()) return const model = this.resolveModel() if (!model || this._warmth.isWarm(model, Date.now())) return logger.info(`제안 모델 변경 감지 (model=${model}) — 미리 워밍업한다`) void this.warmUp() } setEnabled(enabled: boolean): void { configSet('suggestionEnabled', enabled) if (!enabled) this.dismiss('disabled') else void this.warmUp() this.emit('state-changed', this.getState()) } /** 설정에서 명시적으로 제안을 켤 때만 모델을 짧게 준비한다. */ warmUp(): Promise { if (!this.isEnabled()) return Promise.resolve() if (this._warmUpPromise) return this._warmUpPromise const abort = new AbortController() this._warmUpAbort = abort this._warmingUp = true this.emit('state-changed', this.getState()) const warmUp = this._warmUpUntilReady(abort) this._warmUpPromise = warmUp void warmUp.finally(() => { if (this._warmUpPromise === warmUp) this._warmUpPromise = null if (this._warmUpAbort === abort) this._warmUpAbort = null }) return warmUp } private async _warmUpUntilReady(abort: AbortController): Promise { try { for (let attempt = 0; attempt <= 15; attempt += 1) { if (abort.signal.aborted || !this.isEnabled()) return if (!getLocalLLMService().isAvailable()) { if (attempt === 15) return await this._waitForWarmUpRetry(abort.signal) continue } const model = this.resolveModel() if (!model) return try { const stream = getLocalLLMService().streamGenerate('hi', { model, maxTokens: 1, temperature: 0, signal: abort.signal, keepAlive: SUGGESTION_KEEP_ALIVE }) for await (const chunk of stream) void chunk if (abort.signal.aborted) return this._noteModelWarm(model) logger.info(`제안 모델 워밍업 완료 (model=${model})`) } catch (error) { if (!abort.signal.aborted) { const message = error instanceof Error ? error.message : String(error) logger.warn(`제안 모델 워밍업 실패: ${message}`) // 실패한 워밍업도 실패다 — 기록하지 않으면 멈출 때마다 새 워밍업을 쏘며 // "준비 중" 만 깜빡이고, 쿨다운·예산이 전혀 걸리지 않는다. this._warmth.noteWarmUpFailure(model, Date.now()) this._lastSkipReason = 'generation-failed' this._noteFailure(`warm-up: ${message}`) } } return } } finally { this._warmingUp = false // 'state-changed' 뿐 아니라 'updated' 도 보내야 한다 — 오버레이는 'updated' 를 // 듣고 표시 여부를 판단하는데, 이게 빠지면 타이핑이 멈춘 사이 워밍업이 끝나도 // "준비 중" 오버레이가 20초 TTL 까지 그대로 남는다. this.emit('updated', this.getState()) this.emit('state-changed', this.getState()) } } private _waitForWarmUpRetry(signal: AbortSignal): Promise { return new Promise((resolve) => { const finish = (): void => { signal.removeEventListener('abort', finish) if (this._warmUpRetryTimer) { clearTimeout(this._warmUpRetryTimer) this._warmUpRetryTimer = null } resolve() } this._warmUpRetryTimer = setTimeout(finish, 2000) this._warmUpRetryTimer.unref?.() signal.addEventListener('abort', finish, { once: true }) }) } private _cancelWarmUp(): void { if (this._warmUpRetryTimer) { clearTimeout(this._warmUpRetryTimer) this._warmUpRetryTimer = null } this._warmUpAbort?.abort() this._warmingUp = false } // ── 입력 텍스트 처리 ──────────────────────────────── /** * 입력 텔레메트리가 보내는 "지금 치고 있는 것" 이벤트. * * 판단은 core `decideSuggestionStep` 이 한다(세션 유효성 → 쿨다운 → 사용자 닫기 → * 정책 → 모델 온기). 서비스는 그 결과만 실행한다. */ handleTypingContext(input: TypingContext): void { // 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함). const context: TypingContext = isTerminalApp(input.appName) ? { ...input, prefix: extractTerminalPromptPrefix(input.fullText ?? input.prefix) ?? '' } : input this._lastContext = context const now = Date.now() this._lastContextAt = now this._watchdog() const config = this.readPolicyConfig() this._logDecisionInputs(context) this._abortStaleGeneration(context.prefix) const model = this.resolveModel() const step = decideSuggestionStep({ policy: this._buildPolicyInput(context, config, now), sessionVisible: this.isVisible, sessionPrefix: this._generatedForPrefix, coolingDown: this._budget.isCoolingDown(now), userDismissedQuiet: now < this._userDismissedUntil, lastRequestedPrefix: this._lastRequestedPrefix, modelWarm: this._warmth.isWarm(model, now), warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS) }) this._executeStep(step, context, config, now) } private _executeStep(step: SuggestionStep, context: TypingContext, config: PolicyConfig, now: number): void { switch (step.action) { case 'end-session': // 세션(페이지 넘기며 보는 고정 목록)을 보여주는 중에 다음 문장이 시작됐다 — // 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외. 즉시 세션을 끝내고 // 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션을 시작한다. logger.debug('제안 닫음: 이어서 입력함 — 멈추면 새 문맥으로 다시 만든다') this.dismiss(step.reason) // 사용자가 고르지 않고 계속 쳤다 — 이 세션 때문에 최소 간격(5초)에 걸려 // 정작 멈췄을 때 아무것도 안 뜨면 안 된다. 분당·일일 상한은 그대로 둔다. this._budget.relaxInterval(now, SUGGESTION_DEFAULTS.minIntervalMs) this.emit('state-changed', this.getState()) return case 'generate': // 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다. void this._generate(step.prefix, context, config.maxCandidates, config.maxChars).catch( (error: unknown) => { logger.warn( `제안 생성 파이프라인 예외: ${error instanceof Error ? error.message : String(error)}` ) this._releaseGeneration() } ) return case 'settle': this._settle(step, context, config) return } } /** 생성하지 않는 결정 — 사유를 남기고, 필요하면 로컬 기억/준비 중/닫기를 수행한다. */ private _settle( step: Extract, context: TypingContext, config: PolicyConfig ): void { logger.debug(`제안 보류: ${step.reason} (${step.then})`) this._lastSkipReason = step.reason if ( step.tryLocalMemory && this._publishLocalMemory( this._currentPrefix(context), context, config.maxCandidates, config.maxChars, 0, null ) ) { return } switch (step.then) { case 'record': break case 'dismiss': if (this.isPresentationActive) this.dismiss(step.reason) break case 'warm-up': void this.warmUp() this._showWarming(context) break case 'show-warming': // 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면 // 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다). this._showWarming(context) break } this.emit('state-changed', this.getState()) } /** "준비 중" 오버레이 — 워밍업 안내도 수명이 있어, 갱신이 없으면 스스로 사라진다. */ private _showWarming(context: TypingContext): void { this._warmingUp = true this._anchor = context.anchor this._anchorKind = context.anchorKind this._appName = context.appName this._armVisibleTtl() this.emit('updated', this.getState()) } /** 수동 요청 (설정/단축키 경로). 디바운스를 건너뛴다. */ async requestNow(): Promise { if (!this.isEnabled()) return { ok: false, reason: 'disabled' } const context = this._lastContext if (!context?.available || !context.isEditable || context.isPassword || context.hasSelection) { const reason = context?.isPassword ? 'password-field' : context?.hasSelection ? 'selection-active' : 'not-editable' if (this.isPresentationActive) this.dismiss(reason) return { ok: false, reason } } const prefix = normalizeRequestPrefix(context.prefix) if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' } // 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에 // 좌우되지 않아야 한다. 그대로 넘기면 _generate 실패 경로의 _canUseLocalMemory 가 // (클릭만 한 뒤 수동 요청 같은 경우) not-typing 으로 막아 버린다. const explicitContext: TypingContext = { ...context, editedSinceFocus: true, typedRecently: true } const config = this.readPolicyConfig() await this._generate(prefix, explicitContext, config.maxCandidates, config.maxChars).catch((error: unknown) => { logger.warn(`수동 제안 생성 예외: ${error instanceof Error ? error.message : String(error)}`) this._releaseGeneration() }) return this.isVisible ? { ok: true } : { ok: false, reason: this._lastSkipReason ?? 'generation-failed' } } /** * 정책 입력 조립 — 모든 경로가 이 한 곳에서 만든다. * * 기본값: 모델 가용성은 실제 값, overlayVisible 은 "생성/워밍업/후보 표시 중" 전체. */ private _buildPolicyInput( context: TypingContext, config: PolicyConfig, now: number, overrides: Partial> = {} ): SuggestionPolicyInput { const budget = this._budget.snapshot(now) return { enabled: this.isEnabled(), modelAvailable: overrides.modelAvailable ?? getLocalLLMService().isAvailable(), overlayVisible: overrides.overlayVisible ?? this.isPresentationActive, composing: context.isComposing, hasSelection: context.hasSelection, isPassword: context.isPassword, isEditable: context.isEditable, appName: context.appName, excludedApps: config.excludedApps, editedSinceFocus: context.editedSinceFocus, typedRecently: context.typedRecently, prefix: context.prefix, idleMs: context.idleMs, triggerDelayMs: config.triggerDelayMs, minPrefixChars: config.minPrefixChars, sinceLastRequestMs: budget.sinceLastRequestMs, minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs, requestsThisMinute: budget.requestsThisMinute, maxRequestsPerMinute: config.maxRequestsPerMinute, requestsToday: budget.requestsToday, dailyBudget: config.dailyBudget } } /** 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. */ private _canUseLocalMemory(context: TypingContext, config: PolicyConfig, now: number): boolean { return ( decideSuggestion( this._buildPolicyInput(context, config, now, { modelAvailable: true, overlayVisible: false }) ).action === 'request' ) } private _collectMemoryHints(prefix: string, appName: string | null): LocalMemoryContext { const telemetry = getInputTelemetryService() const graphContext = getPersonalGraphService().retrieveContext(prefix, 4) const phrases = telemetry.listPhrases(60) const phraseHints = selectPhraseHints(phrases, prefix, 5, { appName }) const requestedApp = appName?.trim().toLowerCase() const appPhraseCount = requestedApp ? phrases.filter( (phrase) => phrase.appName?.trim().toLowerCase() === requestedApp && phraseHints.includes(phrase.phrase) ).length : 0 return { continuationHints: graphContext.continuations, relatedHints: graphContext.related, phraseHints, appPhraseCount } } private _isCurrentFallbackContext( prefix: string, context: TypingContext, token: number | null ): boolean { if (token !== null && token !== this._generationToken) return false const current = this._lastContext if ( !current || !current.available || !current.isEditable || current.isPassword || current.isComposing || current.hasSelection ) { return false } return ( normalizeSessionPrefix(current.prefix) === normalizeSessionPrefix(prefix) && current.appName === context.appName && current.windowTitle === context.windowTitle ) } private _publishLocalMemory( prefix: string, context: TypingContext, maxCandidates: number, maxChars: number, latencyMs: number, token: number | null, memory: LocalMemoryContext | null = null ): boolean { if (!this._isCurrentFallbackContext(prefix, context, token)) return false const trimmedPrefix = normalizeSessionPrefix(prefix) const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName) const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars) if (candidates.length === 0) return false this._partialText = '' this._generating = false this._warmingUp = false this._candidates = candidates.map((text, index) => ({ text, rank: index })) this._activeIndex = 0 this._anchor = context.anchor this._anchorKind = context.anchorKind this._appName = context.appName this._windowTitle = context.windowTitle this._windowHandle = context.windowHandle ?? null this._generatedForPrefix = trimmedPrefix this._targetTotal = candidates.length this._lastSkipReason = null this._lastLatencyMs = latencyMs this._provenance = { mode: 'local-memory', continuationCount: localMemory.continuationHints.length, relatedCount: localMemory.relatedHints.length, phraseCount: localMemory.phraseHints.length, appPhraseCount: localMemory.appPhraseCount } this._recordSuggestion({ prefix: trimmedPrefix, text: candidates[0], model: 'local-memory', latencyMs, candidateCount: candidates.length }) this._armVisibleTtl() this.emit('updated', this.getState()) this.emit('state-changed', this.getState()) logger.info(`로컬 기억 제안 ${candidates.length}개 생성`) return true } // ── 생성 ────────────────────────────────────────────── private async _generate( prefix: string, context: TypingContext, maxCandidates: number, maxChars: number ): Promise { if (this._inFlight) { // 침묵하면 "왜 안 뜨는지" 를 알 수 없다 — 진단을 남긴다. logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._budget.lastRequestAt}ms 경과)`) return } // 어떤 경로로 끝나든 진행 플래그는 반드시 해제한다. // (emit/showOverlay 쪽 예외가 여기로 새면 _inFlight 가 true 로 굳어 // 이후 모든 제안이 조용히 막힌다 — "한 번 나오고 안 나옴" 의 원인 후보) try { const startedAt = Date.now() // 세션 접두 — 프롬프트 입력이자 세션 유효성 비교의 기준 (normalizeSessionPrefix 정본) const trimmedPrefix = normalizeSessionPrefix(prefix) const model = this.resolveModel() if (!model) { const config = this.readPolicyConfig() if ( this._canUseLocalMemory(context, config, startedAt) && this._publishLocalMemory( trimmedPrefix, context, maxCandidates, maxChars, Date.now() - startedAt, null ) ) { return } this._lastSkipReason = 'model-unavailable' this.emit('state-changed', this.getState()) return } this._abort?.abort() const abort = new AbortController() this._abort = abort const token = (this._generationToken += 1) this._inFlight = true this._generating = true this._lastRequestedPrefix = normalizeRequestPrefix(prefix) this._warmingUp = false this._provenance = null this._budget.consume(Date.now()) // 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고 // "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다. this._candidates = [] this._activeIndex = 0 this._anchor = context.anchor this._anchorKind = context.anchorKind this._appName = context.appName this._windowTitle = context.windowTitle this._windowHandle = context.windowHandle ?? null this._armVisibleTtl() this.emit('updated', this.getState()) const timeoutMs = this.readTimeoutMs() let timedOut = false if (this._timeoutTimer) clearTimeout(this._timeoutTimer) this._timeoutTimer = setTimeout(() => { // 모델이 하드웨어보다 느릴 때 UI 를 붙잡지 않는다 (다음 타이핑 주기에 재시도). if (!abort.signal.aborted) { timedOut = true abort.abort() } }, timeoutMs) this._timeoutTimer.unref?.() // 개인 그래프 문맥: (1) 과거에 그 꼬리 뒤에 실제로 이어 쓴 문장, // (2) follows/용어공유 관계로 끌어온 관련 문장. const memory = this._collectMemoryHints(trimmedPrefix, context.appName) const continuationHints = memory.continuationHints const hints = [...memory.relatedHints, ...memory.phraseHints] .filter((value, index, list) => list.indexOf(value) === index) .slice(0, 5) // 세션의 첫 요청은 딱 1개만 청한다 — 한꺼번에 여러 개를 요청하면 느리다(사용자 // 요청). 첫 후보를 보여준 뒤 나머지는 채우기 루프가 하나씩 순차로 더 만든다. const { systemPrompt, text } = buildSuggestionPrompt({ prefix: trimmedPrefix, appName: context.appName, windowTitle: context.windowTitle, phraseHints: hints, continuationHints, candidates: 1, maxChars }) let raw = '' try { this._partialText = '' const stream = getLocalLLMService().streamGenerate(text, { model, systemPrompt, temperature: SUGGESTION_DEFAULTS.temperature, maxTokens: SUGGESTION_DEFAULTS.maxOutputTokens, signal: abort.signal, keepAlive: SUGGESTION_KEEP_ALIVE }) for await (const chunk of stream) { if (abort.signal.aborted) break raw += chunk // 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다. this._noteModelWarm(model) // 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다. // IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다. this._partialText = sanitizePartial(raw) const now = Date.now() if (now - this._lastPartialEmitAt >= 120) { this._lastPartialEmitAt = now this.emit('updated', this.getState()) } if (raw.length >= MAX_RAW_OUTPUT_CHARS) break } } catch (error) { if (!abort.signal.aborted) { this._lastSkipReason = 'generation-failed' this._noteFailure(error instanceof Error ? error.message : String(error)) if ( this._publishLocalMemory( trimmedPrefix, context, maxCandidates, maxChars, Date.now() - startedAt, token, memory ) ) { logger.warn(`모델 제안 실패 후 로컬 기억 제안으로 전환: ${error instanceof Error ? error.message : String(error)}`) return } this.dismiss('generation-failed') this.emit('state-changed', this.getState()) return } } finally { if (this._abort === abort) this._abort = null if (this._timeoutTimer) { clearTimeout(this._timeoutTimer) this._timeoutTimer = null } } if (abort.signal.aborted) { if (token !== this._generationToken) { logger.debug(`중단된 제안 결과 폐기 (세대 ${token} ≠ ${this._generationToken})`) return } if (!timedOut) return this._lastSkipReason = 'generation-failed' this._noteFailure(`timeout ${timeoutMs}ms`) if ( this._publishLocalMemory( trimmedPrefix, context, maxCandidates, maxChars, Date.now() - startedAt, token, memory ) ) { logger.warn(`모델 제안 시간 초과 후 로컬 기억 제안으로 전환 (${timeoutMs}ms)`) return } this.dismiss('generation-failed') this.emit('state-changed', this.getState()) return } // 사용자가 그사이 닫았거나 새 요청이 시작됐다면 이 결과는 버린다. if (token !== this._generationToken) { logger.debug(`제안 결과 폐기 (세대 ${token} ≠ ${this._generationToken})`) return } // 타이핑을 멈추고 자리를 뜬 뒤 도착한 결과는 화면에 띄우지 않는다. // (아무것도 안 치는데 제안창이 뜨던 신고의 원인 중 하나) const staleness = Date.now() - this._lastContextAt if (this._lastContextAt > 0 && staleness > SUGGESTION_DEFAULTS.resultMaxStalenessMs) { logger.debug(`제안 결과 폐기 (문맥이 ${staleness}ms 지났다)`) this.dismiss('stale') return } const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars) if (candidates.length === 0) { this._lastSkipReason = 'generation-failed' logger.info('제안 후보가 비어 있음 (모델 출력 정제 후)') if ( this._publishLocalMemory( trimmedPrefix, context, maxCandidates, maxChars, Date.now() - startedAt, token, memory ) ) { return } this.dismiss('generation-failed') this.emit('state-changed', this.getState()) return } const latencyMs = Date.now() - startedAt this._lastLatencyMs = latencyMs this._budget.noteSuccess() this._partialText = '' this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index })) this._activeIndex = 0 this._anchor = context.anchor this._anchorKind = context.anchorKind this._appName = context.appName this._windowTitle = context.windowTitle this._windowHandle = context.windowHandle ?? null this._generatedForPrefix = trimmedPrefix this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal this._lastSkipReason = null this._provenance = { mode: 'local-model', continuationCount: continuationHints.length, relatedCount: memory.relatedHints.length, phraseCount: memory.phraseHints.length, appPhraseCount: memory.appPhraseCount } this._recordSuggestion({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length }) logger.info(`제안 첫 후보 생성 (${latencyMs}ms, model=${model}) — 최대 ${SUGGESTION_DEFAULTS.maxCandidatesTotal}개까지 순차로 채운다`) this._armVisibleTtl() this.emit('updated', this.getState()) // generating 은 계속 true 로 남는다 — 채우기 루프가 백그라운드에서 나머지를 // 하나씩 청한다("더 온다" 를 UI 에 알린다). _releaseGeneration() 이 이를 // 지우지 않도록 _filling 을 먼저 켠다. this._filling = true void this._runFillLoop(token, context, maxChars).catch((error: unknown) => { logger.warn(`제안 채우기 루프 예외: ${error instanceof Error ? error.message : String(error)}`) this._filling = false }) } finally { // 어떤 경로로 끝나든 진행 플래그를 해제한다 — // 예외가 어디로 새든 다음 제안이 조용히 막히지 않는다. this._releaseGeneration() } } // ── 채우기 루프 (2번째 이후 후보, 최대 12개) ──────────── /** * 첫 후보 공개 뒤 나머지를 하나씩 순차로 채운다. * * 종료 조건: 12개 도달, 연속 2번 새 후보 없음, 세대 토큰이 바뀜(세션 종료). * `_generate` 의 예산/타임아웃/실패 카운트와 무관하다 — 채우기는 그 어느 것도 * 소비하지 않는다(설계). 시간 초과된 채우기 요청은 조용히 다음으로 넘어간다. */ private async _runFillLoop(token: number, context: TypingContext, maxChars: number): Promise { let consecutiveEmpty = 0 while ( token === this._generationToken && this._candidates.length < SUGGESTION_DEFAULTS.maxCandidatesTotal && consecutiveEmpty < 2 ) { const appended = await this._fillOne(token, context, maxChars) if (token !== this._generationToken) break consecutiveEmpty = appended ? 0 : consecutiveEmpty + 1 } if (token === this._generationToken) { this._filling = false this._generating = false this.emit('updated', this.getState()) } } /** 채우기 루프 한 스텝 — 후보 1개를 요청해 고유하면 덧붙인다. */ private async _fillOne(token: number, context: TypingContext, maxChars: number): Promise { const model = this.resolveModel() if (!model) return false const abort = new AbortController() this._fillAbort = abort const timeoutMs = this.readTimeoutMs() const timer = setTimeout(() => { if (!abort.signal.aborted) abort.abort() }, timeoutMs) timer.unref?.() try { // 세션이 고정한 접두를 쓴다 — context.prefix 는 그사이 바뀌었을 수 있지만, // 바뀌었다면 이미 위(handleTypingContext)에서 세션이 dismiss 되어 토큰이 // 달라져 있으므로 이 루프는 다음 체크에서 멈춘다. const prefix = this._generatedForPrefix const memory = this._collectMemoryHints(prefix, context.appName) const hints = [...memory.relatedHints, ...memory.phraseHints] .filter((value, index, list) => list.indexOf(value) === index) .slice(0, 5) const avoidCandidates = this._candidates.map((candidate) => candidate.text) const { systemPrompt, text } = buildSuggestionPrompt({ prefix, appName: context.appName, windowTitle: context.windowTitle, phraseHints: hints, continuationHints: memory.continuationHints, avoidCandidates, candidates: 1, maxChars }) let raw = '' const stream = getLocalLLMService().streamGenerate(text, { model, systemPrompt, temperature: SUGGESTION_DEFAULTS.temperature, maxTokens: SUGGESTION_DEFAULTS.maxOutputTokens, signal: abort.signal, keepAlive: SUGGESTION_KEEP_ALIVE }) for await (const chunk of stream) { if (abort.signal.aborted) break raw += chunk this._noteModelWarm(model) if (raw.length >= MAX_RAW_OUTPUT_CHARS) break } if (abort.signal.aborted || token !== this._generationToken) return false const parsed = parseSuggestionCandidates(raw, prefix, 1, maxChars) if (parsed.length === 0) return false const candidateText = parsed[0] if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false this._candidates.push({ text: candidateText, rank: this._candidates.length }) logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`) this._armVisibleTtl() this.emit('updated', this.getState()) this._recordSuggestion({ prefix, text: candidateText, model, latencyMs: 0, candidateCount: this._candidates.length }) return true } catch (error) { if (!abort.signal.aborted) { logger.debug( `제안 채우기 요청 실패 — 조용히 다음으로 넘어간다: ${error instanceof Error ? error.message : String(error)}` ) } return false } finally { clearTimeout(timer) if (this._fillAbort === abort) this._fillAbort = null } } /** 정규화 후 완전 중복이거나 기존 후보의 접두/확장이면 중복으로 본다. */ private _isDuplicateCandidate(candidate: string): boolean { const normalized = candidate.replace(/\s+/gu, ' ').trim().toLowerCase() return this._candidates.some((existing) => { const existingNormalized = existing.text.replace(/\s+/gu, ' ').trim().toLowerCase() return ( existingNormalized === normalized || existingNormalized.startsWith(normalized) || normalized.startsWith(existingNormalized) ) }) } /** * 생성 플래그를 무조건 해제한다 (누수 감시 포함). * * 응답 제한 + 여유를 넘겨 진행 중이면 비정상 상태로 보고 스스로 복구한다. */ private _releaseGeneration(): void { this._inFlight = false // 채우기 루프가 막 시작됐으면 generating 을 그대로 둔다 — 아직 더 올 게 있다. // (루프 자신이 끝날 때 스스로 false 로 내린다) if (!this._filling) this._generating = false this._partialText = '' } /** 마지막 생성 이후 흐른 시간 (ms). */ get inFlightAgeMs(): number { return this._inFlight ? Date.now() - this._budget.lastRequestAt : 0 } /** 응답 제한 (설정 → SSOT 기본값). */ private readTimeoutMs(): number { const configured = configGet('suggestionRequestTimeoutMs') return configured && configured > 0 ? configured : SUGGESTION_DEFAULTS.requestTimeoutMs } /** 현재 정책 기준의 접두. */ private _currentPrefix(context: TypingContext): string { return normalizeRequestPrefix(context.prefix) } /** 판단 입력을 서명이 바뀔 때만 남긴다. */ private _logDecisionInputs(context: TypingContext): void { const signature = [ context.available, context.isEditable, context.isPassword, context.isComposing, context.anchor ? 'A' : '-', context.editedSinceFocus, context.typedRecently, getLocalLLMService().isAvailable(), this.isEnabled() ].join('|') if (signature === this._lastDecisionSignature) return this._lastDecisionSignature = signature logger.info( `제안 판단 입력: avail=${context.available} edit=${context.isEditable} pw=${context.isPassword} ` + `comp=${context.isComposing} edited=${context.editedSinceFocus} typed=${context.typedRecently} ` + `prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` + `app=${context.appName ?? '-'} enabled=${this.isEnabled()} ` + `model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}` ) } /** * 생성 실패를 기록하고, 연속 실패면 잠시 쉰다. * * 실패할 때마다 새 요청이 스피너를 다시 걸면 "끝나지도 꺼지지도 않는" 것처럼 보인다 * (실측 신고). 임계 도달 시 쿨다운 동안은 요청 자체를 하지 않는다. */ private _noteFailure(reason: string): void { if (!this._budget.noteFailure(Date.now())) return logger.warn( `제안 생성 연속 실패 (${reason}) — ${Math.round( SUGGESTION_DEFAULTS.failureCooldownMs / 1000 )}초 동안 요청을 쉰다 (모델이 다른 작업으로 바쁠 수 있음)` ) } /** 이 모델이 방금 응답했다 — keep-alive 동안 메모리에 남아 있다고 본다. */ private _noteModelWarm(model: string): void { this._warmth.noteWarm(model, Date.now() + SUGGESTION_KEEP_ALIVE_MS - SUGGESTION_KEEP_ALIVE_MARGIN_MS) } private _armVisibleTtl(): void { if (this._ttlTimer) clearTimeout(this._ttlTimer) this._ttlTimer = setTimeout(() => { this._ttlTimer = null logger.debug('제안 표시 수명 만료 — 자동으로 닫는다') this.dismiss('stale') }, SUGGESTION_DEFAULTS.visibleTtlMs) this._ttlTimer.unref?.() } /** 표시 수명 타이머 해제. */ private _clearVisibleTtl(): void { if (this._ttlTimer) { clearTimeout(this._ttlTimer) this._ttlTimer = null } } private _abortStaleGeneration(currentPrefix: string): void { if (!this._inFlight || !this._abort || this._abort.signal.aborted) return const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix) if (refresh === 'keep') return this._generationToken += 1 this._abort.abort() // 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다. // (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어 // 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제) this._budget.refund() // 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다. if (this._candidates.length === 0) this.dismiss('stale') logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`) } /** * 진행 플래그가 비정상적으로 오래 남았으면 강제 해제한다. * * 응답 제한(기본 8초) + 5초를 넘긴 in-flight 는 죽은 것으로 본다 — * 어떤 예외 경로로든 플래그가 누수되면 제안이 영구히 멈추기 때문이다. */ private _watchdog(): void { if (!this._inFlight) return const age = Date.now() - this._budget.lastRequestAt if (age < this.readTimeoutMs() + 5000) return logger.warn(`제안 생성 플래그 누수 감지 (${age}ms) — 강제 해제`) this._generationToken += 1 this._abort?.abort() this._abort = null if (this._timeoutTimer) { clearTimeout(this._timeoutTimer) this._timeoutTimer = null } this._releaseGeneration() this._lastSkipReason = 'stale' this.emit('updated', this.getState()) } // ── 사용자 동작 ─────────────────────────────────────── /** 이전 후보로 순환 (전체 후보를 가로질러, 페이지 무관). */ previous(): SuggestionState { if (this._candidates.length > 1) { this._moveActive((this._activeIndex - 1 + this._candidates.length) % this._candidates.length) } return this.getState() } /** * 다음 후보로 — 페이지는 활성 후보를 따라 넘어간다. * * 마지막 후보에서 아직 더 만드는 중이면 그 자리에 머문다: 처음으로 돌아가 버리면 * 곧 도착할 후보를 보려던 사용자가 길을 잃는다. 다 만들었으면 처음으로 돌아간다. */ next(): SuggestionState { const last = this._candidates.length - 1 if (last < 1) return this.getState() if (this._activeIndex < last) this._moveActive(this._activeIndex + 1) else if (!this._filling) this._moveActive(0) return this.getState() } /** 사용자가 후보를 훑는 중에는 표시 수명이 끝나 창이 닫히면 안 된다. */ private _moveActive(index: number): void { this._activeIndex = index this._armVisibleTtl() this.emit('updated', this.getState()) } dismiss(reason: SuggestionSkipReason = 'dismissed'): void { if (reason === 'dismissed') { // 명시적 닫기: 잠깐 조용히 있고, 진행 중 생성은 무효화한다. this._userDismissedUntil = Date.now() + SUGGESTION_DEFAULTS.userDismissQuietMs logger.info(`사용자가 제안을 닫음 — ${SUGGESTION_DEFAULTS.userDismissQuietMs}ms 동안 재표시하지 않는다`) } const wasVisible = this.isPresentationActive // 진행 중이던 생성을 무효화한다 (닫았는데 잠시 뒤 결과가 다시 뜨는 것을 막는다). this._generationToken += 1 this._clearVisibleTtl() this._candidates = [] this._generating = false this._warmingUp = false this._partialText = '' this._activeIndex = 0 this._anchor = null this._anchorKind = null this._generatedForPrefix = '' this._targetTotal = 0 this._provenance = null this._lastSkipReason = reason this._abort?.abort() this._abort = null // 채우기 루프도 함께 끝낸다 — accept/dismiss/stale 은 전부 세션 종료다(설계). this._filling = false this._fillAbort?.abort() this._fillAbort = null if (this._timeoutTimer) { clearTimeout(this._timeoutTimer) this._timeoutTimer = null } // 워밍업은 오버레이 표시와 독립된 요청이다 — 포커스 전환/stale 등으로 오버레이를 // 지울 때마다 취소하면 모델이 영영 안 뜬다. 설정에서 기능을 끌 때만 취소한다. if (reason === 'disabled') this._cancelWarmUp() if (wasVisible || reason === 'dismissed') { this.emit('cleared', { reason }) } } /** 활성 후보를 대상 앱에 삽입하고 수락으로 기록한다. */ async accept(index?: number): Promise { if (index !== undefined && index >= 0 && index < this._candidates.length) { this._activeIndex = index } const candidate = this._candidates[this._activeIndex] if (!candidate) { // 창이 이미 닫힌 뒤의 클릭·단축키 — 조용히 끝나면 "골라도 안 들어간다" 의 원인이 안 보인다. logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`) return { ok: false, reason: 'already-visible' } } const text = candidate.text const appName = this._appName const windowTitle = this._windowTitle const targetWindow = this._windowHandle // 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다. this.dismiss('accepted') this.emit('state-changed', this.getState()) // 단축키로 수락했다면 사용자가 아직 Ctrl+Alt 를 쥐고 있다. 그대로 Ctrl+V 를 보내면 // 대상 앱에 Ctrl+Alt+V 가 들어가 붙여넣기가 되지 않는다(실측) — 뗄 때까지 기다린다. if (!(await waitForModifiersReleased())) { logger.warn('수정자 키가 떼어지지 않은 채 제안을 삽입한다 (1.5초 초과)') } // 붙여넣기(Ctrl+V)는 그 순간의 포그라운드 창으로 간다. 오버레이는 다음 스냅샷이 와야 // 닫히므로, 그사이 다른 앱으로 옮겨 간 뒤 수락하면 엉뚱한 창에 삽입됐다 — 세션을 만든 // 창이 아니면 삽입하지 않는다 (창을 알 수 없으면 기존처럼 진행한다). if (targetWindow !== null) { const currentWindow = this._foreground.currentWindowHandle() if (currentWindow !== null && currentWindow !== targetWindow) { logger.info(`제안 수락 취소: 포커스가 다른 창으로 옮겨 감 (app=${appName ?? '-'})`) this._lastSkipReason = 'stale' this.emit('state-changed', this.getState()) return { ok: false, reason: 'stale' } } } // 삽입 직후의 텍스트 스냅샷 diff 가 이 텍스트를 "타이핑" 으로 다시 학습하지 않도록 알린다. const cancelExpectedInsert = this._learning.expectProgrammaticInsert(text) const method = configGet('insertMethod') try { const result = await getTextInsertService().insertText( text, method === 'keyboard' ? 'keyboard' : 'clipboard' ) if (!result.success) { cancelExpectedInsert() logger.warn(`제안 삽입 실패 (method=${result.method}, len=${result.textLength})`) return { ok: false, reason: 'generation-failed' } } } catch (error) { cancelExpectedInsert() logger.warn(`제안 삽입 예외: ${error instanceof Error ? error.message : String(error)}`) return { ok: false, reason: 'generation-failed' } } logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`) this._repository.markAccepted(text) // 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨). this._learning.recordAccepted(text, { appName, windowTitle }) return { ok: true } } getHistory(limit = 50): SuggestionHistoryEntry[] { return this._repository.list(limit) } // ── 내부 ────────────────────────────────────────────── private readPolicyConfig(): PolicyConfig { return { triggerDelayMs: Math.max( SUGGESTION_DEFAULTS.minTriggerDelayMs, configGet('suggestionTriggerDelayMs') || SUGGESTION_DEFAULTS.triggerDelayMs ), minPrefixChars: configGet('suggestionMinPrefixChars') || SUGGESTION_DEFAULTS.minPrefixChars, maxRequestsPerMinute: Math.min( configGet('suggestionMaxRequestsPerMinute') || SUGGESTION_DEFAULTS.maxRequestsPerMinute, 12 ), dailyBudget: configGet('suggestionDailyBudget') || SUGGESTION_DEFAULTS.dailyBudget, maxCandidates: SUGGESTION_DEFAULTS.maxCandidates, maxChars: SUGGESTION_MAX_OUTPUT_CHARS, // 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다. // 터미널도 제안한다(입력 줄만 추출). 학습 제외는 InputTelemetryService 가 따로 한다. excludedApps: [...configGet('inputExcludedApps')] } } private _recordSuggestion(input: Omit): void { this._repository.record({ ...input, appName: this._appName }) } /** 테스트/진단 — 현재 앱이 제외 대상인지. */ isAppAllowed(appName: string | null): boolean { if (!appName) return true return !isAppExcluded(appName, configGet('inputExcludedApps')) } dispose(): void { this.dismiss('disabled') this._cancelWarmUp() this.removeAllListeners() } } /** * 스트리밍 중간 텍스트 정제 — 번호/불릿/따옴표와 지시문 누출을 화면에 잠깐 * 보여주지 않도록 줄 단위로 다듬는다. */ function sanitizePartial(raw: string): string { const lines = raw .split(/\r?\n/u) .map((line) => sanitizeSuggestionLine(line, SUGGESTION_MAX_OUTPUT_CHARS) ?? line.trim()) .filter((line) => line.length > 0) return lines.slice(0, 2).join(' ').slice(0, SUGGESTION_MAX_OUTPUT_CHARS) } let instance: SuggestionService | null = null export function getSuggestionService(): SuggestionService { if (!instance) instance = new SuggestionService() return instance } export function resetSuggestionServiceForTests(): void { instance?.dispose() instance = null }