release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept and Esc to close; old default bindings migrate and the panel guide follows the live bindings. The overlay is redesigned, stays put while candidates stream and sits outside the input box when no caret is reported. The personal phrase memory stops learning from terminals, code editors and the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders, and prunes existing entries that break those rules. Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0 speech engine without the focus endpoint, Ollama runner windows flashing while typing, the speech engine starting twice, and cold-model timeouts. Live captions can be dragged to a remembered position and show a waiting notice until the first line arrives. Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
parent
856e375f3e
commit
2fe20fa7b5
71 changed files with 2469 additions and 366 deletions
|
|
@ -18,22 +18,28 @@ import { EventEmitter } from 'events'
|
|||
import { screen } from 'electron'
|
||||
import { uIOhook } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent, UiohookMouseEvent, UiohookWheelEvent } from 'uiohook-napi'
|
||||
import { sql, gte, eq, desc } from 'drizzle-orm'
|
||||
import { sql, gte, eq, desc, inArray } from 'drizzle-orm'
|
||||
import {
|
||||
INPUT_TELEMETRY_DEFAULTS,
|
||||
SUGGESTION_CONTEXT_MAX_CHARS,
|
||||
SUGGESTION_DEFAULTS,
|
||||
calculateFrictionInsight,
|
||||
classifyKeyStroke,
|
||||
computeTypedDelta,
|
||||
countWords,
|
||||
emptyActivityBucket,
|
||||
extractPhrases,
|
||||
isAppExcluded,
|
||||
isLearnablePhrase,
|
||||
LEARNING_EXCLUDED_APPS,
|
||||
manhattanDistance,
|
||||
mergeActivityBucket,
|
||||
rankFlowWindows,
|
||||
recommendAppExclusion,
|
||||
summarizeActivity,
|
||||
textBeforeCaret,
|
||||
withoutPlaceholderText,
|
||||
type AnchorKind,
|
||||
type FocusSnapshot,
|
||||
type InputActivityBucket,
|
||||
type InputAppStat,
|
||||
|
|
@ -45,6 +51,7 @@ import {
|
|||
type InputTelemetryState,
|
||||
type InputSnapshotSummary,
|
||||
type AppReadabilityEvidence,
|
||||
type KeyStrokeClass,
|
||||
type PersonalPhrase,
|
||||
type PhraseSource,
|
||||
type UiRect
|
||||
|
|
@ -68,6 +75,8 @@ export interface TypingContext {
|
|||
fullText: string
|
||||
caretOffset: number | null
|
||||
anchor: UiRect | null
|
||||
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
|
||||
anchorKind: AnchorKind
|
||||
isPassword: boolean
|
||||
isEditable: boolean
|
||||
isComposing: boolean
|
||||
|
|
@ -77,6 +86,10 @@ export interface TypingContext {
|
|||
windowTitle: string | null
|
||||
idleMs: number
|
||||
capturedAt: number
|
||||
/** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */
|
||||
editedSinceFocus: boolean
|
||||
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
|
||||
typedRecently: boolean
|
||||
}
|
||||
|
||||
interface InputTelemetryEvents {
|
||||
|
|
@ -95,6 +108,22 @@ const MAX_SAMPLE_CHARS = 500
|
|||
const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
const MAX_READABILITY_APPS = 64
|
||||
|
||||
/**
|
||||
* "실제로 타이핑했다" 로 볼 키 종류.
|
||||
*
|
||||
* 화살표/단축키/기능키/엔터/탭 등은 제외한다 — 필드 이동이나 명령일 뿐 텍스트를
|
||||
* 치는 게 아니다. IME 조합은 포함한다(한/일 입력은 조합 중이 곧 타이핑이다).
|
||||
*/
|
||||
const TYPING_KEY_CLASSES = new Set<KeyStrokeClass>([
|
||||
'letter',
|
||||
'digit',
|
||||
'symbol',
|
||||
'space',
|
||||
'backspace',
|
||||
'delete',
|
||||
'ime'
|
||||
])
|
||||
|
||||
class InputTelemetryService extends EventEmitter {
|
||||
private _running = false
|
||||
private _releaseHook: (() => void) | null = null
|
||||
|
|
@ -124,6 +153,18 @@ class InputTelemetryService extends EventEmitter {
|
|||
* 필드에 이미 있던 텍스트로 제안이 만들어졌다(실측 신고).
|
||||
*/
|
||||
private _lastKeyAt = 0
|
||||
/**
|
||||
* 마지막 "실제 타이핑" 시각 (TYPING_KEY_CLASSES 에 속하는 키만).
|
||||
*
|
||||
* `_lastKeyAt` 은 화살표/단축키를 포함한 모든 키를 기록해 필드 이동만으로도
|
||||
* 갱신된다. 제안 게이트는 진짜 타이핑만 기준으로 삼아야 한다(실측: 필드에
|
||||
* 이미 있던 텍스트를 마우스로 클릭만 했는데 제안이 뜸).
|
||||
*/
|
||||
private _lastTypedAt = 0
|
||||
/** 현재 포커스된 (창, 컨트롤 종류, 위치)를 식별하는 키 — 바뀌면 새 필드로 본다. */
|
||||
private _lastFocusKey = ''
|
||||
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
||||
private _textAtFocus = ''
|
||||
private _lastActiveTickAt = 0
|
||||
private _lastMouse: { x: number; y: number } | null = null
|
||||
private _lastClickAt = 0
|
||||
|
|
@ -338,6 +379,7 @@ class InputTelemetryService extends EventEmitter {
|
|||
if (keyClass === 'backspace') bucket.backspaces += 1
|
||||
|
||||
this._lastKeyAt = Date.now()
|
||||
if (TYPING_KEY_CLASSES.has(keyClass)) this._lastTypedAt = this._lastKeyAt
|
||||
|
||||
this._scheduleTextSnapshot()
|
||||
}
|
||||
|
|
@ -518,16 +560,19 @@ class InputTelemetryService extends EventEmitter {
|
|||
if (!this._running) return null
|
||||
|
||||
const uia = getUiaContextService()
|
||||
const snapshot = await uia.getSnapshot()
|
||||
const snapshot = withoutPlaceholderText(await uia.getSnapshot())
|
||||
const now = Date.now()
|
||||
// 유휴 시간은 키보드 기준이다 (마우스 이동/클릭으로는 제안하지 않는다).
|
||||
const idleMs = this._lastKeyAt > 0 ? now - this._lastKeyAt : Number.MAX_SAFE_INTEGER
|
||||
const typedRecently =
|
||||
this._lastTypedAt > 0 && now - this._lastTypedAt <= SUGGESTION_DEFAULTS.recentTypingWindowMs
|
||||
|
||||
const context: TypingContext = {
|
||||
prefix: '',
|
||||
fullText: '',
|
||||
caretOffset: null,
|
||||
anchor: null,
|
||||
anchorKind: null,
|
||||
isPassword: snapshot.isPassword,
|
||||
isEditable: snapshot.isEditable,
|
||||
isComposing: snapshot.isComposing,
|
||||
|
|
@ -536,11 +581,15 @@ class InputTelemetryService extends EventEmitter {
|
|||
appName: this._foreground.appName,
|
||||
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
|
||||
idleMs,
|
||||
capturedAt: snapshot.capturedAt
|
||||
capturedAt: snapshot.capturedAt,
|
||||
editedSinceFocus: false,
|
||||
typedRecently
|
||||
}
|
||||
|
||||
if (snapshot.available) {
|
||||
const usesCaretRect = snapshot.caretRect !== null
|
||||
context.anchor = snapshot.caretRect ?? snapshot.elementRect
|
||||
context.anchorKind = usesCaretRect ? 'caret' : snapshot.elementRect ? 'element' : null
|
||||
context.caretOffset = snapshot.caretOffset
|
||||
context.fullText = snapshot.text
|
||||
// 케어렛 오프셋을 못 얻는 제공자가 많다(실측: Notepad — src=value, caret=null).
|
||||
|
|
@ -558,6 +607,18 @@ class InputTelemetryService extends EventEmitter {
|
|||
: tail
|
||||
this._caretFallback = !caretKnown
|
||||
|
||||
// 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다).
|
||||
// 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가
|
||||
// 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데
|
||||
// 옛 검색어로 제안이 뜸).
|
||||
const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect)
|
||||
const textForFocus = snapshot.isPassword ? '' : snapshot.text
|
||||
if (focusKey !== this._lastFocusKey) {
|
||||
this._lastFocusKey = focusKey
|
||||
this._textAtFocus = textForFocus
|
||||
}
|
||||
context.editedSinceFocus = textForFocus !== this._textAtFocus
|
||||
|
||||
if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) {
|
||||
this._applyTypedDelta(snapshot)
|
||||
}
|
||||
|
|
@ -670,7 +731,15 @@ class InputTelemetryService extends EventEmitter {
|
|||
raw: string,
|
||||
meta: { appName: string | null; windowTitle: string | null; source: PhraseSource }
|
||||
): void {
|
||||
const text = sanitizeSample(raw)
|
||||
if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) {
|
||||
return
|
||||
}
|
||||
const text = sanitizeSample(
|
||||
raw
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => isLearnablePhrase(line))
|
||||
.join('\n')
|
||||
)
|
||||
if (!text) return
|
||||
|
||||
try {
|
||||
|
|
@ -699,7 +768,7 @@ class InputTelemetryService extends EventEmitter {
|
|||
at: now
|
||||
})
|
||||
|
||||
for (const phrase of extractPhrases(text)) {
|
||||
for (const phrase of extractPhrases(text).filter(isLearnablePhrase)) {
|
||||
db.insert(personalPhrases)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -1197,11 +1266,74 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금의 학습 규칙(제외 앱·문장 판정)에 맞지 않는 기존 코퍼스를 걷어낸다.
|
||||
*
|
||||
* 규칙이 생기기 전에 쌓인 터미널 상태줄·개발 지시가 그래프를 개발 쪽으로 끌고
|
||||
* 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다.
|
||||
*/
|
||||
private _pruneUnlearnableCorpus(): void {
|
||||
const excluded = (source: string, appName: string | null): boolean =>
|
||||
source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
||||
const phraseIds = db
|
||||
.select({
|
||||
id: personalPhrases.id,
|
||||
phrase: personalPhrases.phrase,
|
||||
source: personalPhrases.source,
|
||||
appName: personalPhrases.appName
|
||||
})
|
||||
.from(personalPhrases)
|
||||
.all()
|
||||
.filter((row) => excluded(row.source, row.appName) || !isLearnablePhrase(row.phrase))
|
||||
.map((row) => row.id)
|
||||
|
||||
const sampleIds = db
|
||||
.select({
|
||||
id: typingSamples.id,
|
||||
text: typingSamples.text,
|
||||
source: typingSamples.source,
|
||||
appName: typingSamples.appName
|
||||
})
|
||||
.from(typingSamples)
|
||||
.all()
|
||||
.filter(
|
||||
(row) =>
|
||||
excluded(row.source, row.appName) ||
|
||||
!row.text.split(/\r?\n/u).some((line) => isLearnablePhrase(line))
|
||||
)
|
||||
.map((row) => row.id)
|
||||
|
||||
for (let i = 0; i < phraseIds.length; i += 200) {
|
||||
db.delete(personalPhrases).where(inArray(personalPhrases.id, phraseIds.slice(i, i + 200))).run()
|
||||
}
|
||||
for (let i = 0; i < sampleIds.length; i += 200) {
|
||||
db.delete(typingSamples).where(inArray(typingSamples.id, sampleIds.slice(i, i + 200))).run()
|
||||
}
|
||||
db.run(
|
||||
sql`DELETE FROM phrase_edges
|
||||
WHERE from_phrase NOT IN (SELECT phrase FROM personal_phrases)
|
||||
OR to_phrase NOT IN (SELECT phrase FROM personal_phrases)`
|
||||
)
|
||||
|
||||
if (phraseIds.length > 0 || sampleIds.length > 0) {
|
||||
logger.info(`학습 규칙에 맞지 않는 코퍼스 정리: 문구 ${phraseIds.length}개, 표본 ${sampleIds.length}개`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`코퍼스 정리 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 보존 기간 초과 데이터 정리 (ActivityWatch 와 동일한 로컬 보존 정책). */
|
||||
private _pruneOldData(now = Date.now()): void {
|
||||
if (now - this._prunedAt < RETENTION_PRUNE_INTERVAL_MS) return
|
||||
this._prunedAt = now
|
||||
|
||||
this._pruneUnlearnableCorpus()
|
||||
|
||||
// 그래프 유지보수도 같은 주기에 돌린다 (용어 공유 엣지 백필).
|
||||
getPersonalGraphService().runMaintenance()
|
||||
|
||||
|
|
@ -1252,6 +1384,19 @@ function lastLineOf(text: string): string {
|
|||
return text.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 위치.
|
||||
*
|
||||
* 높이는 뺀다 — 여러 줄 입력창은 타이핑에 따라 높이가 자라, 높이를 포함하면
|
||||
* 같은 필드인데도 칠 때마다 "새 포커스" 로 오인돼 기준선이 계속 리셋된다.
|
||||
*/
|
||||
function computeFocusKey(hwnd: number | null, controlType: string | null, elementRect: UiRect | null): string {
|
||||
const rect = elementRect
|
||||
? `${Math.round(elementRect.x)},${Math.round(elementRect.y)},${Math.round(elementRect.width)}`
|
||||
: 'none'
|
||||
return `${hwnd ?? ''}|${controlType ?? ''}|${rect}`
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue