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

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

View file

@ -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> = {}): 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<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
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)
})
})

View file

@ -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

View file

@ -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
}

View file

@ -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
}