refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators
This commit is contained in:
parent
9aa7302944
commit
190b6db284
13 changed files with 1994 additions and 496 deletions
219
packages/core/__tests__/suggestion-redteam-r1-1.test.ts
Normal file
219
packages/core/__tests__/suggestion-redteam-r1-1.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
217
packages/core/src/suggestion-text.ts
Normal file
217
packages/core/src/suggestion-text.ts
Normal 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
|
||||
}
|
||||
49
packages/core/src/terminal-prompt.ts
Normal file
49
packages/core/src/terminal-prompt.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue