From 190b6db284003773e16a8f728446fe2e2b497f6b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:42 +0900 Subject: [PATCH] refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators --- .../src/main/ipc/suggestion-handlers.ts | 4 + .../main/services/InputTelemetryService.ts | 100 ++- .../src/main/services/PersonalGraphService.ts | 18 +- .../src/main/services/SuggestionRepository.ts | 111 ++++ .../src/main/services/SuggestionService.ts | 618 +++++++++--------- .../services/suggestion/ModelWarmTracker.ts | 54 ++ .../services/suggestion/SuggestionBudget.ts | 169 +++++ .../input-telemetry-redteam-r1-1.test.ts | 181 +++++ .../services/suggestion-redteam-r1-1.test.ts | 415 ++++++++++++ .../__tests__/suggestion-redteam-r1-1.test.ts | 219 +++++++ packages/core/src/input-intelligence.ts | 335 +++++----- packages/core/src/suggestion-text.ts | 217 ++++++ packages/core/src/terminal-prompt.ts | 49 ++ 13 files changed, 1994 insertions(+), 496 deletions(-) create mode 100644 apps/desktop/src/main/services/SuggestionRepository.ts create mode 100644 apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts create mode 100644 apps/desktop/src/main/services/suggestion/SuggestionBudget.ts create mode 100644 apps/desktop/tests/main/services/input-telemetry-redteam-r1-1.test.ts create mode 100644 apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts create mode 100644 packages/core/__tests__/suggestion-redteam-r1-1.test.ts create mode 100644 packages/core/src/suggestion-text.ts create mode 100644 packages/core/src/terminal-prompt.ts diff --git a/apps/desktop/src/main/ipc/suggestion-handlers.ts b/apps/desktop/src/main/ipc/suggestion-handlers.ts index 9258fea..b38124e 100644 --- a/apps/desktop/src/main/ipc/suggestion-handlers.ts +++ b/apps/desktop/src/main/ipc/suggestion-handlers.ts @@ -46,6 +46,8 @@ export function registerSuggestionHandlers(): void { async (_event, params: SetSuggestionConfigParams) => { try { const service = getSuggestionService() + const modelChangeRequested = params.modelId !== undefined + const previousModel = modelChangeRequested ? service.resolveModel() : null if (params.enabled !== undefined) service.setEnabled(params.enabled === true) if (params.modelId !== undefined) configSet('suggestionModelId', params.modelId || null) if (params.triggerDelayMs !== undefined) { @@ -75,6 +77,8 @@ export function registerSuggestionHandlers(): void { } service.applyConfig() + // 새 모델은 콜드일 수 있다 — 첫 멈춤에서 타임아웃되기 전에 미리 올린다. + if (modelChangeRequested && service.resolveModel() !== previousModel) service.handleModelChanged() return ipcSuccess(service.getState()) } catch (error) { return ipcError( diff --git a/apps/desktop/src/main/services/InputTelemetryService.ts b/apps/desktop/src/main/services/InputTelemetryService.ts index d21dd38..f5b742f 100644 --- a/apps/desktop/src/main/services/InputTelemetryService.ts +++ b/apps/desktop/src/main/services/InputTelemetryService.ts @@ -84,6 +84,11 @@ export interface TypingContext { available: boolean appName: string | null windowTitle: string | null + /** + * 입력창이 속한 최상위 창 핸들 (HWND). 알 수 없으면 null. + * 제안 수락 직전 "아직 같은 창인가" 를 확인하는 기준이다. + */ + windowHandle?: number | null idleMs: number capturedAt: number /** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */ @@ -105,6 +110,13 @@ interface ForegroundInfo { } const MAX_SAMPLE_CHARS = 500 +/** + * 프로그램 삽입(제안 수락 등) 예고의 유효 시간. + * + * 수락은 수정자 키가 떼어지길 최대 1.5초 기다린 뒤 붙여넣고, 스냅샷은 디바운스(700ms)· + * settle·UIA 조회(최대 1.5초)를 거쳐 온다 — 그 합을 넉넉히 덮는다. + */ +const PROGRAMMATIC_INSERT_TTL_MS = 10_000 const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000 const MAX_READABILITY_APPS = 64 @@ -187,6 +199,9 @@ class InputTelemetryService extends EventEmitter { private _lastSnapshotSummary: InputSnapshotSummary | null = null /** 원문 없이 앱별 UIA 읽기 가능성만 누적한다. */ private _appReadability = new Map() + /** 곧(또는 방금) 프로그램이 삽입한 텍스트 — 다음 diff 에서 "타이핑" 으로 학습하지 않는다. */ + private _expectedInserts: Array<{ id: number; text: string; expiresAt: number }> = [] + private _expectedInsertSeq = 0 private _prunedAt = 0 @@ -584,6 +599,7 @@ class InputTelemetryService extends EventEmitter { available: snapshot.available, appName: this._foreground.appName, windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle, + windowHandle: this._foreground.hwnd, idleMs, capturedAt: snapshot.capturedAt, editedSinceFocus: false, @@ -705,8 +721,9 @@ class InputTelemetryService extends EventEmitter { bucket.words += delta.insertedWords bucket.sentences += delta.insertedSentences - if (delta.insertedText) { - this._learnText(delta.insertedText, { + const typedText = delta.insertedText ? this._withoutExpectedInsert(delta.insertedText) : '' + if (typedText) { + this._learnText(typedText, { appName: this._foreground.appName, windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle, source: 'typed' @@ -714,6 +731,66 @@ class InputTelemetryService extends EventEmitter { } } + /** + * 곧 프로그램이 이 텍스트를 삽입한다 (제안 수락 · 받아쓰기 삽입). + * + * 삽입된 텍스트는 다음 스냅샷 diff 에 그대로 나타난다. 붙여넣기 판정 임계(200자)보다 + * 짧으면 "타이핑" 으로 다시 학습돼, 삽입 경로가 이미 출처(source)를 붙여 기록한 문장이 + * 두 번 쌓이고 제안이 자기 출력을 강화했다. 예고된 텍스트는 diff 학습에서 뺀다. + * + * 삽입이 실패했으면 돌려준 함수로 예고를 취소한다. + */ + expectProgrammaticInsert(text: string): () => void { + const trimmed = text.trim() + if (!trimmed) return () => undefined + const now = Date.now() + this._pruneExpectedInserts(now) + this._expectedInsertSeq += 1 + const id = this._expectedInsertSeq + this._expectedInserts.push({ id, text: trimmed, expiresAt: now + PROGRAMMATIC_INSERT_TTL_MS }) + return () => { + this._expectedInserts = this._expectedInserts.filter((entry) => entry.id !== id) + } + } + + private _pruneExpectedInserts(now: number): void { + if (this._expectedInserts.length === 0) return + this._expectedInserts = this._expectedInserts.filter((entry) => entry.expiresAt > now) + } + + /** + * diff 로 얻은 삽입 텍스트에서 예고된 프로그램 삽입을 뺀 "실제 타이핑" 부분. + * + * - 삽입 구간이 예고 텍스트를 품으면 그 부분만 빼고 나머지(앞뒤로 직접 친 글)는 남긴다. + * - diff 경계가 반복 글자에서 한 칸 밀려 회전된 형태로 잡힌 경우도 같은 텍스트로 본다. + * - 삽입 구간이 예고 텍스트의 앞부분이면(키보드 방식으로 치는 도중 잡힌 스냅샷) 전부 + * 프로그램 삽입이고, 남은 뒷부분을 계속 기다린다. + */ + private _withoutExpectedInsert(inserted: string): string { + this._pruneExpectedInserts(Date.now()) + const trimmed = inserted.trim() + if (!trimmed) return inserted + for (let i = 0; i < this._expectedInserts.length; i += 1) { + const expected = this._expectedInserts[i] + const index = inserted.indexOf(expected.text) + if (index >= 0) { + this._expectedInserts.splice(i, 1) + return `${inserted.slice(0, index)} ${inserted.slice(index + expected.text.length)}`.trim() + } + if (trimmed.length === expected.text.length && `${trimmed}${trimmed}`.includes(expected.text)) { + this._expectedInserts.splice(i, 1) + return '' + } + if (expected.text.startsWith(trimmed)) { + const remainder = expected.text.slice(trimmed.length).trim() + if (remainder) expected.text = remainder + else this._expectedInserts.splice(i, 1) + return '' + } + } + return inserted + } + /** * 외부(음성 전사 등)에서 들어온 텍스트를 코퍼스에 넣는다. * @@ -1239,11 +1316,26 @@ class InputTelemetryService extends EventEmitter { } } + /** + * 학습 문구 삭제. + * + * 그래프 엣지(phrase_edges)는 문장 텍스트를 외래 키 없이 들고 있다 — 문구 행만 지우면 + * follows 엣지를 따라 지운 문장이 제안 프롬프트와 그래프 통계에 계속 나온다. + * 문구와 그 문장을 가리키는 엣지를 한 트랜잭션에서 함께 지운다. + */ deletePhrase(id: string): boolean { try { const db = getDatabase() - const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run() - return Number(result.changes ?? 0) > 0 + return db.transaction(() => { + const row = db + .select({ phrase: personalPhrases.phrase }) + .from(personalPhrases) + .where(eq(personalPhrases.id, id)) + .get() + const result = db.delete(personalPhrases).where(eq(personalPhrases.id, id)).run() + if (row) getPersonalGraphService().removePhrase(row.phrase) + return Number(result.changes ?? 0) > 0 + }) } catch (error) { logger.warn(`개인 문구 제 실패: ${error instanceof Error ? error.message : String(error)}`) return false diff --git a/apps/desktop/src/main/services/PersonalGraphService.ts b/apps/desktop/src/main/services/PersonalGraphService.ts index 6059515..443f880 100644 --- a/apps/desktop/src/main/services/PersonalGraphService.ts +++ b/apps/desktop/src/main/services/PersonalGraphService.ts @@ -24,7 +24,7 @@ import { type RelatedCandidate } from '@d3ro/core/personal-graph' import { isLearnablePhrase, prefixTail } from '@d3ro/core/input-intelligence' -import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, or, sql } from 'drizzle-orm' import { getDatabase } from '../db' import { personalPhrases, phraseEdges } from '../db/schema' import { getLogger } from './LoggerService' @@ -357,6 +357,22 @@ class PersonalGraphService { } } + /** + * 한 문장 노드에 닿는 엣지를 모두 지운다 (사용자가 문구를 삭제했을 때). + * + * 엣지는 문장 텍스트를 외래 키 없이 들고 있어, 노드만 지우면 follows 이웃으로 + * 지운 문장이 계속 제안 문맥에 끌려 나온다. 호출자의 트랜잭션 안에서 부를 수 있도록 + * 예외를 삼키지 않는다. + */ + removePhrase(phrase: string): number { + const db = getDatabase() + const result = db + .delete(phraseEdges) + .where(or(eq(phraseEdges.fromPhrase, phrase), eq(phraseEdges.toPhrase, phrase))) + .run() + return Number(result.changes ?? 0) + } + clearAll(): void { try { const db = getDatabase() diff --git a/apps/desktop/src/main/services/SuggestionRepository.ts b/apps/desktop/src/main/services/SuggestionRepository.ts new file mode 100644 index 0000000..683c689 --- /dev/null +++ b/apps/desktop/src/main/services/SuggestionRepository.ts @@ -0,0 +1,111 @@ +// src/main/services/SuggestionRepository.ts +// +// 제안 이력 영속화 포트 — SuggestionService 가 DB 스키마에 직접 묶이지 않도록 분리한다 (DIP). +// 기본 구현은 SQLite(drizzle)이다. 실패는 기존과 같이 삼키고 경고만 남긴다 — 이력 기록 +// 실패가 제안 자체를 막으면 안 된다. + +import { desc, eq } from 'drizzle-orm' +import { SUGGESTION_CONTEXT_MAX_CHARS } from '@d3ro/core/input-intelligence' +import { getDatabase } from '../db' +import { suggestions } from '../db/schema' +import { getLogger } from './LoggerService' + +const logger = getLogger('SuggestionRepository') + +/** 이력 조회 상한 (UI 한 화면 + 여유). */ +const HISTORY_LIMIT_MAX = 200 + +export interface SuggestionHistoryEntry { + id: string + appName: string | null + prefixText: string + suggestionText: string + model: string | null + latencyMs: number | null + accepted: boolean + createdAt: number +} + +export interface SuggestionRecordInput { + appName: string | null + prefix: string + text: string + model: string + latencyMs: number + candidateCount: number +} + +export interface SuggestionRepository { + /** 제안 후보 1건을 기록한다. */ + record(input: SuggestionRecordInput): void + /** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다. */ + markAccepted(text: string): void + /** 최근 이력 (limit 는 1~200 으로 클램프). */ + list(limit: number): SuggestionHistoryEntry[] +} + +export function createSqliteSuggestionRepository(): SuggestionRepository { + return { + record(input: SuggestionRecordInput): void { + try { + getDatabase() + .insert(suggestions) + .values({ + id: crypto.randomUUID(), + appName: input.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)}`) + } + }, + + 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)}`) + } + }, + + list(limit: number): SuggestionHistoryEntry[] { + try { + return getDatabase() + .select() + .from(suggestions) + .orderBy(desc(suggestions.createdAt)) + .limit(Math.max(1, Math.min(limit, HISTORY_LIMIT_MAX))) + .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 [] + } + } + } +} diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index 2410323..c16c6e0 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -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(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, + 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, - now: number - ): boolean { - const decision = decideSuggestion({ + config: PolicyConfig, + now: number, + overrides: Partial> = {} + ): 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 { 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): void { + this._repository.record({ ...input, appName: this._appName }) } /** 테스트/진단 — 현재 앱이 제외 대상인지. */ diff --git a/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts b/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts new file mode 100644 index 0000000..a5c55a3 --- /dev/null +++ b/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts @@ -0,0 +1,54 @@ +// src/main/services/suggestion/ModelWarmTracker.ts +// +// 제안 모델의 "메모리에 떠 있음" 상태를 모델별로 추적한다 (SRP). +// +// 온기는 모델에 묶인 사실이다. 숫자 하나로만 들고 있으면 설정에서 모델을 바꾼 직후에도 +// 새(콜드) 모델을 warm 으로 판정해, 콜드 로드(실측 11.4s)가 요청 타임아웃(8s)을 넘겨 +// 연속 실패 → 60초 쿨다운으로 이어졌다. +// +// 워밍업 실패도 여기서 기록한다 — 실패한 워밍업을 매 멈춤마다 다시 쏘지 않도록 +// 잠시 물러선다(backoff). + +export class ModelWarmTracker { + private _warmModel: string | null = null + private _warmUntil = 0 + private _failedModel: string | null = null + private _failedAt = 0 + + /** 이 모델이 `until` 까지 메모리에 남아 있다고 본다. 같은 모델의 실패 기록은 지운다. */ + noteWarm(model: string, until: number): void { + this._warmModel = model + this._warmUntil = until + if (this._failedModel === model) { + this._failedModel = null + this._failedAt = 0 + } + } + + isWarm(model: string | null, now: number): boolean { + return model !== null && model === this._warmModel && now < this._warmUntil + } + + /** 워밍업이 실패했다 (모델 미설치 · 서버 오류 등). */ + noteWarmUpFailure(model: string, now: number): void { + this._failedModel = model + this._failedAt = now + } + + /** 직전 워밍업 실패 뒤 `backoffMs` 가 지나지 않았으면 재시도를 미룬다. */ + isBackingOff(model: string | null, now: number, backoffMs: number): boolean { + return model !== null && model === this._failedModel && now - this._failedAt < backoffMs + } + + /** 가장 최근 warm 으로 기록된 모델 (진단용). */ + get warmModel(): string | null { + return this._warmModel + } + + reset(): void { + this._warmModel = null + this._warmUntil = 0 + this._failedModel = null + this._failedAt = 0 + } +} diff --git a/apps/desktop/src/main/services/suggestion/SuggestionBudget.ts b/apps/desktop/src/main/services/suggestion/SuggestionBudget.ts new file mode 100644 index 0000000..61800ed --- /dev/null +++ b/apps/desktop/src/main/services/suggestion/SuggestionBudget.ts @@ -0,0 +1,169 @@ +// src/main/services/suggestion/SuggestionBudget.ts +// +// 제안 요청 예산 — 최소 간격 · 분당/일일 카운트 · 연속 실패 쿨다운 (SRP). +// +// 이 클래스는 게이트가 아니다: 요청 여부의 정본은 core `decideSuggestion` 이고, 여기서는 +// 그 입력(snapshot)과 소비/환급/실패 기록만 맡는다. 모든 메서드가 `now` 를 인자로 받아 +// 가짜 시계로 단위 테스트할 수 있다. + +import { SUGGESTION_DEFAULTS } from '@d3ro/core/input-intelligence' + +export interface SuggestionBudgetSnapshot { + sinceLastRequestMs: number + requestsThisMinute: number + requestsToday: number +} + +export interface SuggestionBudgetOptions { + failureCooldownThreshold: number + failureCooldownMs: number +} + +const MINUTE_MS = 60_000 + +function dayKeyOf(now: number): string { + return new Date(now).toDateString() +} + +export class SuggestionBudget { + private _lastRequestAt = 0 + /** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */ + private _prevRequestAt = 0 + private _minuteWindowStart = 0 + private _minuteCount = 0 + private _dayKey = '' + private _dayCount = 0 + /** 마지막 소비가 속한 창 — 창이 바뀐 뒤의 환급이 새 창을 깎지 않도록. */ + private _consumedMinuteWindow = -1 + private _consumedDayKey = '' + private _consecutiveFailures = 0 + private _cooldownUntil = 0 + + constructor( + private readonly _options: SuggestionBudgetOptions = { + failureCooldownThreshold: SUGGESTION_DEFAULTS.failureCooldownThreshold, + failureCooldownMs: SUGGESTION_DEFAULTS.failureCooldownMs + } + ) {} + + get lastRequestAt(): number { + return this._lastRequestAt + } + + get prevRequestAt(): number { + return this._prevRequestAt + } + + get minuteCount(): number { + return this._minuteCount + } + + get dayCount(): number { + return this._dayCount + } + + get consecutiveFailures(): number { + return this._consecutiveFailures + } + + get cooldownUntil(): number { + return this._cooldownUntil + } + + /** 분/일 창을 굴린다. */ + roll(now: number): void { + if (now - this._minuteWindowStart >= MINUTE_MS) { + this._minuteWindowStart = now + this._minuteCount = 0 + } + const dayKey = dayKeyOf(now) + if (dayKey !== this._dayKey) { + this._dayKey = dayKey + this._dayCount = 0 + } + } + + /** 정책 입력으로 쓸 현재 값 (창을 먼저 굴린다). */ + snapshot(now: number): SuggestionBudgetSnapshot { + this.roll(now) + return { + sinceLastRequestMs: now - this._lastRequestAt, + requestsThisMinute: this._minuteCount, + requestsToday: this._dayCount + } + } + + /** 오늘 요청 수 (창을 먼저 굴린다). */ + requestsToday(now: number): number { + this.roll(now) + return this._dayCount + } + + /** + * 요청 1건을 소비한다. + * + * 반드시 창을 먼저 굴린다 — 굴리지 않고 올리면 직후의 getState() 가 창을 굴리며 + * 방금 올린 카운트를 지워, 오래 쉰 뒤의 수동 요청이 예산에 잡히지 않았다. + */ + consume(now: number): void { + this.roll(now) + this._prevRequestAt = this._lastRequestAt + this._lastRequestAt = now + this._minuteCount += 1 + this._dayCount += 1 + this._consumedMinuteWindow = this._minuteWindowStart + this._consumedDayKey = this._dayKey + } + + /** + * 취소된 요청의 부담을 되돌린다. + * + * 소비 이후 창이 바뀌었으면 그 창의 카운트는 이미 0부터 다시 셌으므로 건드리지 않는다. + */ + refund(): void { + this._lastRequestAt = this._prevRequestAt + if (this._consumedMinuteWindow === this._minuteWindowStart) { + this._minuteCount = Math.max(0, this._minuteCount - 1) + } + if (this._consumedDayKey === this._dayKey) { + this._dayCount = Math.max(0, this._dayCount - 1) + } + this._consumedMinuteWindow = -1 + this._consumedDayKey = '' + } + + /** 최소 간격 제한을 푼다 (분당·일일 상한은 그대로). */ + relaxInterval(now: number, minIntervalMs: number): void { + this._lastRequestAt = Math.min(this._lastRequestAt, now - minIntervalMs) + } + + /** + * 마지막 요청 시각을 직접 맞춘다. + * + * 진행 플래그 누수 감시(watchdog)의 경과 시간을 재현하는 진단·테스트 경로 전용이다. + */ + overrideLastRequestAt(at: number): void { + this._lastRequestAt = at + } + + /** + * 실패를 기록한다. 임계에 도달하면 쿨다운을 시작하고 true 를 돌려준다. + */ + noteFailure(now: number): boolean { + this._consecutiveFailures += 1 + if (this._consecutiveFailures < this._options.failureCooldownThreshold) return false + this._cooldownUntil = now + this._options.failureCooldownMs + this._consecutiveFailures = 0 + return true + } + + /** 성공하면 실패 누적과 쿨다운을 지운다. */ + noteSuccess(): void { + this._consecutiveFailures = 0 + this._cooldownUntil = 0 + } + + isCoolingDown(now: number): boolean { + return now < this._cooldownUntil + } +} diff --git a/apps/desktop/tests/main/services/input-telemetry-redteam-r1-1.test.ts b/apps/desktop/tests/main/services/input-telemetry-redteam-r1-1.test.ts new file mode 100644 index 0000000..b8dc8c1 --- /dev/null +++ b/apps/desktop/tests/main/services/input-telemetry-redteam-r1-1.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDb } from '../../helpers/createTestDb' +import { personalPhrases, phraseEdges, typingSamples } from '../../../src/main/db/schema' + +const holder = vi.hoisted(() => ({ + db: null as unknown, + config: new Map() +})) + +vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } })) +vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } })) +vi.mock('../../../src/main/db', async (importOriginal) => ({ + ...(await importOriginal()), + getDatabase: () => holder.db +})) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: (key: string) => holder.config.get(key), + configSet: (key: string, value: unknown) => holder.config.set(key, value) +})) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) +})) +vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined })) +vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null })) +vi.mock('../../../src/main/services/UiaContextService', () => ({ + getUiaContextService: () => ({ isAvailable: () => false, lastReason: 'test', lastSuccessAt: 0 }) +})) +vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) + +import { + getInputTelemetryService, + resetInputTelemetryServiceForTests +} from '../../../src/main/services/InputTelemetryService' +import { + getPersonalGraphService, + resetPersonalGraphServiceForTests +} from '../../../src/main/services/PersonalGraphService' + +let testDb: ReturnType + +interface TelemetryInternal { + _foreground: { appName: string | null; windowTitle: string | null; hwnd: number | null } + _applyTypedDelta(snapshot: { windowTitle: string | null; controlName?: string; controlType?: string; text: string }): void +} + +function snapshot(text: string) { + return { windowTitle: '채널', controlName: '메시지', controlType: 'Edit', text } +} + +function typedSamples(): string[] { + return testDb.db + .select() + .from(typingSamples) + .all() + .filter((row) => row.source === 'typed') + .map((row) => row.text) +} + +beforeEach(() => { + testDb = createTestDb() + holder.db = testDb.db + holder.config.clear() + holder.config.set('inputLearnTypedText', true) + holder.config.set('inputExcludedApps', []) + resetInputTelemetryServiceForTests() + resetPersonalGraphServiceForTests() + const telemetry = getInputTelemetryService() as unknown as TelemetryInternal + telemetry._foreground.appName = 'Slack.exe' +}) + +afterEach(() => { + testDb.close() +}) + +describe('버그 3: 프로그램이 삽입한 텍스트를 타이핑으로 다시 학습하지 않는다', () => { + const ACCEPTED = '다음 회의는 내일 오후 세 시에 하겠습니다' + + it('예고된 제안 수락 삽입은 diff 에서 typed 로 학습하지 않는다', () => { + const telemetry = getInputTelemetryService() + const internal = telemetry as unknown as TelemetryInternal + internal._applyTypedDelta(snapshot('안녕하세요 여러분 ')) + + telemetry.expectProgrammaticInsert(ACCEPTED) + internal._applyTypedDelta(snapshot(`안녕하세요 여러분 ${ACCEPTED}`)) + + expect(typedSamples()).toEqual([]) + }) + + it('삽입 앞뒤로 직접 친 부분만 학습한다', () => { + const telemetry = getInputTelemetryService() + const internal = telemetry as unknown as TelemetryInternal + internal._applyTypedDelta(snapshot('시작 ')) + + telemetry.expectProgrammaticInsert(ACCEPTED) + internal._applyTypedDelta(snapshot(`시작 ${ACCEPTED} 확인 부탁드립니다`)) + + expect(typedSamples()).toEqual(['확인 부탁드립니다']) + }) + + it('예고는 한 번만 소비된다 — 같은 문장을 나중에 직접 치면 학습한다', () => { + const telemetry = getInputTelemetryService() + const internal = telemetry as unknown as TelemetryInternal + internal._applyTypedDelta(snapshot('')) + internal._applyTypedDelta(snapshot('첫 줄 ')) + telemetry.expectProgrammaticInsert(ACCEPTED) + internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}`)) + internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}\n${ACCEPTED}`)) + + expect(typedSamples()).toContain(ACCEPTED) + }) + + it('삽입이 실패해 예고를 취소하면 이후 직접 친 같은 글은 학습한다', () => { + const telemetry = getInputTelemetryService() + const internal = telemetry as unknown as TelemetryInternal + internal._applyTypedDelta(snapshot('앞 ')) + const cancel = telemetry.expectProgrammaticInsert(ACCEPTED) + cancel() + internal._applyTypedDelta(snapshot(`앞 ${ACCEPTED}`)) + + expect(typedSamples()).toEqual([ACCEPTED]) + }) + + it('키보드 방식으로 치는 도중 잡힌 앞부분도 학습하지 않고 나머지를 기다린다', () => { + const telemetry = getInputTelemetryService() + const internal = telemetry as unknown as TelemetryInternal + internal._applyTypedDelta(snapshot('')) + internal._applyTypedDelta(snapshot('x ')) + telemetry.expectProgrammaticInsert(ACCEPTED) + internal._applyTypedDelta(snapshot(`x ${ACCEPTED.slice(0, 8)}`)) + internal._applyTypedDelta(snapshot(`x ${ACCEPTED}`)) + + expect(typedSamples()).toEqual([]) + }) +}) + +describe('버그 9: 문구 삭제는 그 문장의 그래프 엣지도 지운다', () => { + const ANCHOR = '오늘 회의 결과를 공유드립니다' + const PRIVATE = '계좌 비밀번호는 따로 보내드리겠습니다' + + it('삭제한 문장이 follows 이웃·통계·조회에 남지 않는다', () => { + const graph = getPersonalGraphService() + graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' }) + expect(graph.retrieveContext('오늘 회의 결과를').related).toContain(PRIVATE) + + const row = testDb.db + .select() + .from(personalPhrases) + .where(eq(personalPhrases.phrase, PRIVATE)) + .get() + expect(row).toBeDefined() + + expect(getInputTelemetryService().deletePhrase(row?.id ?? '')).toBe(true) + + const edges = testDb.db.select().from(phraseEdges).all() + expect(edges.filter((edge) => edge.fromPhrase === PRIVATE || edge.toPhrase === PRIVATE)).toEqual([]) + expect(graph.retrieveContext('오늘 회의 결과를').related).not.toContain(PRIVATE) + expect(graph.getStats().topEdges.some((edge) => edge.to === PRIVATE || edge.from === PRIVATE)).toBe(false) + expect(graph.query('오늘 회의').neighbors.some((neighbor) => neighbor.text === PRIVATE)).toBe(false) + }) + + it('없는 id 삭제는 false 이고 다른 엣지를 건드리지 않는다', () => { + const graph = getPersonalGraphService() + graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' }) + const before = testDb.db.select().from(phraseEdges).all().length + + expect(getInputTelemetryService().deletePhrase('missing')).toBe(false) + expect(testDb.db.select().from(phraseEdges).all()).toHaveLength(before) + }) + + it('removePhrase 는 양방향 엣지를 모두 지우고 지운 수를 돌려준다', () => { + const graph = getPersonalGraphService() + graph.indexText(`${ANCHOR}. ${PRIVATE}. 확인 후 다시 연락드리겠습니다.`, { source: 'typed' }) + + const removed = graph.removePhrase(PRIVATE) + + expect(removed).toBeGreaterThanOrEqual(2) + const remaining = testDb.db.select().from(phraseEdges).all() + expect(remaining.every((edge) => edge.fromPhrase !== PRIVATE && edge.toPhrase !== PRIVATE)).toBe(true) + }) +}) diff --git a/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts b/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts new file mode 100644 index 0000000..36f29cd --- /dev/null +++ b/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts @@ -0,0 +1,415 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const config = vi.hoisted(() => ({ + suggestionEnabled: true as boolean, + suggestionModelId: 'model-a' as string | null, + llmModelId: 'model-a' as string | null, + inputExcludedApps: [] as string[], + suggestionTriggerDelayMs: 600, + suggestionMinPrefixChars: 8, + suggestionMaxRequestsPerMinute: 12, + suggestionDailyBudget: 500, + suggestionRequestTimeoutMs: 8000, + inputLearnTypedText: false, + inputTelemetryEnabled: false, + suggestionOverlayInteractive: true, + insertMethod: 'clipboard' +})) +const harness = vi.hoisted(() => ({ + isAvailable: vi.fn(() => true), + streamGenerate: vi.fn(), + insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })), + continuations: [] as string[] +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: keyof typeof config) => config[key]), + configSet: vi.fn() +})) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate }) +})) +vi.mock('../../../src/main/services/InputTelemetryService', () => ({ + getInputTelemetryService: () => ({ listPhrases: () => [] }) +})) +vi.mock('../../../src/main/services/PersonalGraphService', () => ({ + getPersonalGraphService: () => ({ + retrieveContext: () => ({ continuations: harness.continuations, related: [] }) + }) +})) +vi.mock('../../../src/main/services/TextInsertService', () => ({ + getTextInsertService: () => ({ insertText: harness.insertText }) +})) +vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true })) +vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) +vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) +vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) + +import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService' +import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget' +import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker' +import type { TypingContext } from '../../../src/main/services/InputTelemetryService' + +const SHORT_PREFIX = '오늘 회의에서 논의한 내용을 정리해서' +const LONG_PREFIX = `${'가나다라 '.repeat(100)}${SHORT_PREFIX}` + +function typingContext(overrides: Partial = {}): TypingContext { + const prefix = overrides.prefix ?? SHORT_PREFIX + return { + prefix, + fullText: prefix, + caretOffset: prefix.length, + anchor: null, + anchorKind: null, + isPassword: false, + isEditable: true, + isComposing: false, + hasSelection: false, + available: true, + appName: 'chrome.exe', + windowTitle: 'Gmail', + windowHandle: 101, + idleMs: 2000, + capturedAt: Date.now(), + editedSinceFocus: true, + typedRecently: true, + ...overrides + } +} + +interface Fakes { + deps: SuggestionServiceDeps + record: ReturnType + foregroundHandle: { value: number | null } + expectInsert: ReturnType + cancelInsert: ReturnType + recordAccepted: ReturnType +} + +function fakes(): Fakes { + const record = vi.fn() + const foregroundHandle = { value: 101 as number | null } + const cancelInsert = vi.fn() + const expectInsert = vi.fn(() => cancelInsert) + const recordAccepted = vi.fn() + return { + record, + foregroundHandle, + expectInsert, + cancelInsert, + recordAccepted, + deps: { + repository: { record, markAccepted: vi.fn(), list: vi.fn(() => []) }, + budget: new SuggestionBudget(), + warmth: new ModelWarmTracker(), + foreground: { currentWindowHandle: () => foregroundHandle.value }, + learning: { expectProgrammaticInsert: expectInsert, recordAccepted } + } + } +} + +function streamOf(text: string) { + return (async function* () { + yield text + })() +} + +let services: SuggestionService[] = [] +function create(deps: SuggestionServiceDeps): SuggestionService { + const service = new SuggestionService(deps) + services.push(service) + return service +} + +interface Internal { + _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise + _warmUpPromise: Promise | null + _lastSkipReason: string | null +} + +beforeEach(() => { + config.suggestionEnabled = true + config.suggestionModelId = 'model-a' + config.llmModelId = 'model-a' + harness.isAvailable.mockReset() + harness.isAvailable.mockReturnValue(true) + harness.streamGenerate.mockReset() + harness.insertText.mockClear() + harness.continuations = [] +}) + +afterEach(() => { + for (const service of services) service.dispose() + services = [] + vi.useRealTimers() +}) + +describe('버그 1: 400자 넘는 접두에서 세션이 stale 로 닫히지 않는다', () => { + it('같은 긴 접두의 다음 스냅샷에서도 세션이 유지되고 재생성하지 않는다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) + const { deps } = fakes() + deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = create(deps) + const internal = service as unknown as Internal + const context = typingContext({ prefix: LONG_PREFIX }) + expect(LONG_PREFIX.length).toBeGreaterThan(400) + + await internal._generate(LONG_PREFIX, context, 3, 160) + expect(service.isVisible).toBe(true) + + service.handleTypingContext(context) + + expect(service.isVisible).toBe(true) + expect(service.getState().lastSkipReason).not.toBe('stale') + // 세션이 유지되므로 새 첫 요청(예산 소비)이 일어나지 않는다. + expect(deps.budget.minuteCount).toBe(1) + }) + + it('긴 접두에서도 이어 치면 세션을 끝낸다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) + const { deps } = fakes() + deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = create(deps) + await (service as unknown as Internal)._generate(LONG_PREFIX, typingContext({ prefix: LONG_PREFIX }), 3, 160) + + service.handleTypingContext(typingContext({ prefix: `${LONG_PREFIX} 그리고`, idleMs: 100 })) + + expect(service.isVisible).toBe(false) + expect(service.getState().lastSkipReason).toBe('stale') + }) +}) + +describe('버그 2: 쿨다운 중 표시된 로컬 기억 세션', () => { + function coolingService(): { service: SuggestionService; fake: Fakes } { + const fake = fakes() + fake.deps.budget.noteFailure(Date.now()) + fake.deps.budget.noteFailure(Date.now()) + expect(fake.deps.budget.isCoolingDown(Date.now())).toBe(true) + harness.continuations = ['첫 번째 이어쓰기 문장입니다', '두 번째 이어쓰기 문장입니다'] + const service = create(fake.deps) + service.handleTypingContext(typingContext()) + expect(service.getState()).toMatchObject({ visible: true, provenance: { mode: 'local-memory' } }) + expect(fake.record).toHaveBeenCalledTimes(1) + return { service, fake } + } + + it('다음 후보 단축키(키 입력 직후 스냅샷)로 세션이 닫히지 않는다', () => { + const { service } = coolingService() + service.next() + service.handleTypingContext(typingContext({ idleMs: 100 })) + expect(service.getState()).toMatchObject({ visible: true, activeIndex: 1 }) + }) + + it('멈춘 상태의 주기 스냅샷이 선택을 초기화하거나 이력을 중복 기록하지 않는다', () => { + const { service, fake } = coolingService() + service.next() + service.handleTypingContext(typingContext({ idleMs: 2000 })) + service.handleTypingContext(typingContext({ idleMs: 2800 })) + expect(service.getState().activeIndex).toBe(1) + expect(fake.record).toHaveBeenCalledTimes(1) + }) + + it('타이핑이 끊긴 뒤(not-typing)에도 읽는 중인 세션을 닫지 않는다', () => { + const { service } = coolingService() + service.handleTypingContext(typingContext({ typedRecently: false, idleMs: 9000 })) + expect(service.isVisible).toBe(true) + }) + + it('비밀번호 칸으로 옮기면 쿨다운 중에도 닫는다', () => { + const { service } = coolingService() + service.handleTypingContext(typingContext({ isPassword: true })) + expect(service.isVisible).toBe(false) + }) +}) + +describe('버그 4: 모델 변경 뒤 콜드 모델로 바로 생성하지 않는다', () => { + it('이전 모델이 warm 이어도 새 모델은 워밍업부터 한다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf('ok')) + const { deps } = fakes() + deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = create(deps) + const internal = service as unknown as Internal + const generateSpy = vi.spyOn(internal, '_generate') + + config.suggestionModelId = 'model-b' + service.handleTypingContext(typingContext()) + + expect(generateSpy).not.toHaveBeenCalled() + expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b', maxTokens: 1 })) + expect(service.getState().lastSkipReason).toBe('model-unavailable') + await internal._warmUpPromise + expect(deps.warmth.isWarm('model-b', Date.now())).toBe(true) + }) + + it('handleModelChanged 는 새 콜드 모델을 미리 워밍업한다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf('ok')) + const { deps } = fakes() + deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = create(deps) + + service.handleModelChanged() + expect(harness.streamGenerate).not.toHaveBeenCalled() + + config.suggestionModelId = 'model-b' + service.handleModelChanged() + await (service as unknown as Internal)._warmUpPromise + expect(harness.streamGenerate).toHaveBeenCalledTimes(1) + expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b' })) + }) +}) + +describe('버그 5: 실패한 워밍업은 실패로 센다', () => { + it('워밍업 실패 뒤 다음 멈춤에서 워밍업을 바로 다시 쏘지 않고, 두 번째 실패로 쿨다운에 들어간다', async () => { + vi.useFakeTimers() + harness.streamGenerate.mockImplementation(() => + (async function* () { + throw new Error('Ollama responded with 404') + })() + ) + const { deps } = fakes() + const service = create(deps) + const internal = service as unknown as Internal + + service.handleTypingContext(typingContext()) + await internal._warmUpPromise + expect(harness.streamGenerate).toHaveBeenCalledTimes(1) + expect(deps.budget.consecutiveFailures).toBe(1) + expect(service.getState().lastSkipReason).toBe('generation-failed') + + // 800ms 뒤 샘플 — 실패 직후라 워밍업을 다시 쏘지 않는다. + vi.advanceTimersByTime(800) + service.handleTypingContext(typingContext()) + expect(harness.streamGenerate).toHaveBeenCalledTimes(1) + expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'generation-failed' }) + + // 물러서기 시간이 지나면 한 번 더 시도하고, 그 실패로 쿨다운에 들어간다. + vi.advanceTimersByTime(5000) + service.handleTypingContext(typingContext()) + await internal._warmUpPromise + expect(harness.streamGenerate).toHaveBeenCalledTimes(2) + expect(deps.budget.isCoolingDown(Date.now())).toBe(true) + expect(service.getState().coolingDown).toBe(true) + }) +}) + +describe('버그 6: 수락 직전 대상 창 확인', () => { + async function visibleService(fake: Fakes): Promise { + harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) + fake.deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = create(fake.deps) + await (service as unknown as Internal)._generate(SHORT_PREFIX, typingContext({ windowHandle: 101 }), 3, 160) + expect(service.isVisible).toBe(true) + return service + } + + it('포커스가 다른 창으로 옮겨 갔으면 붙여넣지 않는다', async () => { + const fake = fakes() + const service = await visibleService(fake) + fake.foregroundHandle.value = 202 + + const result = await service.accept() + + expect(result).toEqual({ ok: false, reason: 'stale' }) + expect(harness.insertText).not.toHaveBeenCalled() + expect(fake.expectInsert).not.toHaveBeenCalled() + }) + + it('같은 창이면 삽입하고, 삽입 예고와 수락 학습을 한 번씩 알린다', async () => { + const fake = fakes() + const service = await visibleService(fake) + + const result = await service.accept() + + expect(result).toEqual({ ok: true }) + expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard') + expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.') + expect(fake.recordAccepted).toHaveBeenCalledTimes(1) + expect(fake.cancelInsert).not.toHaveBeenCalled() + }) + + it('삽입이 실패하면 삽입 예고를 취소한다', async () => { + const fake = fakes() + const service = await visibleService(fake) + harness.insertText.mockResolvedValueOnce({ success: false, method: 'clipboard', textLength: 0 }) + + const result = await service.accept() + + expect(result.ok).toBe(false) + expect(fake.cancelInsert).toHaveBeenCalledTimes(1) + expect(fake.recordAccepted).not.toHaveBeenCalled() + }) + + it('창을 알 수 없으면(포그라운드 조회 실패) 기존처럼 삽입한다', async () => { + const fake = fakes() + const service = await visibleService(fake) + fake.foregroundHandle.value = null + + expect(await service.accept()).toEqual({ ok: true }) + }) +}) + +describe('SuggestionBudget', () => { + it('consume 은 창을 먼저 굴린다 — 오래 쉰 뒤의 요청도 예산에 잡힌다', () => { + const budget = new SuggestionBudget() + const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime() + budget.consume(t0) + const later = t0 + 120_000 + budget.consume(later) + // 직후의 상태 조회(창 굴리기)가 방금 소비를 지우면 안 된다. + expect(budget.snapshot(later + 10)).toMatchObject({ requestsThisMinute: 1, requestsToday: 2 }) + }) + + it('창이 바뀐 뒤의 환급은 새 창의 카운트를 깎지 않는다', () => { + const budget = new SuggestionBudget() + const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime() + budget.consume(t0) + budget.consume(t0 + 61_000) + budget.snapshot(t0 + 61_000) + budget.consume(t0 + 62_000) + budget.refund() + expect(budget.snapshot(t0 + 62_000)).toMatchObject({ requestsThisMinute: 1 }) + expect(budget.lastRequestAt).toBe(t0 + 61_000) + + const other = new SuggestionBudget() + other.consume(t0) + other.snapshot(t0 + 61_000) + other.refund() + expect(other.minuteCount).toBe(0) + expect(other.dayCount).toBe(0) + }) + + it('연속 실패 임계에서 쿨다운, 성공이면 초기화, relaxInterval 은 최소 간격만 푼다', () => { + const budget = new SuggestionBudget({ failureCooldownThreshold: 2, failureCooldownMs: 60_000 }) + expect(budget.noteFailure(1000)).toBe(false) + expect(budget.noteFailure(2000)).toBe(true) + expect(budget.isCoolingDown(61_999)).toBe(true) + expect(budget.isCoolingDown(62_000)).toBe(false) + budget.noteSuccess() + expect(budget.isCoolingDown(3000)).toBe(false) + + budget.consume(100_000) + budget.relaxInterval(101_000, 5000) + expect(budget.snapshot(101_000)).toMatchObject({ sinceLastRequestMs: 5000, requestsThisMinute: 1 }) + }) +}) + +describe('ModelWarmTracker', () => { + it('온기와 워밍업 실패를 모델별로 기억한다', () => { + const tracker = new ModelWarmTracker() + tracker.noteWarm('a', 10_000) + expect(tracker.isWarm('a', 9_999)).toBe(true) + expect(tracker.isWarm('a', 10_000)).toBe(false) + expect(tracker.isWarm('b', 5_000)).toBe(false) + expect(tracker.isWarm(null, 5_000)).toBe(false) + + tracker.noteWarmUpFailure('b', 1_000) + expect(tracker.isBackingOff('b', 5_999, 5_000)).toBe(true) + expect(tracker.isBackingOff('b', 6_000, 5_000)).toBe(false) + expect(tracker.isBackingOff('a', 2_000, 5_000)).toBe(false) + tracker.noteWarm('b', 20_000) + expect(tracker.isBackingOff('b', 2_000, 5_000)).toBe(false) + }) +}) diff --git a/packages/core/__tests__/suggestion-redteam-r1-1.test.ts b/packages/core/__tests__/suggestion-redteam-r1-1.test.ts new file mode 100644 index 0000000..cc026e3 --- /dev/null +++ b/packages/core/__tests__/suggestion-redteam-r1-1.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { + SUGGESTION_CONTEXT_MAX_CHARS, + buildLocalSuggestionCandidates, + decideSuggestionStep, + matchesSessionPrefix, + normalizeSessionPrefix, + normalizeSuggestionText, + parseSuggestionCandidates, + sanitizeSuggestionLine, + stripModelArtifacts, + type SuggestionPolicyInput, + type SuggestionStepInput +} from '../src/input-intelligence' +import * as suggestionText from '../src/suggestion-text' +import * as terminalPrompt from '../src/terminal-prompt' +import * as inputIntelligence from '../src/input-intelligence' + +const LONG_PREFIX = `${'가'.repeat(450)} 회의 결과를 정리해서` + +function policy(overrides: Partial = {}): SuggestionPolicyInput { + return { + enabled: true, + modelAvailable: true, + overlayVisible: false, + composing: false, + hasSelection: false, + isPassword: false, + isEditable: true, + appName: 'Slack.exe', + excludedApps: [], + editedSinceFocus: true, + typedRecently: true, + prefix: '오늘 회의 결과를 정리해서', + idleMs: 2000, + triggerDelayMs: 600, + minPrefixChars: 8, + sinceLastRequestMs: 60_000, + minIntervalMs: 5000, + requestsThisMinute: 0, + maxRequestsPerMinute: 6, + requestsToday: 0, + dailyBudget: 500, + ...overrides + } +} + +function step(overrides: Partial = {}, policyOverrides: Partial = {}) { + return decideSuggestionStep({ + policy: policy(policyOverrides), + sessionVisible: false, + sessionPrefix: '', + coolingDown: false, + userDismissedQuiet: false, + lastRequestedPrefix: '', + modelWarm: true, + warmUpBackoff: false, + ...overrides + }) +} + +describe('세션 접두 정규화 (400자 초과 입력창)', () => { + it('세션이 저장한 400자 창과 잘리지 않은 현재 접두를 같은 세션으로 본다', () => { + const stored = normalizeSessionPrefix(LONG_PREFIX) + expect(stored).toHaveLength(SUGGESTION_CONTEXT_MAX_CHARS) + expect(matchesSessionPrefix(stored, LONG_PREFIX)).toBe(true) + expect(matchesSessionPrefix(stored, `${LONG_PREFIX} `)).toBe(true) + }) + + it('긴 입력에서도 이어 치기는 세션 종료, 마지막 글자 IME 조합은 유지', () => { + const stored = normalizeSessionPrefix(LONG_PREFIX) + expect(matchesSessionPrefix(stored, `${LONG_PREFIX} 추가`)).toBe(false) + expect(matchesSessionPrefix(stored, `${LONG_PREFIX.slice(0, -1)}사`)).toBe(true) + }) + + it('decideSuggestionStep 은 400자 넘는 같은 접두로 표시 중 세션을 끝내지 않는다', () => { + const result = step( + { sessionVisible: true, sessionPrefix: normalizeSessionPrefix(LONG_PREFIX), lastRequestedPrefix: LONG_PREFIX }, + { prefix: LONG_PREFIX, overlayVisible: true } + ) + expect(result).not.toMatchObject({ action: 'end-session' }) + expect(result).toMatchObject({ action: 'settle', then: 'record' }) + }) +}) + +describe('decideSuggestionStep — 쿨다운 중 표시된 세션', () => { + const visible = { sessionVisible: true, sessionPrefix: '오늘 회의 결과를 정리해서', coolingDown: true } + + it('다음 후보 단축키(디바운스 구간)에서도 세션을 닫거나 다시 만들지 않는다', () => { + expect(step(visible, { overlayVisible: true, idleMs: 100 })).toEqual({ + action: 'settle', + reason: 'cooldown', + tryLocalMemory: false, + then: 'record' + }) + }) + + it('멈춘 상태에서도 로컬 기억 세션을 다시 게시하지 않는다 (선택 초기화 · 이력 중복 방지)', () => { + expect(step(visible, { overlayVisible: true, idleMs: 5000 })).toMatchObject({ tryLocalMemory: false, then: 'record' }) + }) + + it('타이핑이 끊겨도(not-typing) 읽는 중인 세션을 닫지 않는다', () => { + expect(step(visible, { overlayVisible: true, typedRecently: false })).toMatchObject({ then: 'record' }) + }) + + it('빠른 실패 직후(최소 간격 안)에도 방금 게시한 폴백을 닫지 않는다', () => { + expect(step(visible, { overlayVisible: true, sinceLastRequestMs: 100 })).toMatchObject({ then: 'record' }) + }) + + it('비밀번호 칸·선택 같은 하드 차단은 쿨다운 중에도 세션을 닫는다', () => { + expect(step(visible, { overlayVisible: true, isPassword: true })).toMatchObject({ + reason: 'cooldown', + tryLocalMemory: false, + then: 'dismiss' + }) + }) + + it('표시 중인 것이 없으면 로컬 기억 게시를 시도한다', () => { + expect(step({ coolingDown: true })).toEqual({ + action: 'settle', + reason: 'cooldown', + tryLocalMemory: true, + then: 'dismiss' + }) + }) +}) + +describe('decideSuggestionStep — 순서와 모델 온기', () => { + it('정상 경로는 생성한다', () => { + expect(step()).toEqual({ action: 'generate', prefix: '오늘 회의 결과를 정리해서' }) + }) + + it('콜드 모델은 워밍업, 워밍업 실패 직후에는 워밍업을 다시 쏘지 않는다', () => { + expect(step({ modelWarm: false })).toMatchObject({ reason: 'model-unavailable', then: 'warm-up' }) + expect(step({ modelWarm: false, warmUpBackoff: true })).toMatchObject({ + reason: 'generation-failed', + tryLocalMemory: true, + then: 'record' + }) + }) + + it('사용자 닫기 직후는 조용하다', () => { + expect(step({ userDismissedQuiet: true })).toMatchObject({ reason: 'cooldown', then: 'record' }) + }) + + it('같은 텍스트로 이미 요청했으면 unchanged', () => { + expect( + step({ lastRequestedPrefix: '오늘 회의 결과를 정리해서' }, { overlayVisible: true }) + ).toMatchObject({ reason: 'unchanged', then: 'record' }) + }) + + it('모델 서버가 없으면 로컬 기억을 먼저 시도하고 준비 중을 보여준다', () => { + expect(step({}, { modelAvailable: false })).toMatchObject({ + reason: 'model-unavailable', + tryLocalMemory: true, + then: 'show-warming' + }) + }) + + it('표시 중 세션의 접두가 자라면 세션을 끝낸다', () => { + expect( + step({ sessionVisible: true, sessionPrefix: '오늘 회의 결과를' }, { prefix: '오늘 회의 결과를 정리해서' }) + ).toEqual({ action: 'end-session', reason: 'stale' }) + }) +}) + +describe('후보 정제 — 문장 앞 숫자 보존', () => { + it.each([ + ['3 pm in the lobby', '3 pm in the lobby'], + ['10 minutes later I will call', '10 minutes later I will call'], + ['2026 roadmap is ready', '2026 roadmap is ready'], + ['3 시에 로비에서 봬요', '3 시에 로비에서 봬요'] + ])('숫자로 시작하는 문장 %s 을 지우지 않는다', (input, expected) => { + expect(sanitizeSuggestionLine(input)).toBe(expected) + }) + + it.each([ + ['1. 첫 항목입니다', '첫 항목입니다'], + ['10) 열 번째 항목', '열 번째 항목'], + ['2] 괄호 번호', '괄호 번호'], + ['- 불릿 항목', '불릿 항목'], + ['• 점 불릿', '점 불릿'] + ])('목록 표지 %s 는 걷어낸다', (input, expected) => { + expect(sanitizeSuggestionLine(input)).toBe(expected) + }) + + it('모델 출력 파싱도 앞 숫자를 보존한다', () => { + expect(parseSuggestionCandidates('10 minutes later I will call', 'I will be there in')).toEqual([ + '10 minutes later I will call' + ]) + }) + + it('로컬 기억 힌트(사용자 자신의 글)는 모델용 정제를 거치지 않는다', () => { + expect( + buildLocalSuggestionCandidates('Let us meet at', { + continuationHints: ['3 pm in the lobby', '"quoted" plan: ready'], + relatedHints: [], + phraseHints: [] + }) + ).toEqual(['3 pm in the lobby', '"quoted" plan: ready']) + }) + + it('stripModelArtifacts 는 지시문 되풀이를 버리고, normalizeSuggestionText 는 내용을 바꾸지 않는다', () => { + expect(stripModelArtifacts('Suggestion: 이어집니다')).toBeNull() + expect(stripModelArtifacts('{{prefix}}')).toBeNull() + expect(normalizeSuggestionText(' 1. 그대로 둔다 ')).toBe('1. 그대로 둔다') + expect(normalizeSuggestionText('a')).toBeNull() + }) +}) + +describe('모듈 분리 — 기존 import 경로 유지', () => { + it('input-intelligence 가 분리된 모듈의 같은 함수를 다시 export 한다', () => { + expect(inputIntelligence.sanitizeSuggestionLine).toBe(suggestionText.sanitizeSuggestionLine) + expect(inputIntelligence.buildLocalSuggestionCandidates).toBe(suggestionText.buildLocalSuggestionCandidates) + expect(inputIntelligence.endsSentence).toBe(suggestionText.endsSentence) + expect(inputIntelligence.extractTerminalPromptPrefix).toBe(terminalPrompt.extractTerminalPromptPrefix) + expect(inputIntelligence.SUGGESTION_MAX_OUTPUT_CHARS).toBe(160) + }) +}) diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index 4187c00..8bd678d 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -14,6 +14,32 @@ // CaretGeometryQuality.Estimated 경로와 같다. // // 순수 함수만 둔다 — Electron/Node 의존 금지 (테스트 가능성 유지). +// +// 후보 텍스트 정제는 suggestion-text.ts, 터미널 입력 줄 파싱은 terminal-prompt.ts 가 +// 정본이다. 기존 import 경로(@d3ro/core/input-intelligence)를 유지하려고 여기서 다시 export 한다. + +import { + SENTENCE_TERMINATORS, + SUGGESTION_MAX_OUTPUT_CHARS, + normalizeRequestPrefix, + normalizeSessionPrefix +} from './suggestion-text' +import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt' + +export { + SUGGESTION_CONTEXT_MAX_CHARS, + SUGGESTION_MAX_OUTPUT_CHARS, + buildLocalSuggestionCandidates, + endsSentence, + normalizeRequestPrefix, + normalizeSessionPrefix, + normalizeSuggestionText, + parseSuggestionCandidates, + sanitizeSuggestionLine, + stripModelArtifacts +} from './suggestion-text' +export type { LocalSuggestionHints } from './suggestion-text' +export { extractTerminalPromptPrefix } from './terminal-prompt' // ============================================================ // 기하 @@ -194,8 +220,6 @@ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean { // 스트 지표 // ============================================================ -const SENTENCE_TERMINATORS = /[.!?。!?…]/ - /** * 단어 수. * @@ -222,14 +246,6 @@ export function countSentences(text: string): number { return count } -/** 마지막 문장 종결 여부 — 참이면 직전 문장이 끝난 것이므로 제안을 지운다. */ -export function endsSentence(text: string): boolean { - const trimmed = text.replace(/\s+$/u, '') - if (!trimmed) return false - const last = trimmed[trimmed.length - 1] - return SENTENCE_TERMINATORS.test(last) || last === '\n' -} - /** 케어 앞 텍스트만 잘라낸다 (UIA 스냅샷 → 컨텍스트). */ export function textBeforeCaret(text: string, caretOffset: number | null): string { if (caretOffset === null || caretOffset < 0) return text @@ -535,7 +551,7 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi return { action: 'clear', reason: 'not-typing' } } - const prefix = input.prefix.replace(/\s+$/u, '') + const prefix = normalizeRequestPrefix(input.prefix) if (!prefix) return { action: 'clear', reason: 'empty-prefix' } // 문장이 끝났다고 막지 않는다. // @@ -576,8 +592,8 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi * 연속 확장으로 인정한다 (예: 생성 시점 "하" → 현재 "한"). */ export function extendsPrefix(generatedPrefix: string, currentPrefix: string): boolean { - const generated = generatedPrefix.replace(/\s+$/u, '') - const current = currentPrefix.replace(/\s+$/u, '') + const generated = normalizeRequestPrefix(generatedPrefix) + const current = normalizeRequestPrefix(currentPrefix) if (current.startsWith(generated)) return true return generated.length > 0 && current.startsWith(generated.slice(0, -1)) } @@ -588,10 +604,14 @@ export function extendsPrefix(generatedPrefix: string, currentPrefix: string): b * `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다" * 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다 * (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다). + * + * 세션은 마지막 SUGGESTION_CONTEXT_MAX_CHARS 자로 만든 후보이므로 양쪽 모두 + * normalizeSessionPrefix 로 맞춘 뒤 비교한다 — 한쪽만 자르면 400자를 넘는 입력창에서 + * 텍스트가 그대로여도 세션이 stale 로 닫힌다. */ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean { - const generated = generatedPrefix.replace(/\s+$/u, '') - const current = currentPrefix.replace(/\s+$/u, '') + const generated = normalizeSessionPrefix(generatedPrefix) + const current = normalizeSessionPrefix(currentPrefix) if (current === generated) return true if (generated.length === 0) return false return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1) @@ -609,13 +629,128 @@ export function decideSuggestionRefresh( currentPrefix: string, minimumGrowth = SUGGESTION_DEFAULTS.regenerateAfterChars ): SuggestionRefreshDecision { - const generated = generatedPrefix.replace(/\s+$/u, '') - const current = currentPrefix.replace(/\s+$/u, '') + const generated = normalizeRequestPrefix(generatedPrefix) + const current = normalizeRequestPrefix(currentPrefix) if (!generated) return 'regenerate' if (!extendsPrefix(generated, current)) return 'stale' return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep' } +/** + * 한 번의 입력 문맥에 대해 제안 서비스가 할 일 (판별 합집합). + * + * - `end-session`: 표시 중인 세션의 접두가 어긋났다 — 세션을 stale 로 닫는다. + * - `generate`: 모델 생성을 시작한다. + * - `settle`: 생성하지 않는다. `reason` 을 남기고, `tryLocalMemory` 면 로컬 기억 후보를 + * 먼저 게시해 보고, 게시하지 못하면 `then` 을 수행한다. + */ +export type SuggestionStep = + | { action: 'end-session'; reason: 'stale' } + | { action: 'generate'; prefix: string } + | { + action: 'settle' + reason: SuggestionSkipReason + tryLocalMemory: boolean + /** + * record: 사유만 남긴다. dismiss: 표시 중이면 이 사유로 닫는다. + * show-warming: "준비 중" 을 보여준다. warm-up: 워밍업을 시작하고 "준비 중" 을 보여준다. + */ + then: 'record' | 'dismiss' | 'show-warming' | 'warm-up' + } + +export interface SuggestionStepInput { + /** 정책 입력. overlayVisible 은 "생성/워밍업/후보 표시 중" 전체를 뜻한다. */ + policy: SuggestionPolicyInput + /** 후보 세션이 화면에 떠 있는가 (후보 1개 이상) */ + sessionVisible: boolean + /** 세션을 만든 접두 */ + sessionPrefix: string + /** 연속 실패 쿨다운 중인가 */ + coolingDown: boolean + /** 사용자가 X 로 닫은 직후의 조용한 구간인가 */ + userDismissedQuiet: boolean + /** 마지막으로 모델에 실제 요청한 접두 (normalizeRequestPrefix 적용) */ + lastRequestedPrefix: string + /** 지금 쓸 모델이 메모리에 떠 있다고 볼 수 있는가 */ + modelWarm: boolean + /** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */ + warmUpBackoff: boolean +} + +/** + * 제안 서비스의 판단 순서 정본: 세션 유효성 → 쿨다운 → 사용자 닫기 → 정책 → 모델 온기. + * + * 서비스는 이 결과를 실행만 한다. 쿨다운 경로도 decideSuggestion 의 'already-visible' + * 규칙을 거친다 — 쿨다운 중 표시된 로컬 기억 세션을 매 스냅샷마다 다시 만들거나 + * (선택 초기화 · 이력 중복) 닫지 않는다. + */ +export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep { + const { policy } = input + if (input.sessionVisible && !matchesSessionPrefix(input.sessionPrefix, policy.prefix)) { + return { action: 'end-session', reason: 'stale' } + } + + // 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. + const localMemoryAllowed = + decideSuggestion({ ...policy, modelAvailable: true, overlayVisible: false }).action === 'request' + + if (input.coolingDown) { + const presentation = decideSuggestion({ ...policy, modelAvailable: true }) + if (presentation.action === 'skip' && presentation.reason === 'already-visible') { + return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' } + } + return { action: 'settle', reason: 'cooldown', tryLocalMemory: localMemoryAllowed, then: 'dismiss' } + } + + if (input.userDismissedQuiet) { + return { action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' } + } + + const decision = decideSuggestion(policy) + if (decision.action === 'clear') { + return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'dismiss' } + } + + if (decision.action === 'skip') { + // 같은 텍스트로 이미 요청했다면 다시 만들지 않는다 (클릭만 했거나, 엔터로 보낸 뒤 + // UIA 가 옛 텍스트를 돌려줄 때 제안이 반복 생성되던 문제). + const current = normalizeRequestPrefix(policy.prefix) + if (input.lastRequestedPrefix && current === input.lastRequestedPrefix) { + return { action: 'settle', reason: 'unchanged', tryLocalMemory: false, then: 'record' } + } + if (decision.reason === 'model-unavailable') { + return { + action: 'settle', + reason: 'model-unavailable', + tryLocalMemory: localMemoryAllowed, + then: 'show-warming' + } + } + return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' } + } + + // 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다 — 콜드 리로드는 + // 요청 타임아웃보다 길어 그대로 요청하면 항상 시간 초과한다. + if (!input.modelWarm) { + if (input.warmUpBackoff) { + return { + action: 'settle', + reason: 'generation-failed', + tryLocalMemory: localMemoryAllowed, + then: 'record' + } + } + return { + action: 'settle', + reason: 'model-unavailable', + tryLocalMemory: localMemoryAllowed, + then: 'warm-up' + } + } + + return { action: 'generate', prefix: decision.prefix } +} + /** 제외 앱 판정 — 대소문자 무시, 실행 파일명(확장자 무관) 부분 일치. */ export function isAppExcluded(appName: string, excludedApps: readonly string[]): boolean { const target = appName.trim().toLowerCase() @@ -654,47 +789,6 @@ export function isTerminalApp(appName: string | null | undefined): boolean { return !!appName && isAppExcluded(appName, TERMINAL_APPS) } -/** - * 입력 줄의 시작 표시: Claude Code/Codex `>`·`›`, starship `❯`, PowerShell `PS …>`, cmd `C:\…>`, - * POSIX `$ # %`(단독이거나 앞에 경로·호스트 표지가 있을 때만 — 출력의 `100% 완료` 를 입력으로 보지 않는다). - */ -const TERMINAL_PROMPT_PATTERN = - /^(?:PS [^>\n]{1,260}>|[A-Za-z]:\\[^>\n]{0,260}>|(?:[^\s]*[@:~/\\][^\s]*)?[$#%]|>|›|❯|»)[ \u00A0]?(.*)$/u -/** 테두리·상자 줄(─━═╭╮╰╯ 등)이나 비어 있는 줄에서 입력 영역이 끝난다. */ -const TERMINAL_BORDER_PATTERN = /^[\s─━═┄┈╌╍╭╮╰╯┌┐└┘├┤┬┴┼│┃║▔▁▏▕-]*$/u -const TERMINAL_EDGE_PATTERN = /^[\s│┃║]+|[\s│┃║]+$/gu -const TERMINAL_SCAN_LINES = 40 - -/** - * 터미널 화면 텍스트에서 "지금 치고 있는 입력" 을 뽑는다. - * - * 문서형 터미널은 캐럿 위치를 주지 않고 화면 전체를 돌려준다 — 마지막 줄은 대개 - * 상태줄이다. 아래에서 위로 가장 가까운 프롬프트 줄을 찾고, 그 아래로 테두리/빈 줄이 - * 나올 때까지의 이어진 줄(줄바꿈된 긴 입력)을 붙인다. 프롬프트를 못 찾으면 null — - * 제안하지 않는다(출력·상태줄로 제안을 만들지 않는다). - */ -export function extractTerminalPromptPrefix(screen: string | null | undefined): string | null { - if (!screen) return null - const lines = screen.replace(/\r/g, '').split('\n') - const start = Math.max(0, lines.length - TERMINAL_SCAN_LINES) - for (let i = lines.length - 1; i >= start; i--) { - const line = lines[i].replace(TERMINAL_EDGE_PATTERN, '') - const match = TERMINAL_PROMPT_PATTERN.exec(line) - if (!match) continue - const parts = [match[1].trimEnd()] - for (let j = i + 1; j < lines.length; j++) { - const raw = lines[j] - if (TERMINAL_BORDER_PATTERN.test(raw)) break - const continuation = raw.replace(TERMINAL_EDGE_PATTERN, '') - // 상태줄 기호(◑ ▕░ 등)가 섞인 줄은 입력이 아니다. - if (!continuation || NON_PROSE_GLYPH_PATTERN.test(continuation)) break - parts.push(continuation) - } - return parts.join(' ').replace(/\s+/gu, ' ').trimStart() - } - return null -} - /** * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. * @@ -722,8 +816,6 @@ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([ 'Agent Switchboard' ]) -/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */ -const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u /** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ const MIN_LETTER_RATIO = 0.6 @@ -752,62 +844,6 @@ export function withoutPlaceholderText(snapshot: FocusSnapshot): FocusSnapshot { return { ...snapshot, text: '', caretOffset: null } } -/** - * 프롬프트에 넣을 컨텍스트 길이 상한. - * - * 길이가 곧 로컬 추론 지연이므로 짧게 유지한다. - */ -export const SUGGESTION_CONTEXT_MAX_CHARS = 400 -/** 후보 문자열 길이 상한. */ -export const SUGGESTION_MAX_OUTPUT_CHARS = 160 - -/** - * 모델 출력에서 후보 목록을 뽑아 정제한다. - * - * gemma 계열 소형 모델은 번호/따옴표/머리말을 붙이기 쉬우므로 여기서 걷어낸다. - * 접두를 그대로 되풀이하는 후보는 버린다 (ghost text 로 쓸 수 없음). - */ -export function parseSuggestionCandidates( - raw: string, - prefix: string, - maxCandidates = 3, - maxChars = SUGGESTION_MAX_OUTPUT_CHARS -): string[] { - if (!raw) return [] - const prefixTail = prefix.replace(/\s+$/u, '').slice(-24).toLowerCase() - - const out: string[] = [] - for (const line of raw.split(/\r?\n/u)) { - const cleaned = sanitizeSuggestionLine(line, maxChars) - if (!cleaned) continue - if (prefixTail && cleaned.toLowerCase().startsWith(prefixTail)) continue - if (out.some((existing) => existing.toLowerCase() === cleaned.toLowerCase())) continue - out.push(cleaned) - if (out.length >= maxCandidates) break - } - return out -} - -/** 한 줄 정제: 번호/불릿/따옴표/마크다운 제거 + 길이 제한 + 접두 반복 제거. */ -export function sanitizeSuggestionLine(line: string, maxChars = SUGGESTION_MAX_OUTPUT_CHARS): string | null { - let text = line.trim() - if (!text) return null - text = text.replace(/^[-*•\d]+[.)\]]?\s+/u, '') - text = text.replace(/^["'“”‘’`]+|["'“”‘’`]+$/gu, '') - text = text.replace(/\s+/gu, ' ').trim() - if (text.length < 2) return null - // 모델이 지시문을 되풀이한 경우 방어 (llm-prompts 회귀와 같은 부류) - if (/^(suggestion|completion|candidate|output|answer|다음 문장)\s*[::]/iu.test(text)) return null - if (/^\{\{.*\}\}$/u.test(text)) return null - if (text.length > maxChars) { - text = text.slice(0, maxChars) - const lastSpace = text.lastIndexOf(' ') - if (lastSpace > maxChars * 0.6) text = text.slice(0, lastSpace) - text = text.trim() - } - return text.length >= 2 ? text : null -} - // ============================================================ // 집계 버킷 / 리포트 // ============================================================ @@ -1145,73 +1181,6 @@ export interface SuggestionProvenance { appPhraseCount: number } -export interface LocalSuggestionHints { - continuationHints: readonly string[] - relatedHints: readonly string[] - phraseHints: readonly string[] -} - -function suffixPrefixOverlap(prefix: string, candidate: string): number { - const normalizedPrefix = prefix.toLowerCase() - const normalizedCandidate = candidate.toLowerCase() - const maximum = Math.min(normalizedPrefix.length, normalizedCandidate.length) - for (let length = maximum; length >= 2; length -= 1) { - if (normalizedPrefix.slice(-length) === normalizedCandidate.slice(0, length)) return length - } - return 0 -} - -/** - * 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다. - * 모델 실패를 성공처럼 숨기지 않고, 호출부가 provenance.mode 로 출처를 명시한다. - */ -export function buildLocalSuggestionCandidates( - prefix: string, - hints: LocalSuggestionHints, - limit = 3, - maxChars = SUGGESTION_MAX_OUTPUT_CHARS -): string[] { - const safeLimit = Math.max(0, Math.floor(limit)) - const safeMaxChars = Math.max(0, Math.floor(maxChars)) - if (safeLimit === 0 || safeMaxChars < 2) return [] - - const normalizedPrefix = prefix.replace(/\s+$/u, '') - const normalizedPrefixLower = normalizedPrefix.toLowerCase() - const acceptsWholePhrase = endsSentence(normalizedPrefix) - const out: string[] = [] - - const append = (candidate: string | null): void => { - if (candidate === null || candidate.length === 0) return - if (candidate.toLowerCase() === normalizedPrefixLower) return - if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return - out.push(candidate) - } - - for (const raw of hints.continuationHints) { - if (out.length >= safeLimit) break - const candidate = sanitizeSuggestionLine(raw, safeMaxChars) - if (candidate === null) continue - if (normalizedPrefixLower && candidate.toLowerCase().startsWith(normalizedPrefixLower)) continue - append(candidate) - } - - for (const source of [hints.relatedHints, hints.phraseHints]) { - for (const raw of source) { - if (out.length >= safeLimit) break - const candidate = sanitizeSuggestionLine(raw, safeMaxChars) - if (candidate === null) continue - const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) - if (overlap >= 2) { - append(sanitizeSuggestionLine(candidate.slice(overlap), safeMaxChars)) - } else if (acceptsWholePhrase) { - append(candidate) - } - } - } - - return out -} - export interface SuggestionState { enabled: boolean modelId: string | null diff --git a/packages/core/src/suggestion-text.ts b/packages/core/src/suggestion-text.ts new file mode 100644 index 0000000..ed64604 --- /dev/null +++ b/packages/core/src/suggestion-text.ts @@ -0,0 +1,217 @@ +// packages/core — 제안 텍스트 정본 (후보 정제 · 접두 정규화). +// +// 두 종류의 입력을 분리해 다룬다 (ISP): +// 1) 신뢰할 수 없는 모델 출력 — 번호/불릿/따옴표/지시문 되풀이를 걷어내야 한다 +// (`stripModelArtifacts` → `normalizeSuggestionText`). +// 2) 사용자가 학습시킨 로컬 기억 문구 — 사용자 자신의 글이므로 내용을 건드리지 않고 +// 공백과 길이만 정규화한다 (`normalizeSuggestionText`). +// 두 입력이 한 정제 함수를 공유하던 시절, 모델 대응 규칙이 사용자의 "3 pm …" 같은 +// 문장 앞 숫자를 지웠다 — 이 분리가 그 회귀를 구조적으로 막는다. +// +// 순수 함수만 둔다 — Electron/Node 의존 금지. input-intelligence.ts 를 import 하지 않는다 +// (input-intelligence 가 이 모듈을 다시 export 하므로 순환이 생긴다). + +/** + * 프롬프트에 넣을 컨텍스트 길이 상한. + * + * 길이가 곧 로컬 추론 지연이므로 짧게 유지한다. + */ +export const SUGGESTION_CONTEXT_MAX_CHARS = 400 +/** 후보 문자열 길이 상한. */ +export const SUGGESTION_MAX_OUTPUT_CHARS = 160 + +/** 문장 종결 부호. */ +export const SENTENCE_TERMINATORS = /[.!?。!?…]/ + +/** 마지막 문장 종결 여부 — 참이면 직전 문장이 끝난 것이므로 제안을 지운다. */ +export function endsSentence(text: string): boolean { + const trimmed = text.replace(/\s+$/u, '') + if (!trimmed) return false + const last = trimmed[trimmed.length - 1] + return SENTENCE_TERMINATORS.test(last) || last === '\n' +} + +// ============================================================ +// 접두 정규화 +// ============================================================ + +/** + * 요청 접두 정규화 — 뒤쪽 공백만 걷어낸다 (길이는 자르지 않는다). + * + * 진행 중 요청의 연속 확장 판정(decideSuggestionRefresh)은 전체 접두로 비교해야 한다 — + * 400자 창으로 자르면 한 글자만 쳐도 창이 밀려 "연속 확장" 이 깨진다. + */ +export function normalizeRequestPrefix(prefix: string): string { + return prefix.replace(/\s+$/u, '') +} + +/** + * 세션 접두 정규화 — 뒤쪽 공백을 걷어내고 마지막 SUGGESTION_CONTEXT_MAX_CHARS 자만 남긴다. + * + * 세션은 이 창으로 만든 후보이므로, 표시 중인 세션의 유효성 비교는 양쪽 모두 이 함수로 + * 맞춰야 한다. 한쪽만 자르면 400자를 넘는 입력창에서 텍스트가 그대로여도 매 스냅샷마다 + * 세션이 stale 로 닫힌다. + */ +export function normalizeSessionPrefix(prefix: string): string { + return normalizeRequestPrefix(prefix).slice(-SUGGESTION_CONTEXT_MAX_CHARS) +} + +// ============================================================ +// 후보 정제 +// ============================================================ + +/** + * 목록 표지: 불릿(-, *, •)이거나, 1~3자리 번호 뒤에 목록 구두점(. ) ])이 붙은 것. + * + * 숫자만으로는 목록 표지로 보지 않는다 — "3 pm in the lobby", "10 minutes later", + * "2026 roadmap" 의 숫자는 문장 내용이다. + */ +const LIST_MARKER_PATTERN = /^(?:[-*•]+[.)\]]?|\d{1,3}[.)\]])\s+/u +const WRAPPING_QUOTES_PATTERN = /^["'“”‘’`]+|["'“”‘’`]+$/gu +/** 모델이 지시문을 되풀이한 경우 (llm-prompts 회귀와 같은 부류) */ +const INSTRUCTION_ECHO_PATTERN = /^(suggestion|completion|candidate|output|answer|다음 문장)\s*[::]/iu +const TEMPLATE_ECHO_PATTERN = /^\{\{.*\}\}$/u + +/** + * 모델 출력 한 줄에서 모델 특유의 부산물을 걷어낸다. + * + * 번호/불릿/따옴표를 지우고, 지시문 되풀이·템플릿 누출은 버린다(null). + * 길이 제한은 하지 않는다 — normalizeSuggestionText 가 맡는다. + */ +export function stripModelArtifacts(line: string): string | null { + let text = line.trim() + if (!text) return null + text = text.replace(LIST_MARKER_PATTERN, '') + text = text.replace(WRAPPING_QUOTES_PATTERN, '') + text = text.replace(/\s+/gu, ' ').trim() + if (INSTRUCTION_ECHO_PATTERN.test(text)) return null + if (TEMPLATE_ECHO_PATTERN.test(text)) return null + return text +} + +/** + * 후보 텍스트 정규화 — 공백을 한 칸으로 모으고 길이를 제한한다. + * + * 내용은 바꾸지 않는다 (사용자 자신의 문구에 안전). 2자 미만이면 null. + */ +export function normalizeSuggestionText( + text: string, + maxChars = SUGGESTION_MAX_OUTPUT_CHARS +): string | null { + let normalized = text.replace(/\s+/gu, ' ').trim() + if (normalized.length < 2) return null + if (normalized.length > maxChars) { + normalized = normalized.slice(0, maxChars) + const lastSpace = normalized.lastIndexOf(' ') + if (lastSpace > maxChars * 0.6) normalized = normalized.slice(0, lastSpace) + normalized = normalized.trim() + } + return normalized.length >= 2 ? normalized : null +} + +/** 모델 출력 한 줄 정제: 번호/불릿/따옴표/지시문 제거 + 길이 제한. */ +export function sanitizeSuggestionLine(line: string, maxChars = SUGGESTION_MAX_OUTPUT_CHARS): string | null { + const stripped = stripModelArtifacts(line) + return stripped === null ? null : normalizeSuggestionText(stripped, maxChars) +} + +/** + * 모델 출력에서 후보 목록을 뽑아 정제한다. + * + * gemma 계열 소형 모델은 번호/따옴표/머리말을 붙이기 쉬우므로 여기서 걷어낸다. + * 접두를 그대로 되풀이하는 후보는 버린다 (ghost text 로 쓸 수 없음). + */ +export function parseSuggestionCandidates( + raw: string, + prefix: string, + maxCandidates = 3, + maxChars = SUGGESTION_MAX_OUTPUT_CHARS +): string[] { + if (!raw) return [] + const prefixTail = normalizeRequestPrefix(prefix).slice(-24).toLowerCase() + + const out: string[] = [] + for (const line of raw.split(/\r?\n/u)) { + const cleaned = sanitizeSuggestionLine(line, maxChars) + if (!cleaned) continue + if (prefixTail && cleaned.toLowerCase().startsWith(prefixTail)) continue + if (out.some((existing) => existing.toLowerCase() === cleaned.toLowerCase())) continue + out.push(cleaned) + if (out.length >= maxCandidates) break + } + return out +} + +// ============================================================ +// 로컬 기억 후보 +// ============================================================ + +export interface LocalSuggestionHints { + continuationHints: readonly string[] + relatedHints: readonly string[] + phraseHints: readonly string[] +} + +function suffixPrefixOverlap(prefix: string, candidate: string): number { + const normalizedPrefix = prefix.toLowerCase() + const normalizedCandidate = candidate.toLowerCase() + const maximum = Math.min(normalizedPrefix.length, normalizedCandidate.length) + for (let length = maximum; length >= 2; length -= 1) { + if (normalizedPrefix.slice(-length) === normalizedCandidate.slice(0, length)) return length + } + return 0 +} + +/** + * 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다. + * + * 힌트는 사용자 자신이 쓴 글이다 — 모델 출력용 정제(목록 표지·따옴표 제거)를 거치지 + * 않고 공백·길이만 정규화한다. 모델 실패를 성공처럼 숨기지 않고, 호출부가 + * provenance.mode 로 출처를 명시한다. + */ +export function buildLocalSuggestionCandidates( + prefix: string, + hints: LocalSuggestionHints, + limit = 3, + maxChars = SUGGESTION_MAX_OUTPUT_CHARS +): string[] { + const safeLimit = Math.max(0, Math.floor(limit)) + const safeMaxChars = Math.max(0, Math.floor(maxChars)) + if (safeLimit === 0 || safeMaxChars < 2) return [] + + const normalizedPrefix = normalizeRequestPrefix(prefix) + const normalizedPrefixLower = normalizedPrefix.toLowerCase() + const acceptsWholePhrase = endsSentence(normalizedPrefix) + const out: string[] = [] + + const append = (candidate: string | null): void => { + if (candidate === null || candidate.length === 0) return + if (candidate.toLowerCase() === normalizedPrefixLower) return + if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return + out.push(candidate) + } + + for (const raw of hints.continuationHints) { + if (out.length >= safeLimit) break + const candidate = normalizeSuggestionText(raw, safeMaxChars) + if (candidate === null) continue + if (normalizedPrefixLower && candidate.toLowerCase().startsWith(normalizedPrefixLower)) continue + append(candidate) + } + + for (const source of [hints.relatedHints, hints.phraseHints]) { + for (const raw of source) { + if (out.length >= safeLimit) break + const candidate = normalizeSuggestionText(raw, safeMaxChars) + if (candidate === null) continue + const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) + if (overlap >= 2) { + append(normalizeSuggestionText(candidate.slice(overlap), safeMaxChars)) + } else if (acceptsWholePhrase) { + append(candidate) + } + } + } + + return out +} diff --git a/packages/core/src/terminal-prompt.ts b/packages/core/src/terminal-prompt.ts new file mode 100644 index 0000000..b58bf16 --- /dev/null +++ b/packages/core/src/terminal-prompt.ts @@ -0,0 +1,49 @@ +// packages/core — 터미널 화면에서 "지금 치고 있는 입력 줄" 을 뽑는다. +// +// 터미널은 화면 버퍼 전체가 입력창으로 읽힌다(상태줄·명령·출력 포함). 제안은 입력 줄만 +// 접두로 써야 하므로 그 파싱을 한곳에 둔다. 순수 함수만 둔다 — 다른 core 모듈을 +// import 하지 않는다 (input-intelligence 가 이 모듈을 다시 export 한다). + +/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */ +export const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u + +/** + * 입력 줄의 시작 표시: Claude Code/Codex `>`·`›`, starship `❯`, PowerShell `PS …>`, cmd `C:\…>`, + * POSIX `$ # %`(단독이거나 앞에 경로·호스트 표지가 있을 때만 — 출력의 `100% 완료` 를 입력으로 보지 않는다). + */ +const TERMINAL_PROMPT_PATTERN = + /^(?:PS [^>\n]{1,260}>|[A-Za-z]:\\[^>\n]{0,260}>|(?:[^\s]*[@:~/\\][^\s]*)?[$#%]|>|›|❯|»)[ \u00A0]?(.*)$/u +/** 테두리·상자 줄(─━═╭╮╰╯ 등)이나 비어 있는 줄에서 입력 영역이 끝난다. */ +const TERMINAL_BORDER_PATTERN = /^[\s─━═┄┈╌╍╭╮╰╯┌┐└┘├┤┬┴┼│┃║▔▁▏▕-]*$/u +const TERMINAL_EDGE_PATTERN = /^[\s│┃║]+|[\s│┃║]+$/gu +const TERMINAL_SCAN_LINES = 40 + +/** + * 터미널 화면 텍스트에서 "지금 치고 있는 입력" 을 뽑는다. + * + * 문서형 터미널은 캐럿 위치를 주지 않고 화면 전체를 돌려준다 — 마지막 줄은 대개 + * 상태줄이다. 아래에서 위로 가장 가까운 프롬프트 줄을 찾고, 그 아래로 테두리/빈 줄이 + * 나올 때까지의 이어진 줄(줄바꿈된 긴 입력)을 붙인다. 프롬프트를 못 찾으면 null — + * 제안하지 않는다(출력·상태줄로 제안을 만들지 않는다). + */ +export function extractTerminalPromptPrefix(screen: string | null | undefined): string | null { + if (!screen) return null + const lines = screen.replace(/\r/g, '').split('\n') + const start = Math.max(0, lines.length - TERMINAL_SCAN_LINES) + for (let i = lines.length - 1; i >= start; i--) { + const line = lines[i].replace(TERMINAL_EDGE_PATTERN, '') + const match = TERMINAL_PROMPT_PATTERN.exec(line) + if (!match) continue + const parts = [match[1].trimEnd()] + for (let j = i + 1; j < lines.length; j++) { + const raw = lines[j] + if (TERMINAL_BORDER_PATTERN.test(raw)) break + const continuation = raw.replace(TERMINAL_EDGE_PATTERN, '') + // 상태줄 기호(◑ ▕░ 등)가 섞인 줄은 입력이 아니다. + if (!continuation || NON_PROSE_GLYPH_PATTERN.test(continuation)) break + parts.push(continuation) + } + return parts.join(' ').replace(/\s+/gu, ' ').trimStart() + } + return null +}