d3ro-voice/apps/desktop/src/main/services/SuggestionService.ts

1450 lines
56 KiB
TypeScript

// 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<void> | 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<K extends keyof SuggestionEvents>(event: K, listener: SuggestionEvents[K]): this {
return super.on(event, listener as (...args: unknown[]) => void)
}
override off<K extends keyof SuggestionEvents>(event: K, listener: SuggestionEvents[K]): this {
return super.off(event, listener as (...args: unknown[]) => void)
}
override emit<K extends keyof SuggestionEvents>(
event: K,
...args: Parameters<SuggestionEvents[K]>
): 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<void> {
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<void> {
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<void> {
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<SuggestionStep, { action: 'settle' }>,
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<SuggestionRequestResult> {
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<Pick<SuggestionPolicyInput, 'modelAvailable' | 'overlayVisible'>> = {}
): 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<void> {
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<void> {
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<boolean> {
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<SuggestionRequestResult> {
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<SuggestionRecordInput, 'appName'>): 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
}