refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 9aa7302944
commit 190b6db284
13 changed files with 1994 additions and 496 deletions

View file

@ -11,16 +11,16 @@
// - 후보가 나온 접두와 현재 접두가 달라지면 그 즉시 오버레이를 지운다 (stale).
import { EventEmitter } from 'events'
import { desc, eq } from 'drizzle-orm'
import {
SUGGESTION_CONTEXT_MAX_CHARS,
SUGGESTION_DEFAULTS,
SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidates,
decideSuggestion,
decideSuggestionRefresh,
decideSuggestionStep,
isAppExcluded,
matchesSessionPrefix,
normalizeRequestPrefix,
normalizeSessionPrefix,
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
@ -28,13 +28,13 @@ import {
extractTerminalPromptPrefix,
type AnchorKind,
type SuggestionCandidate,
type SuggestionPolicyInput,
type SuggestionProvenance,
type SuggestionSkipReason,
type SuggestionState,
type SuggestionStep,
type UiRect
} from '@d3ro/core/input-intelligence'
import { getDatabase } from '../db'
import { suggestions } from '../db/schema'
import { configGet, configSet } from './ConfigService'
import { getLogger } from './LoggerService'
import { getLocalLLMService } from './LocalLLMService'
@ -43,18 +43,67 @@ import { getInputTelemetryService, type TypingContext } from './InputTelemetrySe
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 interface SuggestionHistoryEntry {
id: string
appName: string | null
prefixText: string
suggestionText: string
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
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 {
@ -86,8 +135,12 @@ const MAX_RAW_OUTPUT_CHARS = 1200
*/
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
class SuggestionService extends EventEmitter {
export class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0
private _anchor: UiRect | null = null
@ -119,10 +172,6 @@ class SuggestionService extends EventEmitter {
private _ttlTimer: NodeJS.Timeout | null = null
/** 마지막으로 실제 요청한 접두 (같은 텍스트 반복 요청 방지) */
private _lastRequestedPrefix = ''
/** 연속 실패 횟수 — 임계 도달 시 쿨다운 */
private _consecutiveFailures = 0
/** 쿨다운 종료 시각 (ms) — 이 동안은 요청하지 않는다 */
private _cooldownUntil = 0
/** 사용자가 X 로 닫은 뒤 재표시를 막는 시각 (ms) */
private _userDismissedUntil = 0
/** 스트리밍 누적 텍스트 */
@ -131,16 +180,16 @@ class SuggestionService extends EventEmitter {
private _lastPartialEmitAt = 0
private _lastContext: TypingContext | null = null
private _provenance: SuggestionProvenance | null = null
/** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */
private _windowHandle: number | null = null
private _lastRequestAt = 0
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
private _prevRequestAt = 0
/** 모델이 메모리에 남아 있다고 볼 수 있는 시각 (ms). 이 시각 이후에는 콜드 리로드로 본다. */
private _modelWarmUntil = 0
private _minuteWindowStart = 0
private _minuteCount = 0
private _dayKey = ''
private _dayCount = 0
/** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */
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
@ -153,6 +202,52 @@ class SuggestionService extends EventEmitter {
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)
@ -189,7 +284,6 @@ class SuggestionService extends EventEmitter {
getState(): SuggestionState {
const now = Date.now()
this._rollCounters(now)
return {
enabled: this.isEnabled(),
modelId: this.resolveModel(),
@ -206,8 +300,8 @@ class SuggestionService extends EventEmitter {
appName: this._appName,
updatedAt: now,
lastSkipReason: this._lastSkipReason,
requestsToday: this._dayCount,
coolingDown: Date.now() < this._cooldownUntil,
requestsToday: this._budget.requestsToday(now),
coolingDown: this._budget.isCoolingDown(now),
dailyBudget: configGet('suggestionDailyBudget') ?? SUGGESTION_DEFAULTS.dailyBudget,
lastLatencyMs: this._lastLatencyMs,
triggerDelayMs: this.readPolicyConfig().triggerDelayMs,
@ -235,6 +329,20 @@ class SuggestionService extends EventEmitter {
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')
@ -283,13 +391,17 @@ class SuggestionService extends EventEmitter {
})
for await (const chunk of stream) void chunk
if (abort.signal.aborted) return
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
this._noteModelWarm(model)
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
} catch (error) {
if (!abort.signal.aborted) {
logger.warn(
`제안 모델 워밍업 실패: ${error instanceof Error ? error.message : String(error)}`
)
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
@ -334,7 +446,8 @@ class SuggestionService extends EventEmitter {
/**
* 입력 텔레메트리가 보내는 "지금 치고 있는 것" 이벤트.
*
* 정책 판단 → (stale 정리) → 필요하면 생성.
* 판단은 core `decideSuggestionStep` 이 한다(세션 유효성 → 쿨다운 → 사용자 닫기 →
* 정책 → 모델 온기). 서비스는 그 결과만 실행한다.
*/
handleTypingContext(input: TypingContext): void {
// 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함).
@ -345,165 +458,105 @@ class SuggestionService extends EventEmitter {
const now = Date.now()
this._lastContextAt = now
this._watchdog()
this._rollCounters(now)
const config = this.readPolicyConfig()
this._logDecisionInputs(context)
this._abortStaleGeneration(context.prefix)
// 세션(페이지 넘기며 보는 고정 목록)이 후보를 보여주는 중이면, 다음 문장이
// 시작된 순간 — 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외 —
// 즉시 세션을 끝낸다(사용자 요청 사양: 계속 자라는 접두를 따라가지 않는다).
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션이 시작된다.
if (this.isVisible && !matchesSessionPrefix(this._generatedForPrefix, context.prefix)) {
logger.debug('제안 닫음: 이어서 입력함 — 멈추면 새 문맥으로 다시 만든다')
this.dismiss('stale')
// 사용자가 고르지 않고 계속 쳤다 — 이 세션 때문에 최소 간격(5초)에 걸려
// 정작 멈췄을 때 아무것도 안 뜨면 안 된다. 분당·일일 상한은 그대로 둔다.
this._lastRequestAt = Math.min(this._lastRequestAt, now - SUGGESTION_DEFAULTS.minIntervalMs)
this.emit('state-changed', this.getState())
return
}
// 연속 실패 쿨다운. 모델이 다른 작업으로 바쁘면(로컬 에이전트 동시 사용 등)
// 요청이 계속 타임아웃되어 스피너만 깜빡인다 — 잠시 요청을 멈춘다.
if (Date.now() < this._cooldownUntil) {
this._lastSkipReason = 'cooldown'
if (
this._canUseLocalMemory(context, config, now) &&
this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
) {
return
}
if (this.isPresentationActive) this.dismiss('cooldown')
this.emit('state-changed', this.getState())
return
}
// 사용자가 X 로 닫은 직후에는 다시 띄우지 않는다 (닫기가 확실히 먹도록).
if (Date.now() < this._userDismissedUntil) {
this._lastSkipReason = 'cooldown'
this.emit('state-changed', this.getState())
return
}
const decision = decideSuggestion({
enabled: this.isEnabled(),
modelAvailable: getLocalLLMService().isAvailable(),
// 생성 중이거나 세션이 떠 있으면 "이미 진행 중" 으로 취급해 중복 요청과
// 깜빡임을 막는다. 접두가 어긋난 세션은 위에서 이미 dismiss 하고 return 했으므로
// 여기 도달했다는 것 자체가 "그대로 유효한 세션" 이라는 뜻이다.
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: now - this._lastRequestAt,
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
requestsThisMinute: this._minuteCount,
maxRequestsPerMinute: config.maxRequestsPerMinute,
requestsToday: this._dayCount,
dailyBudget: config.dailyBudget
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)
}
if (decision.action === 'clear') {
logger.debug(`제안 삭제: ${decision.reason}`)
if (this.isPresentationActive) this.dismiss(decision.reason)
else this._lastSkipReason = decision.reason
this.emit('state-changed', this.getState())
return
}
if (decision.action === 'skip') {
logger.debug(`제안 건너: ${decision.reason}`)
this._lastSkipReason = decision.reason
// 같은 텍스트로 이미 요청했다면 다시 만들지 않는다.
// (필드를 클릭만 했거나, 엔터로 보낸 뒤 UIA 가 옛 텍스트를 돌려줄 때
// 제안이 반복 생성되던 문제)
if (this._currentPrefix(context) === this._lastRequestedPrefix && this._lastRequestedPrefix) {
this._lastSkipReason = 'unchanged'
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 (decision.reason === 'model-unavailable') {
if (this._canUseLocalMemory(context, config, now)) {
const shown = this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
if (shown) return
}
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
// 워밍업 안내도 수명이 있다 — 모델이 뜬 뒤 갱신이 없으면 스스로 사라진다.
this._armVisibleTtl()
this.emit('updated', this.getState())
}
this.emit('state-changed', this.getState())
if (
step.tryLocalMemory &&
this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
) {
return
}
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다.
// 콜드 리로드(실측 11.4s)는 요청 타임아웃(8s)보다 길어 그대로 요청하면 항상 시간 초과한다.
if (now >= this._modelWarmUntil) {
if (this._canUseLocalMemory(context, config, now)) {
const shown = this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
if (shown) return
}
void this.warmUp()
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._armVisibleTtl()
this.emit('updated', this.getState())
this._lastSkipReason = 'model-unavailable'
this.emit('state-changed', this.getState())
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())
}
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
void this._generate(decision.prefix, context, config.maxCandidates, config.maxChars).catch(
(error: unknown) => {
logger.warn(
`제안 생성 파이프라인 예외: ${error instanceof Error ? error.message : String(error)}`
)
this._releaseGeneration()
}
)
/** "준비 중" 오버레이 — 워밍업 안내도 수명이 있어, 갱신이 없으면 스스로 사라진다. */
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())
}
/** 수동 요청 (설정/단축키 경로). 디바운스를 건너뛴다. */
@ -519,7 +572,7 @@ class SuggestionService extends EventEmitter {
if (this.isPresentationActive) this.dismiss(reason)
return { ok: false, reason }
}
const prefix = context.prefix.replace(/\s+$/u, '')
const prefix = normalizeRequestPrefix(context.prefix)
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
// 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에
@ -534,15 +587,22 @@ class SuggestionService extends EventEmitter {
return this.isVisible ? { ok: true } : { ok: false, reason: this._lastSkipReason ?? 'generation-failed' }
}
private _canUseLocalMemory(
/**
* 정책 입력 조립 — 모든 경로가 이 한 곳에서 만든다.
*
* 기본값: 모델 가용성은 실제 값, overlayVisible 은 "생성/워밍업/후보 표시 중" 전체.
*/
private _buildPolicyInput(
context: TypingContext,
config: ReturnType<SuggestionService['readPolicyConfig']>,
now: number
): boolean {
const decision = decideSuggestion({
config: PolicyConfig,
now: number,
overrides: Partial<Pick<SuggestionPolicyInput, 'modelAvailable' | 'overlayVisible'>> = {}
): SuggestionPolicyInput {
const budget = this._budget.snapshot(now)
return {
enabled: this.isEnabled(),
modelAvailable: true,
overlayVisible: false,
modelAvailable: overrides.modelAvailable ?? getLocalLLMService().isAvailable(),
overlayVisible: overrides.overlayVisible ?? this.isPresentationActive,
composing: context.isComposing,
hasSelection: context.hasSelection,
isPassword: context.isPassword,
@ -555,14 +615,22 @@ class SuggestionService extends EventEmitter {
idleMs: context.idleMs,
triggerDelayMs: config.triggerDelayMs,
minPrefixChars: config.minPrefixChars,
sinceLastRequestMs: now - this._lastRequestAt,
sinceLastRequestMs: budget.sinceLastRequestMs,
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
requestsThisMinute: this._minuteCount,
requestsThisMinute: budget.requestsThisMinute,
maxRequestsPerMinute: config.maxRequestsPerMinute,
requestsToday: this._dayCount,
requestsToday: budget.requestsToday,
dailyBudget: config.dailyBudget
})
return decision.action === 'request'
}
}
/** 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. */
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 {
@ -603,8 +671,7 @@ class SuggestionService extends EventEmitter {
return false
}
return (
current.prefix.replace(/\s+$/u, '').slice(-SUGGESTION_CONTEXT_MAX_CHARS) ===
prefix.replace(/\s+$/u, '').slice(-SUGGESTION_CONTEXT_MAX_CHARS) &&
normalizeSessionPrefix(current.prefix) === normalizeSessionPrefix(prefix) &&
current.appName === context.appName &&
current.windowTitle === context.windowTitle
)
@ -620,7 +687,7 @@ class SuggestionService extends EventEmitter {
memory: LocalMemoryContext | null = null
): boolean {
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
const trimmedPrefix = prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
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
@ -634,6 +701,7 @@ class SuggestionService extends EventEmitter {
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
@ -645,7 +713,7 @@ class SuggestionService extends EventEmitter {
phraseCount: localMemory.phraseHints.length,
appPhraseCount: localMemory.appPhraseCount
}
this._record({
this._recordSuggestion({
prefix: trimmedPrefix,
text: candidates[0],
model: 'local-memory',
@ -669,7 +737,7 @@ class SuggestionService extends EventEmitter {
): Promise<void> {
if (this._inFlight) {
// 침묵하면 "왜 안 뜨는지" 를 알 수 없다 — 진단을 남긴다.
logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._lastRequestAt}ms 경과)`)
logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._budget.lastRequestAt}ms 경과)`)
return
}
@ -679,7 +747,8 @@ class SuggestionService extends EventEmitter {
try {
const startedAt = Date.now()
const trimmedPrefix = prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
// 세션 접두 — 프롬프트 입력이자 세션 유효성 비교의 기준 (normalizeSessionPrefix 정본)
const trimmedPrefix = normalizeSessionPrefix(prefix)
const model = this.resolveModel()
if (!model) {
const config = this.readPolicyConfig()
@ -708,13 +777,10 @@ class SuggestionService extends EventEmitter {
this._inFlight = true
this._generating = true
this._lastRequestedPrefix = prefix.replace(/\s+$/u, '')
this._lastRequestedPrefix = normalizeRequestPrefix(prefix)
this._warmingUp = false
this._provenance = null
this._prevRequestAt = this._lastRequestAt
this._lastRequestAt = Date.now()
this._minuteCount += 1
this._dayCount += 1
this._budget.consume(Date.now())
// 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고
// "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다.
@ -724,6 +790,7 @@ class SuggestionService extends EventEmitter {
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._windowHandle = context.windowHandle ?? null
this._armVisibleTtl()
this.emit('updated', this.getState())
@ -775,7 +842,7 @@ class SuggestionService extends EventEmitter {
if (abort.signal.aborted) break
raw += chunk
// 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다.
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
this._noteModelWarm(model)
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
@ -886,8 +953,7 @@ class SuggestionService extends EventEmitter {
const latencyMs = Date.now() - startedAt
this._lastLatencyMs = latencyMs
this._consecutiveFailures = 0
this._cooldownUntil = 0
this._budget.noteSuccess()
this._partialText = ''
this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index }))
this._activeIndex = 0
@ -895,6 +961,7 @@ class SuggestionService extends EventEmitter {
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
@ -906,7 +973,7 @@ class SuggestionService extends EventEmitter {
appPhraseCount: memory.appPhraseCount
}
this._record({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
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())
@ -999,7 +1066,7 @@ class SuggestionService extends EventEmitter {
for await (const chunk of stream) {
if (abort.signal.aborted) break
raw += chunk
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
this._noteModelWarm(model)
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
}
if (abort.signal.aborted || token !== this._generationToken) return false
@ -1013,7 +1080,7 @@ class SuggestionService extends EventEmitter {
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
this._armVisibleTtl()
this.emit('updated', this.getState())
this._record({
this._recordSuggestion({
prefix,
text: candidateText,
model,
@ -1062,7 +1129,7 @@ class SuggestionService extends EventEmitter {
/** 마지막 생성 이후 흐른 시간 (ms). */
get inFlightAgeMs(): number {
return this._inFlight ? Date.now() - this._lastRequestAt : 0
return this._inFlight ? Date.now() - this._budget.lastRequestAt : 0
}
/** 응답 제한 (설정 → SSOT 기본값). */
@ -1073,7 +1140,7 @@ class SuggestionService extends EventEmitter {
/** 현재 정책 기준의 접두. */
private _currentPrefix(context: TypingContext): string {
return context.prefix.replace(/\s+$/u, '')
return normalizeRequestPrefix(context.prefix)
}
/** 판단 입력을 서명이 바뀔 때만 남긴다. */
@ -1108,11 +1175,7 @@ class SuggestionService extends EventEmitter {
* (실측 신고). 임계 도달 시 쿨다운 동안은 요청 자체를 하지 않는다.
*/
private _noteFailure(reason: string): void {
this._consecutiveFailures += 1
if (this._consecutiveFailures < SUGGESTION_DEFAULTS.failureCooldownThreshold) return
this._cooldownUntil = Date.now() + SUGGESTION_DEFAULTS.failureCooldownMs
this._consecutiveFailures = 0
if (!this._budget.noteFailure(Date.now())) return
logger.warn(
`제안 생성 연속 실패 (${reason}) — ${Math.round(
SUGGESTION_DEFAULTS.failureCooldownMs / 1000
@ -1120,6 +1183,11 @@ class SuggestionService extends EventEmitter {
)
}
/** 이 모델이 방금 응답했다 — 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(() => {
@ -1149,9 +1217,7 @@ class SuggestionService extends EventEmitter {
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
this._lastRequestAt = this._prevRequestAt
this._minuteCount = Math.max(0, this._minuteCount - 1)
this._dayCount = Math.max(0, this._dayCount - 1)
this._budget.refund()
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
if (this._candidates.length === 0) this.dismiss('stale')
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
@ -1165,7 +1231,7 @@ class SuggestionService extends EventEmitter {
*/
private _watchdog(): void {
if (!this._inFlight) return
const age = Date.now() - this._lastRequestAt
const age = Date.now() - this._budget.lastRequestAt
if (age < this.readTimeoutMs() + 5000) return
logger.warn(`제안 생성 플래그 누수 감지 (${age}ms) — 강제 해제`)
this._generationToken += 1
@ -1264,6 +1330,7 @@ class SuggestionService extends EventEmitter {
const text = candidate.text
const appName = this._appName
const windowTitle = this._windowTitle
const targetWindow = this._windowHandle
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
this.dismiss('accepted')
@ -1275,6 +1342,21 @@ class SuggestionService extends EventEmitter {
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(
@ -1282,57 +1364,30 @@ class SuggestionService extends EventEmitter {
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._markAccepted(text)
this._repository.markAccepted(text)
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' })
this._learning.recordAccepted(text, { appName, windowTitle })
return { ok: true }
}
getHistory(limit = 50): SuggestionHistoryEntry[] {
try {
const db = getDatabase()
return db
.select()
.from(suggestions)
.orderBy(desc(suggestions.createdAt))
.limit(Math.max(1, Math.min(limit, 200)))
.all()
.map((row) => ({
id: row.id,
appName: row.appName,
prefixText: row.prefixText,
suggestionText: row.suggestionText,
model: row.model,
latencyMs: row.latencyMs,
accepted: row.accepted,
createdAt: row.createdAt
}))
} catch (error) {
logger.warn(`제안 이력 조회 실패: ${error instanceof Error ? error.message : String(error)}`)
return []
}
return this._repository.list(limit)
}
// ── 내부 ──────────────────────────────────────────────
private readPolicyConfig(): {
triggerDelayMs: number
minPrefixChars: number
maxRequestsPerMinute: number
dailyBudget: number
maxCandidates: number
maxChars: number
excludedApps: string[]
} {
private readPolicyConfig(): PolicyConfig {
return {
triggerDelayMs: Math.max(
SUGGESTION_DEFAULTS.minTriggerDelayMs,
@ -1353,61 +1408,8 @@ class SuggestionService extends EventEmitter {
}
}
/** 분/일 카운터 롤링. */
private _rollCounters(now: number): void {
if (now - this._minuteWindowStart >= 60000) {
this._minuteWindowStart = now
this._minuteCount = 0
}
const dayKey = new Date(now).toDateString()
if (dayKey !== this._dayKey) {
this._dayKey = dayKey
this._dayCount = 0
}
}
private _record(input: {
prefix: string
text: string
model: string
latencyMs: number
candidateCount: number
}): void {
try {
const db = getDatabase()
db.insert(suggestions)
.values({
id: crypto.randomUUID(),
appName: this._appName,
prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS),
suggestionText: input.text,
candidateCount: input.candidateCount,
model: input.model,
latencyMs: input.latencyMs,
accepted: false,
createdAt: Date.now()
})
.run()
} catch (error) {
logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
}
}
private _markAccepted(text: string): void {
try {
const db = getDatabase()
const row = db
.select({ id: suggestions.id })
.from(suggestions)
.where(eq(suggestions.suggestionText, text))
.orderBy(desc(suggestions.createdAt))
.limit(1)
.get()
if (!row) return
db.update(suggestions).set({ accepted: true }).where(eq(suggestions.id, row.id)).run()
} catch (error) {
logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
}
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void {
this._repository.record({ ...input, appName: this._appName })
}
/** 테스트/진단 — 현재 앱이 제외 대상인지. */