release: ship v1.5.0 with on-device writing suggestions
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s

Adds next-sentence suggestions while typing, weekly input insights and a
personal phrase memory to the desktop app, and fixes custom instructions so
they process the text instead of inserting the instruction's own wording.
Local model requests are now bounded and individually cancellable.

Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the
landing and web download links, and records the new INPUT feature rows and the
open verification gaps in the infrastructure map.
This commit is contained in:
Yun Chan 2026-09-23 16:04:27 +09:00
parent 99f06c253c
commit 5c11ee2fde
104 changed files with 14410 additions and 174 deletions

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
buildLocalSuggestionCandidates,
calculateFrictionInsight,
rankFlowWindows,
recommendAppExclusion,
selectPhraseHints,
type InputAppSuggestionStat,
type InputHourlyStat,
type InputPrivacyReceipt,
type PersonalPhrase
} from '@d3ro/core/input-intelligence'
import {
auditKeyBindingMap,
createDefaultBindingMap,
type KeyBinding
} from '@d3ro/core/keybinding'
const HOUR = 60 * 60 * 1000
const DAY = 24 * HOUR
function binding(code: number, modifiers: Partial<KeyBinding> = {}): KeyBinding {
return {
device: 'keyboard',
code,
ctrl: false,
alt: false,
shift: false,
meta: false,
...modifiers
}
}
describe('로컬 플로우 도메인', () => {
it('편집 마찰을 0~1 비율과 경계 등급으로 계산한다', () => {
expect(calculateFrictionInsight(0, 0)).toEqual({ rate: 0, editsPer100Chars: 0, band: 'steady' })
expect(calculateFrictionInsight(92, 8)).toMatchObject({ rate: 0.08, editsPer100Chars: 8.7, band: 'watch' })
expect(calculateFrictionInsight(82, 18)).toMatchObject({ rate: 0.18, editsPer100Chars: 22, band: 'high' })
})
it('플로우 시간대는 밀도·안정성 순으로 고르고 입력 없는 시간은 빼며 원본을 바꾸지 않는다', () => {
const hourly: InputHourlyStat[] = [
{ hour: 11, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 9, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 13, keystrokes: 200, clicks: 0, chars: 300, backspaces: 100, activeMs: HOUR / 2 },
{ hour: 3, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 }
]
const before = structuredClone(hourly)
expect(rankFlowWindows(hourly, 1, 3)).toEqual([
{ hour: 9, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 11, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 13, score: 44, activeMinutes: 30, chars: 300, frictionRate: 0.25 }
])
expect(hourly).toEqual(before)
})
it('활성 일수로 나눠 같은 시간대라도 하루 밀도를 낮춘다', () => {
const [window] = rankFlowWindows(
[{ hour: 9, keystrokes: 1400, clicks: 0, chars: 1200, backspaces: 0, activeMs: HOUR }],
2
)
expect(window).toMatchObject({ hour: 9, score: 58, activeMinutes: 30, chars: 1200 })
})
it('앱 문체 보너스와 반감기로 개인 문구를 정렬하며 원본 배열을 바꾸지 않는다', () => {
const now = 1_800_000_000_000
const phrases: PersonalPhrase[] = [
{
id: 'z-old-frequent',
phrase: '배포 일정을 공유합니다',
count: 16,
source: 'typed',
appName: 'Slack.exe',
lastUsedAt: now - DAY * 90,
createdAt: now - DAY * 90
},
{
id: 'b-current-app',
phrase: '검토 결과를 남깁니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
},
{
id: 'a-current-app-tie',
phrase: '다음 조치를 확인합니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
}
]
const before = structuredClone(phrases)
expect(selectPhraseHints(phrases, '', 3, { appName: 'notion.exe', now })).toEqual([
'다음 조치를 확인합니다',
'검토 결과를 남깁니다',
'배포 일정을 공유합니다'
])
expect(phrases).toEqual(before)
})
it('반복적인 비가독 앱만 제외를 권하고 읽힌 기록이 하나라도 있으면 멈춘다', () => {
expect(
recommendAppExclusion({ appName: 'Legacy.exe', samples: 4, readable: 0, unreadable: 3, empty: 1 })
).toEqual({ appName: 'Legacy.exe', reason: 'repeated-unreadable', samples: 4 })
expect(
recommendAppExclusion({ appName: 'Mixed.exe', samples: 6, readable: 1, unreadable: 5, empty: 0 })
).toBeNull()
expect(
recommendAppExclusion({ appName: ' ', samples: 6, readable: 0, unreadable: 6, empty: 0 })
).toBeNull()
})
it('로컬 제안은 출처 우선순위·접미 중첩 제거·중복 제거를 지킨다', () => {
expect(
buildLocalSuggestionCandidates(
'회의 결과',
{
continuationHints: ['를 공유합니다.'],
relatedHints: ['결과를 정리합니다.', '무관한 전체 문장'],
phraseHints: ['결과를 정리합니다.', '결과를 검토합니다.']
}
)
).toEqual(['를 공유합니다.', '를 정리합니다.', '를 검토합니다.'])
})
it('문장이 끝난 뒤에만 겹침 없는 전체 문구를 로컬 제안으로 허용한다', () => {
const hints = { continuationHints: [], relatedHints: ['다음 안건을 정리합니다.'], phraseHints: [] }
expect(buildLocalSuggestionCandidates('회의를 마쳤다', hints)).toEqual([])
expect(buildLocalSuggestionCandidates('회의를 마쳤다.', hints)).toEqual(['다음 안건을 정리합니다.'])
})
it('프라이버시·앱 품질 계약은 수량과 로컬 경계를 명시한다', () => {
const receipt: InputPrivacyReceipt = {
localOnly: true,
rawKeyContentStored: false,
retention: {
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
},
counts: { activityBuckets: 3, typingSamples: 2, personalPhrases: 4, suggestions: 5 }
}
const appStat: InputAppSuggestionStat = {
appName: 'notion.exe',
total: 4,
accepted: 2,
acceptRate: 0.5,
avgLatencyMs: 310
}
expect(receipt.counts.personalPhrases).toBe(4)
expect(receipt.retention).toEqual({
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
})
expect(appStat.acceptRate).toBe(0.5)
})
})
describe('단축키 안전 감사', () => {
it('유효하지 않은 바인딩과 충돌을 한 번씩 보고하고 홀드·더블프레스 기본 예외는 유지한다', () => {
const map = createDefaultBindingMap()
map.caption = [binding(0x56, { ctrl: true, shift: true })]
map.dictation = [binding(999)]
const issues = auditKeyBindingMap(map)
expect(issues).toContainEqual({
kind: 'invalid',
actionId: 'dictation',
bindingIndex: 0,
binding: binding(999),
reasonKey: 'keybinding.reject.unknownKey',
conflictActionIds: []
})
expect(issues).toContainEqual({
kind: 'conflict',
actionId: 'caption',
bindingIndex: 0,
binding: binding(0x56, { ctrl: true, shift: true }),
reasonKey: null,
conflictActionIds: ['history-popup']
})
expect(issues.filter((issue) => issue.kind === 'conflict')).toHaveLength(1)
})
})