138 Commits

Author SHA1 Message Date
49c29f34c3 Merge pull request 'chore(release): v0.2.4 — patch cut (backlog 5건 + dogfood unblock)' (#22) from feat/v024-patch-cleanup into main
Reviewed-on: #22
2026-05-04 15:24:46 +00:00
altair823
d213d45f92 fix(v024): About dialog EOL + .catch (round 1 review)
Round 1 review minor + final reviewer minor 일괄:
- About dialog detail/clipboard 의 줄바꿈 → os.EOL (Windows Notepad 등에서 줄바꿈 정상)
- showMessageBox().then().catch(() => {}) — dialog reject (main crash 예외) silent
  (tray.ts 가 logger 미import — minimal swallow 패턴 채택)

skip:
- nit: 트레이 메뉴 ordering ("정보" → "종료" 한 그룹) — 현재 패턴도 흔함, 호불호 영역
- nit: process.versions.electron ?? '?' dead branch — 안전 fallback 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:22:00 +09:00
altair823
298d1c6182 chore(release): v0.2.4 — patch cut (backlog 5건 처리 + dogfood unblock)
PR #21 머지 후 v0.2.3.1 binary 빌드 시도 → electron-builder semver 검증
실패 (4-part X.Y.Z.W 비호환). v0.2.4 minor bump 으로 우회.

본 cut 동봉:
- 0.2.3.1 의 in-app Ollama 설정 UI (PR #21 fee982a)
- backlog #2 (DAY_MS 상수)
- backlog #6 (media.gc .catch)
- backlog #13 (NoteCard onDeleted optional)
- backlog #44 (버전 정보 트레이 메뉴)
- backlog #1 stale 표기 (PR #13 시 이미 fix)

게이트: typecheck 0 / 단위 413 / e2e 1
다음: PR + 머지 후 binary 빌드 v0.2.4 + Gitea release
v0.2.5 brainstorm 트리거 시 잔여 backlog 39건 일괄 triage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:15:51 +09:00
altair823
d3dfe1e4e2 feat(v024): "Inkling 정보..." 트레이 메뉴 + native About dialog (backlog #44)
dogfood 발견 #44 fix — 사용자가 설치된 버전 확인 path 부재 해소.

- 트레이 메뉴 마지막 항목 (종료 직전): "Inkling 정보..."
- 클릭 시 native dialog (showMessageBox):
  - title: Inkling 정보
  - message: Inkling {version}
  - detail: 버전, Electron, Node, OS platform/release, 데이터 위치
  - 버튼 3개: 확인 / 데이터 위치 열기 (shell.openPath) / 정보 복사 (clipboard)
- 디버그 정보 노출로 사용자가 issue report 시 첨부 가능

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:14:30 +09:00
altair823
c87c248e89 refactor(v024): NoteCard onDeleted optional + trash mode 미전달 (backlog #13)
- onDeleted: () => void → onDeleted?: () => void (inbox mode 전용 명시)
- handleDelete 내부 onDeleted() → onDeleted?.()
- App.tsx 의 trash mode NoteCard 가 onDeleted prop 미전달 (dead-code 제거)
- API 시그니처 정리 — trash mode 는 onPermanentDelete/onRestore 만 의미

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:12:56 +09:00
altair823
ef5d3daf4c refactor(v024): TelemetryService DAY_MS 상수 + media.gc .catch (backlog #2 #6)
- #2: 24*60*60*1000 magic number → 모듈 상단 const DAY_MS
  cleanupOldFiles + readAllRecent 두 callsite 통일
- #6: gc.run() 의 .catch 누락 → backup.runDaily 패턴 통일
  실패 시 logger.warn('media.gc.failed', { reason })

Note: backlog #1 (now() 2번 호출) 은 PR #13 round 1 review 시 이미 fix —
backlog 항목 stale. v0.2.5 brainstorm 시 backlog 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:11:38 +09:00
altair823
4bde148cdc docs(v024): patch cleanup spec — 5 backlog 항목 + version bump
0.2.3.1 semver 위반 → 0.2.4 minor bump 이용해 backlog risk 낮은 cleanup
5건 + dogfood 가치 #44 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.

In: #1 (now() 2번), #2 (DAY_MS), #6 (media.gc .catch), #13 (NoteCard onDeleted),
    #44 (버전 정보 surface), version bump
Out: #45 (autostart bug — 별도 cut), #3/#4/#5/#22/#26 (큰 refactor),
     #39~#43 (PR #21 deferred — v0.2.5 brainstorm)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:09:56 +09:00
altair823
8ba43d939e docs(backlog): v0.2.3.1 dogfood 발견 +2건 (#44 버전 정보, #45 자동실행 버그)
PR #21 머지 후 dogfood 중 사용자 발견:
- #44: 버전 / 빌드 정보 표시 surface 부재 (트레이 / Inbox footer / About 모달)
- #45: 윈도우 자동 실행 옵션 재시작 후 풀려있는 버그
  (tray.ts:47-58, app.setLoginItemSettings + getLoginItemSettings 비대칭)

PR review deferred 와 별개의 raw UX/bug 발견. 신설 섹션 "v0.2.3 / v0.2.3.1
dogfood 발견" 으로 분리 — v0.2.4 brainstorm 시 우선순위 결정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:02:40 +09:00
fee982a6e6 Merge pull request 'feat(ollama): v0.2.3.1 — in-app endpoint/model 설정' (#21) from feat/v0231-ollama-settings into main
Reviewed-on: #21
2026-05-04 15:00:40 +00:00
altair823
d974335ee4 docs(backlog): v0.2.3.1 round 1 review m2/i1 + 신규 항목 5건 추가
PR #21 round 1 review 에서 deferred 항목들 backlog 38 → 43:
- #39 (m2): ollama_unreachable.reason 의 endpoint URL PII 우회 노출
- #40 (i1): save vs HealthChecker tick race UX flicker
- #41: OllamaSettingsModal 인라인 스타일 (#24 와 합산)
- #42: Modal client-side URL validation 부재
- #43: createTray 10번째 positional callback (#4/#26 blocker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:54:50 +09:00
altair823
6f95e89456 fix(ollama): PR #21 review round 1 — m1+m3+m4+n1 (v0.2.3.1)
- m1 (Minor): saveOllamaSettings IPC가 setOllama throw 시 try/catch
  → { ok: false, reason: 'persist failed: ...' } 대칭 응답
- m3 (Minor): Modal ESC=close + Enter=save 키 핸들러 + 첫 input autoFocus
- m4 (Minor): handleSave 첫 줄 if (saving) return; — sync double-click 가드
- n1 (Nit): 'gemma4:e4b' / 'http://localhost:11434' magic
  → src/shared/constants.ts 의 DEFAULT_OLLAMA_MODEL / DEFAULT_OLLAMA_ENDPOINT

defer to v0.2.4 backlog:
- m2: ollama_unreachable.reason 에 endpoint URL 노출 (PII 우회) — telemetry masking 정책

skip:
- i1 (race UX): acknowledge only, 정확성 영향 0
- m5 (abort try/catch): 현재 LocalOllamaProvider.abort 는 throw X
- m6 (first-boot blocking): 무시 가능
- n2 (offReplace): 현재 listener callsite 0건

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:53:42 +09:00
altair823
3a2ff1a35c chore(release): v0.2.3.1 — Ollama 설정 in-app UI (patch cut)
dogfood unblock 패치. v0.2.3 의 INKLING_OLLAMA_ENDPOINT env var 의존 →
in-app UI (트레이 + 배너) 에서 endpoint + model 변경 가능.

게이트: typecheck 0 / 단위 413 / e2e 1
다음: PR + 머지 후 binary 재빌드 + Gitea release v0.2.3.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:44:45 +09:00
altair823
0c0327ddb6 feat(ollama): 트레이 메뉴 "Ollama 설정..." (v0.2.3.1)
- createTray 10번째 positional callback runOpenOllamaSettings
- 트레이 → 메뉴 클릭 → main 이 inbox:openOllamaSettings IPC push
- renderer App.tsx 가 구독해 modal open

backlog #4/#26 (TrayCallbacks object refactor) 와 합산 — v0.2.4 시 일괄 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:43:00 +09:00
altair823
833a598368 feat(ollama): OllamaSettingsModal + App mount + OllamaBanner 설정 링크 (v0.2.3.1)
- OllamaSettingsModal: endpoint + model freetext 입력, 저장 시 healthCheck → 성공 닫기, 실패 inline 에러
- App.tsx: ollamaSettingsOpen state + onOpenOllamaSettings IPC subscribe
- OllamaBanner: onOpenSettings prop 추가, 우측 "설정" 버튼
- preload + types: onOpenOllamaSettings listener bridge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:40:31 +09:00
altair823
4153284af1 fix(ollama): saveOllamaSettings 가 health.runOnce() 즉시 호출 (T4 review)
T4 fallback comment "60s polling cycle" 대신 HealthChecker 의 기존 public
method runOnce() 사용. 사용자가 settings 저장하자마자 OllamaBanner 갱신.
runOnce 는 이미 inbox:ollamaRecheck IPC 가 사용 중인 패턴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:37:57 +09:00
altair823
cee39a90aa feat(ollama): index 부팅 + IPC + preload + types (v0.2.3.1)
- index.ts: SettingsService.load() 후 endpoint/model 결정 (settings > env > default)
- IPC: inbox:loadOllamaSettings + inbox:saveOllamaSettings
  - save: 임시 provider 로 healthCheck 통과 시에만 영속화 + holder.replace
  - 기존 in-flight generate 는 abort?.() (optional method)
- preload + InboxApi shared types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:36:46 +09:00
altair823
d1f36250e7 fix(ollama): InferenceProvider — abort?: () => void optional 추가 (T3 review)
T3 가 ProviderHolder 를 InferenceProvider 로 추상화. 단 IPC handler 가
holder.get().abort() 호출 예정 — interface 에 method 가 없으면 typecheck 실패.

abort 는 in-flight generate 중단용이라 모든 provider 가 지원할 필요는 없음
→ optional method 로 추가. caller 는 holder.get().abort?.() 패턴 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:34:17 +09:00
altair823
9fef2edb6e feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)
- ProviderHolder: mutable holder + listeners, indirection layer
- AiWorker: constructor InferenceProvider → ProviderHolder
  this.provider.x → this.holder.get().x 전환
- HealthChecker: 동일 패턴
- src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성
- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap
- 단위 +2 cases (ProviderHolder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:32:20 +09:00
altair823
c77c30be83 feat(ollama): LocalOllamaProvider — abort() + AbortController instance field (v0.2.3.1)
- abortController 가 method-local 에서 private instance field 로 이동
- public abort() 메서드 — 외부에서 in-flight generate 강제 중단
- ProviderHolder.replace() 시 호출되어 endpoint 변경 즉시 반영
- 단위 +2 cases (abort cancellation, model 파라미터)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:26:48 +09:00
altair823
de895b8fec feat(settings): SettingsService — JSON 영속화 + zod 검증 (v0.2.3.1)
- `<profileDir>/settings.json` atomic write (temp + rename)
- 손상 JSON / 파일 없음 → 빈 객체 fallback (no throw)
- in-memory cache (load 1회 file read)
- zod .strict() schema for ollama { endpoint: URL, model: string }
- 단위 +6 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:23:32 +09:00
altair823
71ec79ae19 docs(ollama-settings): v0.2.3.1 plan — 7 tasks TDD + 10 단위 cases
T1 SettingsService (JSON 영속화 + zod, +6 cases)
T2 LocalOllamaProvider abort + model param (+2 cases)
T3 ProviderHolder + AiWorker/HealthChecker refactor (+2 cases)
T4 index 부팅 + IPC + preload + types
T5 OllamaSettingsModal + App.tsx + OllamaBanner 링크
T6 트레이 메뉴 "Ollama 설정..."
T7 Closure (version 0.2.3 → 0.2.3.1 + gates)

총 신규 단위 +10. 단위 403 → 413.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:21:00 +09:00
altair823
97ca119b55 docs(ollama-settings): v0.2.3.1 spec — in-app endpoint/model 설정
mini-brainstorm 3개 결정:
- Q1=B: Endpoint + Model 둘 다 포함
- Q2=A: Freetext input (dropdown 은 v0.2.4 영역)
- Q3=B: JSON file (`<profileDir>/settings.json`, migration v4 회피)

자명 결정 (질문 없이 패턴):
- precedence: settings > env > default
- in-flight: AbortController abort + provider re-create
- UI: 트레이 + OllamaBanner 진입점, React modal
- validation: save 전 healthCheck

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:17:23 +09:00
altair823
b259734aa0 docs(backlog): v0.2.4 backlog memory → repo 이동
v0.2.3 cut 7항목 동안 final reviewer + PR review 에서 발견된 minor/nit
중 의도적 deferred 38건 누적. 기존엔 user-level memory 에만 있어
사용자가 직접 보거나 편집 어려움 → repo 안으로 lift.

dogfood 1주 soak 동안 user 가 직접 prune / 우선순위 표시 / 새 항목 추가
가능. v0.2.4 brainstorm 진입 시 본 doc 가 1차 backlog reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:18:07 +09:00
altair823
5fc694c57b hotfix(build): publish: null 추가 — Mac 빌드 시 updateInfoBuilder crash 회피
PR #20 직후 Mac arm64 dist 시도 중 발견:
- Cannot detect repository by .git/config 경고 (3회)
- ⨯ Cannot read properties of null (reading 'channel')
  at computeChannelNames (updateInfoBuilder.ts:47:74)

원인: electron-builder 가 auto-update 메타파일 (latest-mac.yml) 생성 시
publish config 또는 git remote 에서 채널 정보 추론 실패 → null 접근 crash.
DMG 자체는 빌드 성공 (dist/Inkling-0.2.3-arm64.dmg) — 후처리 단계 crash.

Fix: build.publish = null 명시 — auto-update 메커니즘 미사용 (개인 dogfood)
이라 latest-mac.yml / latest.yml 생성 단계 skip. Windows 빌드도 동일 경고
3회 떴는데 이번 fix 로 함께 사라짐.

검증: npm run dist:dir on Windows → "Cannot detect repository" 경고 사라짐, 정상 빌드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:15:47 +09:00
4e1f60cb7d Merge pull request 'hotfix(build): npm run dist Mac arm64 cross-platform 지원' (#20) from hotfix/arm-mac-build into main
Reviewed-on: #20
2026-05-02 07:06:23 +00:00
altair823
8cdffb2143 hotfix(build): npm run dist 가 Mac arm64 에서도 동작하도록 cross-platform
- dist / dist:dir 에서 --win --x64 제거 → electron-builder host-default
  (Windows 에선 win-x64, Mac 에선 mac-arm64 자동 선택)
- 명시적 강제 variant 추가: dist:win, dist:mac
- build.mac 블록 추가:
  - target: dmg / arch: arm64
  - category: productivity
  - identity: null (개인 dogfood, codesign skip)

검증:
- typecheck 0
- 단위 403/403
- npm run dist:dir on Windows: platform=win32 arch=x64 (회귀 X)

Mac arm64 빌드 시 첫 실행 시 "Apple 이 검증할 수 없음" 경고 → 우클릭 → 열기 (codesign 미적용 의도).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:04:20 +09:00
altair823
5d0f87c5fb chore(release): v0.2.3 — package.json + lock 버전 bump
v0.2.3 cut 7/7 완료 (PR #13/#14/#15/#16/#17/#18/#19) 후 binary 빌드.

빌드 결과: dist/Inkling Setup 0.2.3.exe (103.8MB, NSIS x64)
gates: typecheck 0 / 단위 403 / e2e 1

다음: dogfood 머신 핸드오프 → ≥1주 soak → telemetry export → v0.2.4 brainstorm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:03:41 +09:00
cb29ef6f89 Merge pull request 'feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7 final)' (#19) from feat/v023-recall-spike into main
Reviewed-on: #19
2026-05-02 04:52:46 +00:00
altair823
61b6fa6c1f fix(recall): PR review round 1 — i1 race + m1~m4 + n2 (#6 v0.2.3)
- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
  store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트

skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
      n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:38:52 +09:00
altair823
348e9ee402 chore(recall): #6 closure — strategy.md 갱신 + roadmap mark + 게이트 검증
- strategy.md §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신
- typecheck 0 / 단위 403 / e2e 1
- v0.2.3 7/7 — 모든 cut 완료. 다음: v0.2.3 binary 빌드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:30:49 +09:00
altair823
646fe7a7ab feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3)
- RecallBanner: 노트 제목 + N일 전 + 3 버튼 (열어보기/다음에/더 이상)
- 첫 렌더 시 emitRecallShown (recallShownIds Set 으로 per-session 1회 제약)
- snoozeUntilMs 만료 setTimeout (ExpiryBanner 패턴)
- 위치: ExpiryBanner 다음 (banner stack 끝)
- NoteCard 외곽 div 에 id="note-${note.id}" — "열어보기" scrollIntoView target
- 컬러 테마: 파랑 (#e8f0fe / #4a7ec0) — 다른 banner (적/황/적) 와 구별

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:28:58 +09:00
altair823
f4e1af83fe feat(recall): renderer store — recallCandidate + 4 actions (#6 v0.2.3)
- recallCandidate, recallSnoozeUntilMs, recallShownIds (Set) state
- loadInitial / refreshMeta 가 listRecallCandidate Promise.all 합류
- loadRecallCandidate / openRecall / dismissRecallNote / snoozeRecall actions
- snoozeRecall: KST 다음 자정 (snoozeExpired 패턴 일관) + emitRecallSnoozed
- openRecall / dismissRecallNote: API 호출 후 다음 후보 fetch
- 신규 store.recall.test.ts +3 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:25:49 +09:00
altair823
20394bf2a3 feat(recall): IPC + preload + InboxApi — 5 channels (#6 v0.2.3)
- ipcMain.handle: list/markOpened/dismiss/emitShown/emitSnoozed
- preload inboxApi: 5 entries (ipcRenderer.invoke)
- shared/types InboxApi: 5 method signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:22:16 +09:00
altair823
0c59ce3715 feat(recall): CaptureService — 5 methods (list/open/dismiss/shown/snoozed) (#6 v0.2.3)
- listRecallCandidate(): repo.findRecallCandidate 위임
- markRecallOpened(id): last_recalled_at 갱신 + recall_opened emit
- dismissRecall(id): recall_dismissed_at 갱신 + recall_dismissed emit
- emitRecallShown(id): ageDays 계산 + recall_shown emit
- emitRecallSnoozed(id): recall_snoozed emit
- private computeAgeDays(note): last_recalled_at ?? created_at 기준 일수
- 단위 +4 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:20:44 +09:00
altair823
59cfb711cd feat(recall): telemetryStats + EmitInput — recall 누적 + 열림율 + 평균 ageDays (#6 v0.2.3)
- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
                 "- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:17:49 +09:00
altair823
b94e68238c feat(recall): telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas (#6 v0.2.3)
- RecallShownPayload { noteId, ageDays: int>=0 } .strict()
- recall_opened/dismissed/snoozed → NoteIdPayload 재사용
- TelemetryEventSchema union 15 → 19
- 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:13:49 +09:00
altair823
0eb2e6282f feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3)
- findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1
- markRecallOpened(id, now): last_recalled_at 갱신
- dismissRecall(id, now): recall_dismissed_at 갱신
- KST 보정 SQL date('now','+9 hours')
- 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:11:14 +09:00
altair823
746671059e docs(recall): #6 plan — 8 tasks TDD + 17 단위 cases (v0.2.3)
8 task TDD plan:
T1 NoteRepository (find/markOpened/dismiss, +5 cases)
T2 telemetryEvents (recall_shown 4 union members, +3 cases)
T3 telemetryStats + EmitInput union 19 (+2 cases)
T4 CaptureService (5 methods, +4 cases)
T5 IPC + preload + types (5 channels)
T6 Renderer store (recallCandidate + 4 actions, +3 cases)
T7 RecallBanner + App.tsx + NoteCard id
T8 closure (strategy.md + roadmap + gates)

총 신규 단위 +17. 단위 386 → 403 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:08:32 +09:00
altair823
e6494b8778 docs(recall): #6 spec — RecallBanner + 4 telemetry events (v0.2.3)
mini-brainstorm 2개 결정:
- Q1=A: snooze in-memory (KST 다음 자정, ExpiryBanner 패턴 일관)
- Q2=B: ageDays = last_recalled_at ?? created_at 기준

자명 결정:
- Banner 위치: ExpiryBanner 다음 (stack 끝)
- 0건 시 null return
- "열어보기" 동작: scrollIntoView (NoteCard 항상 expanded)
- scroll target: id="note-${id}" (ref 시스템 복잡도 회피)

핵심 invariants 6개 + privacy invariant + tests 17개 약속.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:03:58 +09:00
3c9326d6ec Merge pull request 'feat(tag-vocab): #3 태그 vocab — prompt + telemetry (v0.2.3 6/7)' (#18) from feat/v023-tag-vocab into main
Reviewed-on: #18
2026-05-02 03:53:01 +00:00
altair823
d8621d55e0 fix(tag-vocab): PR review round 1 — i1 dedup + m2 test gap (#3 v0.2.3)
- i1 (Important): AiWorker per-tag emit 루프에 res.tags Set dedup
  AI 가 같은 태그 중복 응답 시 hit count 2번 emit 되던 통계 왜곡 수정
  + 테스트 1개 (중복 태그 1 hit + 1 miss 검증)
- m2 (Minor): NoteRepository.getTopUsedTags LIMIT-then-filter 테스트 갭
  + 테스트 1개 (limit=3 + 한글 1 + kebab 2 → 결과 length=2 lock-in)

skip: m1 (per-tag serial await — ai_succeeded 패턴 일관),
      n1 (prompt 빈 줄 cosmetic), n2 (tagId positive — AUTOINCREMENT 1+)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:49:36 +09:00
altair823
ff07738b02 chore(tag-vocab): #3 closure — gates verified + roadmap mark complete
- typecheck 0 / 단위 384 / e2e 1
- v0.2.3 6/7 (#3 태그 vocab 머지)
- 다음: #6 리마인드 spike (마지막 항목)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:37:30 +09:00
altair823
727eeb1919 fix(tag-vocab): T7 review nit 2건 — test 코드 ergonomics (#3 v0.2.3)
- nit1: tag_vocab_hit/miss 테스트 payload cast dedupe (한 번에 typed 바인딩)
- nit2: { kind: string; payload: unknown } 반복을 EmittedEvent 타입 alias 로 hoist

skip: Minor1 (serial await — ai_succeeded 와 패턴 일관), Nit3 (magic number VOCAB_TOP_N — v0.2.4 backlog), Nit4 (한국어 코멘트 — 기존 코드와 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:36:16 +09:00
altair823
3e0f710c70 feat(tag-vocab): AiWorker — vocab fetch + per-tag hit/miss emit (#3 v0.2.3)
- processJob 가 generate 직전 repo.getTopUsedTags(20) fetch
- provider.generate 에 vocab 전달 (LocalOllamaProvider 가 prompt 에 주입)
- ai_succeeded emit 후 per-tag 분류 → tag_vocab_hit/miss emit
  - hit: vocabSet.has + getTagIdByName lookup → { tagId, vocabSize }
  - miss: { vocabSize }
- AiTelemetryEmitter union 4종 (ai_succeeded/ai_failed/tag_vocab_hit/tag_vocab_miss)
- 단위 +4 cases (vocab passthrough, hit+miss, vocab=[] all miss, per-tag emit count)
- collectingTelemetry mock → AiTelemetryEmitter 타입 적용 (typecheck 통과)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:33:16 +09:00
altair823
26f1db5626 feat(tag-vocab): TelemetryService EmitInput +tag_vocab_hit/miss + 테스트 narrowing 확장 (#3 v0.2.3)
- EmitInput union 13 → 15
- narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:29:24 +09:00
altair823
973cb1d08d feat(tag-vocab): telemetryStats — hit/miss 누적 + summary 적중률 (#3 v0.2.3)
- DailyRow +2 cols (tag_vocab_hit, tag_vocab_miss)
- accumulators + branches
- table 컬럼 +2
- summary "- 태그 vocab: hit/miss = N/M (적중률 X%)" 또는 "(데이터 없음)"
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:27:22 +09:00
altair823
b81fc82621 feat(tag-vocab): telemetryEvents — tag_vocab_hit/miss zod schemas (#3 v0.2.3)
- TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict()
- TagVocabMissPayload { vocabSize: int>=0 } .strict()
- TelemetryEventSchema union 13 → 15
- 단위 +3 cases (hit accept, miss accept, hit extra field 거부)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:23:31 +09:00
altair823
daa8507364 feat(tag-vocab): InferenceProvider.vocab + LocalOllamaProvider 전달 (#3 v0.2.3)
- GenerateInput.vocab?: string[] (optional, 미전달 시 빈 배열 처리)
- LocalOllamaProvider.generate 가 input.vocab ?? [] 를 buildPrompt 4th 인자로
- 단위 +1 case (vocab → prompt body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:21:24 +09:00
altair823
896b374f56 fix(tag-vocab): T2 review minor/nit 2건 (#3 v0.2.3)
- M1: prompt.test.ts test 4 변수명 candidateIdx → headerIdx (실제 anchor 가 'Today's date' 헤더)
- N1: prompt.ts return 직전 self-delimited block 컨벤션 1줄 코멘트

skip: N2 (PROMPT_VERSION 테스트 redundancy nit — harmless guard), N3 (vocab dedup/normalize — Task 1 caller 책임)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:19:39 +09:00
altair823
134d59ddb4 feat(tag-vocab): prompt.ts — PROMPT_VERSION 4 + vocab parameter (#3 v0.2.3)
- PROMPT_VERSION 3 → 4 (marker bump, retry 트리거 X)
- buildPrompt 4번째 param vocab: string[] = []
- vocab.length > 0 시 "Existing vocabulary tags" + "Prefer reusing" 라인 추가
- vocab=[] 시 라인 자체 생략 (Q3=B 결정)
- 단위 +4 cases (신규 prompt.test.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:17:17 +09:00
altair823
e2b16d44d7 fix(tag-vocab): T1 review minor/nit 4건 일괄 (#3 v0.2.3)
- M1: getTopUsedTags 의 LIMIT-then-filter 의미 docstring 명시
- M2: AI+user source 통합 테스트 강화 — 카운트 차이로 정렬 검증 (toContain 만으론 약함)
  updateUserAiFields 는 tags REPLACE 방식 (DELETE+reinsert) 이므로
  fallback 패턴 사용: 3개 노트 각 1태그, AI/user 혼합으로 design=2 > meeting=1 검증
- N1: SQL "COUNT(*) c" → "COUNT(*) AS c" (countFailed 패턴과 일관)
- N2: kebab-case regex 모듈 상수 KEBAB_CASE_RE 로 hoist

skip: N3 (test 헬퍼 — verbosity 경미), N4 (it 블록 분리 — 코드베이스 패턴 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:14:53 +09:00
altair823
df8a53aec1 feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3)
- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외
- getTagIdByName(name): COLLATE NOCASE lookup
- AI+user source 통합 카운트 (Q1=C 결정)
- 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:10:36 +09:00
altair823
853ca39c0d docs(tag-vocab): #3 plan — 8 tasks TDD + 21 단위 cases (v0.2.3)
8 task TDD plan:
T1 NoteRepository (getTopUsedTags + getTagIdByName, +7 cases)
T2 prompt.ts (PROMPT_VERSION 4 + vocab param, +4 cases, 신규 prompt.test.ts)
T3 InferenceProvider + LocalOllamaProvider (vocab passthrough, +1 case)
T4 telemetryEvents (zod schemas, +3 cases)
T5 telemetryStats (누적 + summary, +2 cases)
T6 TelemetryService EmitInput + narrowing 확장
T7 AiWorker (vocab fetch + per-tag emit, +4 cases)
T8 closure (gates + roadmap)

총 신규 단위 +21 (spec budget 19 + 2 surplus). 단위 363 → 382 (±5) 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:07:28 +09:00
altair823
8206462ee4 docs(tag-vocab): #3 spec — vocab pool/telemetry/prompt 강도/재처리 결정 (v0.2.3)
mini-brainstorm 4개 결정:
- Q1=C: vocab pool = AI+user 통합 + kebab-case 필터
- Q2=A: telemetry emit 단위 = 태그별 (per-tag hit/miss)
- Q3=B: prompt 강도 = "Prefer" (우선, MUST 아님)
- Q4=A: 기존 노트 재처리 = 자연 진화 (X)

핵심 invariant 6개 + privacy invariant + tests ≥19개 약속.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:02:06 +09:00
dbbec38079 Merge pull request 'feat(retry): #2 AI retry 수동 trigger (v0.2.3 5/7)' (#17) from feat/v023-ai-retry into main
Reviewed-on: #17
2026-05-02 02:44:42 +00:00
altair823
8f56814186 fix(retry): review round 1 — minor/nit 4건 일괄 (#2 v0.2.3)
m1 — NoteRepository.test.ts 에 retryAllFailed OR IGNORE race-safe 회귀
가드 1 case 추가. failed 노트인데 pending_jobs row 가 이미 존재하는
비정상 race 상태 시뮬레이션 → INSERT OR IGNORE 라 duplicate 안 됨,
기존 attempts/next_run_at 보존.

m2 — store.retryAllFailed 의 r.count 무시 의도 주석 1줄.
단일 process (Electron) 환경 + 모든 ai_status='failed' 가 retry 대상이라
사용자 시점 카운트는 0 reset 가 정확.

n1 — AiWorker unreachableBackoffStep increment 명료화.
Math.min(..., length-1) → 명시적 if 가드 (step < length-1) 로 cap 도달 시
no-op 의도 가시화. 동작 동일.

n2 — AiWorker.processJob 의 max 의미 주석 1줄. unreachable/timeout 분기는
attempt -= 1 로 인덱스 stay 라 max 무관 — future maintainer 위해 명시.

n3 (FailedBanner inline style) 은 v0.2.4 backlog (banner theme cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:47:08 +09:00
altair823
95bbe9cd22 chore(retry): #2 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 362/362 (T1~T7 누적 18 신규)
- e2e 1/1
- roadmap §3 #2 ✓ 완료 마커

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:37:34 +09:00
altair823
e4a0be15ae feat(retry): tray '지금 AI 처리' 9th callback + main wiring (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:36:44 +09:00
altair823
406a5e61f0 feat(retry): FailedBanner + App.tsx mount (#2 v0.2.3) 2026-05-02 03:34:09 +09:00
altair823
3ebd3bc9a5 feat(retry): store retryAllFailed action + failedCount (#2 v0.2.3) 2026-05-02 03:32:01 +09:00
altair823
6e5f3703d7 feat(retry): CaptureService.retryAllFailed + IPC 2 channels (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:28:11 +09:00
altair823
12c267aabd feat(retry): telemetry ai_retry_manual + stats AI 수동 재시도 (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:24:31 +09:00
altair823
449eb76683 feat(retry): AiWorker unreachable/timeout 무한 retry — 15분 cap (#2 v0.2.3) 2026-05-02 03:19:43 +09:00
altair823
2e3f0edffd feat(retry): NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt (#2 v0.2.3) 2026-05-02 03:15:05 +09:00
altair823
821db4001d docs(plan): v0.2.3 #2 AI retry / 수동 trigger 구현 계획
8 task TDD 분할 + 단위 ≥ 18개 (spec §6 의 17개 충족 + 1 over):
- T1 NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt
- T2 AiWorker unreachable/timeout 무한 retry (15분 cap)
- T3 telemetry ai_retry_manual + stats
- T4 CaptureService.retryAllFailed + IPC 2채널
- T5 store retryAllFailed action + failedCount
- T6 FailedBanner + App.tsx mount
- T7 tray '지금 AI 처리' 9th callback
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:08:06 +09:00
altair823
f50cabcc62 docs(spec): v0.2.3 #2 AI retry / 수동 trigger design
mini-brainstorm 결정 3개:
- Q1=A unreachable backoff cap 15분 (30s→60s→120s→240s→480s→900s)
- Q2=A timeout 도 unreachable 동일 (무한 retry, attempts 증가 안 함)
- Q3=A retry-all 만 (per-note 버튼 v0.2.4)

AiWorker unreachable/timeout 무한 retry + schema/other max 3 유지
+ retryAllFailed atomic + FailedBanner (Inbox stack 4번째)
+ tray '지금 AI 처리 (실패 N건)' 9th callback
+ ai_retry_manual telemetry.

roadmap §3 #2 deviation 1건 (timeout) 의식적 — v0.2.4 dogfood 데이터로 영구 hang 케이스 식별 후 가다듬기.

T1-T8 작업 순서 + 단위 ≥ 17개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:00:49 +09:00
37292f1a53 Merge pull request 'feat(ollama): #1 Ollama 회복 polling (v0.2.3 4/7)' (#16) from feat/v023-ollama into main
Reviewed-on: #16
2026-05-01 17:08:44 +00:00
altair823
b6c307148d chore: remove accidental review artifacts (.pr-16-*.json) 2026-05-02 02:04:43 +09:00
altair823
a94c7578b7 fix(ollama): review round 1 — minor/nit 7건 일괄 (#1 v0.2.3)
m1 — HealthChecker.last={ok:true} sentinel 의도 주석 (line 17).
  첫 healthy=ok=true 면 transition 으로 인식 안 됨, ok=false 면 unreachable
  transition 으로 정상 인식. telemetry 누락 0.

m2 — runOnce in-flight guard 추가. polling 첫 호출이 늦게 끝나는 동안
  setInterval 가 두 번째 호출 시작하면 같은 promise 반환. healthCheck 가
  idempotent HTTP 라 race 안전하지만, 이중 onUpdate/telemetry emit 회피.

m3 — main.ts before-quit 핸들러 통합. trayInterval cleanup 별도 핸들러
  (line 349) 제거하고 health.stop() 핸들러 안에 흡수. 모든 cleanup 한 곳.

n1 — OllamaBanner 재확인 button 의 onClick 에 .catch 추가.
  recheckOllama Promise rejection 시 console.warn (silent swallow 회피).

n2 — App.tsx useEffect deps array 의도 주석 1줄. onOllamaStatus 콜백이
  useInbox.setState 직접 호출 — store reference 안정적이라 deps 불필요.

n3 — HealthChecker idempotent test 강화. <=2 → ===2 (정확).
  두 timer 등록되면 4 (각 timer 마다 즉시+1s) 가 됨.

n4 — runOnce 의 manual emit 이 healthCheck *전에* fire 인 의도 주석.
  provider 실패 시에도 manual 카운트 1:1 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:04:25 +09:00
altair823
d8f4ae5f6b chore(ollama): #1 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 344/344 (T1~T7 누적 17 신규)
- e2e 1/1
- roadmap §3 #1 ✓ 완료 마커

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:47:54 +09:00
altair823
cdf2e4bc47 feat(ollama): OllamaBanner 재확인 button (#1 v0.2.3) 2026-05-02 01:46:18 +09:00
altair823
557960ff5a feat(ollama): tray 'Ollama 재확인' 메뉴 + 8th callback (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:44:11 +09:00
altair823
c78f3af3a6 feat(ollama): InboxApi + preload + store recheckOllama + onOllamaStatus subscriber (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:41:04 +09:00
altair823
410a6f494b feat(ollama): IPC inbox:ollamaRecheck + pushOllamaStatus helper (#1 v0.2.3) 2026-05-02 01:37:47 +09:00
altair823
e30e436051 feat(ollama): main wiring — health.start + before-quit stop (#1 v0.2.3) 2026-05-02 01:34:33 +09:00
altair823
a68ffe0aeb feat(ollama): telemetry 3 events — unreachable/recovered/recheck_manual (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:30:26 +09:00
altair823
12681e431c feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3) 2026-05-02 01:25:26 +09:00
altair823
f299926f58 docs(plan): v0.2.3 #1 Ollama 회복 polling 구현 계획
8 task TDD 분할 + 단위 ≥ 17개 (spec §6 의 12개 충족 + 5 over):
- T1 HealthChecker.start/stop + delta + onTelemetry hook
- T2 telemetry 3 events + stats.md (downtime 평균 / unreachable 빈도 / recheck 사용량)
- T3 main wiring — health.start + before-quit stop + onUpdate→push
- T4 IPC inbox:ollamaRecheck + pushOllamaStatus helper
- T5 InboxApi + preload + store recheckOllama + onOllamaStatus subscriber
- T6 tray 'Ollama 재확인' 메뉴 + 8th callback
- T7 OllamaBanner 재확인 button
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:22:06 +09:00
altair823
050e7f08f1 docs(spec): #1 ollama — runOnce({manual}) + ollama_recheck_manual via hook
§2.1 / §3.2 / §11 보강 — IPC handler 가 직접 telemetry.emit 안 하고
HealthChecker.runOnce({ manual: true }) 호출 → onTelemetry hook 으로
ollama_recheck_manual 발화. 단위 테스트 가능 (HealthChecker 레이어).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:18:28 +09:00
altair823
f36b9ecb5b docs(spec): v0.2.3 #1 Ollama 회복 polling design
mini-brainstorm 결과 3개 결정:
- Q1=A polling 주기 60s
- Q2=A 절대 중단 안 함
- Q3=A constant (no backoff)

HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events
(ollama_unreachable / ollama_recovered / ollama_recheck_manual)
+ main → renderer push (ollama:status) + manual recheck (banner + tray).

T1-T7 작업 순서 + 단위 ≥ 12개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:16:14 +09:00
da7455b25f Merge pull request 'feat(expiry): #5 만료 추천 (v0.2.3 3/7)' (#15) from feat/v023-expiry into main
Reviewed-on: #15
2026-05-01 15:52:37 +00:00
altair823
d672ec3afa fix(expiry): review round 1 — minor/nit 6건 일괄 (#5 v0.2.3)
m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소).

m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant).

m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지.

m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지)

n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거)

n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해.

n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:47:58 +09:00
altair823
8a96d5279d chore(expiry): #5 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 326/326 (T1~T7 누적 26 신규)
- e2e 1/1
- spec §3 IPC 채널명 inbox:trashBatch → inbox:trashExpiredBatch 보정 (의미 명확화)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:25:15 +09:00
altair823
7cbbd4dc97 feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3) 2026-05-02 00:22:38 +09:00
altair823
b7205597db feat(expiry): zustand store extension — expiredCandidates + snooze (#5 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:18:11 +09:00
altair823
749235f65d feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3) 2026-05-02 00:13:49 +09:00
altair823
f76ca06d9e feat(expiry): telemetry 2 events — expired_banner_shown / expired_batch_trash (#5 v0.2.3) 2026-05-02 00:08:44 +09:00
altair823
fec80361dd feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3) 2026-05-02 00:01:03 +09:00
altair823
00423fb235 feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3) 2026-05-01 23:57:53 +09:00
altair823
0a9dab4a7f feat(expiry): KST util — todayInKstString + nextKstMidnightMs (#5 v0.2.3) 2026-05-01 23:53:20 +09:00
altair823
a5e6859ac9 docs(plan): v0.2.3 #5 만료 추천 구현 계획
8 task TDD 분할 + 단위 26개 (spec §8 의 16개 충족 + 6 over):
- T1 KST util (todayInKstString + nextKstMidnightMs)
- T2 NoteRepository.findExpiredCandidates
- T3 NoteRepository.trashBatch (atomic)
- T4 telemetry 2 events + stats.md 만료 trash ratio
- T5 CaptureService listExpired/trashExpiredBatch + IPC 2채널 + preload
- T6 zustand store 확장
- T7 ExpiryBanner 컴포넌트 + App.tsx mount
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:30:48 +09:00
altair823
c45e613b31 docs(spec): #5 expiry — move dedup to main, keep IPC at 2 channels
§6.2 의 expired_banner_shown signature dedup 위치를 zustand store(renderer)
→ CaptureService(main) 로 변경. 결과: 신규 IPC 채널 1개 추가 회피.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:25:12 +09:00
altair823
4c2769fd82 docs(spec): v0.2.3 #5 만료 추천 design
mini-brainstorm 결과 5개 결정 박힘:
- Q1=B due_date_edited_by_user 필터 없음 (AI + 수동 모두)
- Q2=A 만료만 (D-7 임박 v0.2.4)
- Q3=C unchecked default + 전체선택 토글 (데이터 안전)
- Q4=B PendingBanner 아래 (system → progress → actionable)
- Q5=A 후보 0건 / snooze 시 collapse (PendingBanner 패턴)

T1-T10 작업 순서 + 단위 ≥ 16개 + IPC 2채널 + telemetry 2이벤트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:22:38 +09:00
df60c5a5b2 Merge pull request 'feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7)' (#14) from feat/v023-trash into main
Reviewed-on: #14
2026-05-01 14:06:21 +00:00
altair823
87b6d71628 fix(trash): add repo.countTrashed() — fix UI 200-cap mismatch (review 회차 1)
PR #14 회차 1 review actionable — `inbox:trashCount` 와 `emptyTrash` dialog
가 `listTrashed({limit:200})` 로 카운트를 도출하면서 (a) hot path 에서 N rows
+ tags/media JOIN hydrate 비효율 (b) trash > 200 시 dialog message 가
실제 SQL DELETE 동작과 mismatch ('200개 영구 삭제합니다' 표시 vs 500개
실제 삭제) 발생.

NoteRepository.countTrashed() — `SELECT COUNT(*) FROM notes WHERE deleted_at
IS NOT NULL` 단일 쿼리. hydrate 없이 정확한 카운트만 반환. 두 IPC 핸들러를
이 메서드 호출로 교체.

테스트: 3 신규 단위 테스트 (0 trash / 부분 trash / 200 cap 초과 범위)
292 → 295 (+3). typecheck 0 errors.

deferrable (v0.2.4 backlog 그대로): AiWorker race guard 강화, restore self-guard,
limit 200 매직 넘버 상수화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:45:11 +09:00
altair823
2ac4d648c1 chore(trash): #4 closure — gates verified + roadmap mark complete
v0.2.3 #4 휴지통 (soft delete + migration v3) 종료.

게이트:
- typecheck: 0 errors
- 단위 테스트: 245 → 292 (+47, schema/repo/AiWorker/CaptureService/Continuity/
  ImportService/ExportService/store 전반)
- e2e smoke: 1/1 PASS

기능:
- migration v3 — deleted_at + last_recalled_at + recall_dismissed_at
- NoteRepository: trash/restore/permanentDelete/emptyTrash/listTrashed
- AiWorker.processJob deletedAt 가드
- CaptureService 4 신규 메서드 + idempotency 가드 + 4 telemetry emit
- telemetryStats: 4 신규 컬럼 + 휴지통 회수율 ratio
- ImportService: deletedAt 보존 + skip-merge 정책
- ExportService 회귀 가드 (T5 listAll filter 자동 동작)
- IPC 5 신규 채널 + native dialog confirm
- zustand store: showTrash/trashNotes/trashCount + 5 actions
- App.tsx 헤더 탭 + 휴지통 view + bulk 비우기
- NoteCard mode='trash' read-only

기타 fix (cross-task):
- ContinuityService streak 가 trash 노트 무시
- getPendingCount 가 trash 노트 무시 (drift 방지)
- MediaGc intentional non-filter 주석 (restore 시 media 보존)

deferred (v0.2.4 backlog):
- exhaustiveness check on stats union
- restore 시 pending_jobs 재생성 정책
- inbox:trashCount cap 200 → repo.countTrashed()
- inbox:delete 채널 rename
- 탭 ARIA role="tab" 정정
- per-note 영구 삭제 텔레메트리 기반 retire 검토

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:53:26 +09:00
altair823
03bca3ed59 feat(trash): Inbox 탭 toggle + 휴지통 view + NoteCard mode prop (#4 v0.2.3) 2026-05-01 21:48:15 +09:00
altair823
df85b88424 fix(trash): T13 review — trashCount clobber guard + restoreNote test (review I1+I2+M5)
- I1: trashCount 가 upsertNote 안에서 항상 trashNotes.length 로 덮어써져
  server 값 (refreshMeta) 손상. showTrash=true (trashNotes cache-loaded)
  일 때만 local recompute.
- I2: restoreNote 의 "fallback for missed event" 주석 부정확 — main 은
  trash/restore 시 pushNoteUpdated 안 보냄. 자가 갱신이 primary mechanism.
  주석 정정.
- M5: restoreNote 테스트가 IPC 호출만 검증, 노트 이동 미검증. trashNotes
  → notes 라우팅 + deletedAt=null 어설션 추가. + I1 회귀 가드 테스트 신규.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:43:59 +09:00
altair823
99cdc346d2 feat(trash): zustand store — showTrash/trashNotes/trashCount + 5 actions (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:38:30 +09:00
altair823
3e4ad6ec91 refactor(trash): emptyTrash IPC dedup query (review T12 nit) 2026-05-01 21:35:31 +09:00
altair823
dd74aec884 feat(trash): IPC 5 channels + native dialog confirm + InboxApi extension (#4 v0.2.3) 2026-05-01 21:32:22 +09:00
altair823
cdceb609e6 test(trash): ExportService excludes trashed notes (regression guard, #4 v0.2.3) 2026-05-01 21:28:12 +09:00
altair823
6f0d032ff1 refactor(trash): import skip-merge reuses trash() for pending_jobs invariant (review T10 minor #1) 2026-05-01 21:26:54 +09:00
altair823
a5f23b925e feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3) 2026-05-01 21:23:23 +09:00
altair823
468ea90d6c fix(trash): idempotency guards on delete/restore/permanent (review T9 important #1+#2)
review T9 flagged 2 service-layer defenses:

#1: deleteNote/restoreNote/permanentDeleteNote 의 idempotency. 이미 trash 인
노트를 trash 하거나, 이미 active 인 노트를 restore 하거나, 존재하지 않는 노트를
permanentDelete 시 telemetry 가 spurious 하게 emit → restore/trash ratio (T8)
오염. findById 가드로 의미 없는 emit skip.

#2: permanentDeleteNote 의 disk cleanup unguarded. store.deleteNoteDirectory
실패 시 (Windows file-lock 등) telemetry 가 영영 emit 안 되고 IPC 호출자가
이미 성공한 작업에 에러 propagate. emptyTrash 와 동일하게 try/catch 로 감싸
best-effort. orphan dir 은 future janitor 가 정리.

Tests: 12/12 still pass. typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:20:03 +09:00
altair823
b19ea6423a feat(trash): CaptureService soft-delete + restore/permanent/empty + 4 emits (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:16:26 +09:00
altair823
e6a945cad4 feat(trash): telemetryStats 4 new counters + 휴지통 회수율 ratio (#4 v0.2.3) 2026-05-01 21:11:15 +09:00
altair823
c5329f1ccc refactor(test): replace as-cast with discriminant narrowing (review T7 I-1) 2026-05-01 21:09:04 +09:00
altair823
284bfcbdd1 feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:05:06 +09:00
altair823
78c10e8817 feat(trash): AiWorker.processJob deletedAt guard (#4 v0.2.3) 2026-05-01 21:00:09 +09:00
altair823
3c780a7464 fix(trash): close active-query invariant leaks (review T5 important #1+#2)
T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.

- ContinuityService.get() now excludes trashed notes from streak / weekCount
  / lastNoteAt / recovery-toast math. A trashed note no longer counts toward
  weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
  notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
  the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
  NOT filter — trashed notes still own their media until permanentDelete /
  emptyTrash. Removing here would defeat restore.

Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.

Tests: 245 → 265 (+20). typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:58:18 +09:00
altair823
2203bcf65b feat(trash): active queries exclude deleted_at IS NOT NULL (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:53:03 +09:00
altair823
70a69f0ae3 refactor(trash): emptyTrash uses DELETE...RETURNING (review T4 S1) 2026-05-01 20:51:06 +09:00
altair823
11703b976e feat(trash): NoteRepository.permanentDelete/emptyTrash/listTrashed (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:47:05 +09:00
altair823
bf49b8351e feat(trash): NoteRepository.restore (#4 v0.2.3) 2026-05-01 20:42:42 +09:00
altair823
13da554461 feat(trash): NoteRepository.trash with pending_jobs cleanup (#4 v0.2.3) 2026-05-01 20:38:17 +09:00
altair823
3797e6c4f3 docs(m003): add dormant-columns rationale comment (review T1 minor #1) 2026-05-01 20:36:27 +09:00
altair823
5bcfd26bfd feat(trash): migration v3 + Note type extension (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:32:52 +09:00
altair823
b93185edd5 docs(plan): #4 휴지통 구현 계획 (v0.2.3 2/7)
15 task TDD plan — migration v3, Note type extension, NoteRepository 신규
4메서드 + active query 일괄 변경, AiWorker deletedAt guard, telemetry 4 new
kinds + stats.md 회수율 ratio, CaptureService soft delete + 3 신규 메서드
+ 4 emit, ImportService deletedAt 보존, ExportService 회귀 가드, IPC 5 신규
채널 + native dialog confirm, zustand store + 5 actions, Inbox 탭 toggle +
NoteCard mode prop, 게이트 + closure marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:16:26 +09:00
altair823
61e277f36c docs(spec): #4 휴지통 (soft delete + migration v3) 설계
v0.2.3 두 번째 항목의 mini-brainstorm 결과 lock.

UI=A (Inbox 탭 toggle), 필터=A (명시적 WHERE deleted_at IS NULL),
AiWorker race=C (pending_jobs cleanup + processJob 가드),
액션=B (per-card 영구 삭제 추가 — IPC 4채널 → 5채널, telemetry 3 → 4 events),
confirm/정렬/카드차이 모두 A.

self-review 후 ExportService/ImportService 충돌 정책 ambiguity 명시화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:04:47 +09:00
6f8ae75ff7 Merge pull request 'feat(telemetry): #7 telemetry skeleton (v0.2.3 1/7)' (#13) from feat/v023-telemetry into main
Reviewed-on: #13
2026-05-01 10:37:55 +00:00
altair823
7e8e2b598d fix(telemetry): 회차 1 review 반영 — attempts 의미 통일 + DI 우회 제거 + 매직 슬라이스 제거
PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영.

- `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨.
- `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능.
- `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거.
- `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정.

테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신.
게이트: typecheck 0 errors, 245/245 unit tests pass.

Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:41:26 +09:00
altair823
5c97397cbe chore(telemetry): #7 closure — gate verification + .catch consistency + spec fix
- Add .catch(...) to telemetry.cleanupOldFiles fire-and-forget for consistency
  with backup.runDaily pattern (M1 from T10 code review).
- Mark Roadmap §3 #7 as completed (✓).
- Correct spec: tray:exportTelemetry was never an IPC channel — tray callbacks
  run in main process directly. Replace with "트레이 콜백 (main 내부)".

Closes v0.2.3 task 1 of 7. Next task: #4 휴지통 (migration v3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:37:00 +09:00
altair823
fe24ff577f feat(telemetry): wire TelemetryService + tray export (#7 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:30:54 +09:00
altair823
dca6aed44e docs(tray): restore F4-C identity-signal intent comment
The T9 full-file replacement accidentally dropped the inline comment
documenting why the count label is conditional on _todayCount > 0
(F4-C UX rationale). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:28:04 +09:00
altair823
4213745dc7 feat(telemetry): tray menu '사용 로그 내보내기...' (#7 v0.2.3) 2026-05-01 17:25:52 +09:00
altair823
01447ddaad feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3) 2026-05-01 17:21:08 +09:00
altair823
f0cef95d3f feat(telemetry): CaptureService emits capture event (#7 v0.2.3) 2026-05-01 17:15:24 +09:00
altair823
36a5c67ed6 feat(telemetry): exportTo writes events.jsonl + stats.md (#7 v0.2.3) 2026-05-01 17:08:34 +09:00
altair823
2036c687d2 test(telemetry): add KST regression test for near-midnight UTC bucketing
Original 'counts events per KST day' test used UTC times that bucket
identically under both KST and naive UTC slice — would not catch a regression
where kstDate was replaced with ev.ts.slice(0,10). Add an explicit
near-midnight case (2026-05-01T15:30Z = 2026-05-02 00:30 KST) that fails
under naive UTC and passes under correct KST conversion.

6 tests pass (was 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:06:29 +09:00
altair823
9a066ed807 feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3) 2026-05-01 17:03:31 +09:00
altair823
729a3f9c47 feat(telemetry): readAllRecent with malformed-line tolerance (#7 v0.2.3) 2026-05-01 16:58:45 +09:00
altair823
0501bd1762 feat(telemetry): cleanupOldFiles with 14-day KST retention (#7 v0.2.3) 2026-05-01 16:54:36 +09:00
altair823
50b6d05bcb fix(telemetry): silent-fs-error test exercises the actual code path
Earlier test used '/proc/0/...' as the unwritable dir. On Windows this
resolved to 'C:\proc\0\...' and mkdir({recursive: true}) silently created
it — the silent code path was never exercised, plus filesystem side-effect
leaked outside the test tmpdir.

Replace with a path that points to an existing file (mkdir on a file path
fails on every platform). Also add a companion test that confirms silent
is opt-in: without {silent: true}, the same fs failure DOES throw.

7 tests pass (was 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:52:11 +09:00
altair823
93e278b241 feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3) 2026-05-01 14:18:59 +09:00
altair823
0a0ef11327 feat(telemetry): event schema + privacy invariant (#7 v0.2.3) 2026-05-01 14:14:19 +09:00
altair823
358cada017 docs(plan): #7 telemetry skeleton 구현 계획 (v0.2.3 1/7)
11 task TDD plan — events schema/privacy invariant, JSONL emit/rotation,
14d cleanup, readAllRecent, stats aggregator, exportTo(folder),
CaptureService/AiWorker hooks, tray menu, index.ts wiring, gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:02:48 +09:00
altair823
22a25cc622 docs(spec): v0.2.3 dogfood feedback roadmap (7 items, single cut)
v0.2.2 dogfood 7항목 (#7 telemetry 신설 + #1~#6) 단일 cut 로드맵.
데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B),
trash↔backup/export B 정책, #6 = 1 spike 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:56:16 +09:00
77 changed files with 19577 additions and 243 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,352 @@
# v0.2.3 #2 AI retry / 수동 trigger 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 5번째 항목 (#2 AI retry) 의 mini-brainstorm 결정 + design. roadmap §3 #2 의 In/Out 위에서 §8 미결정 3항목 (unreachable backoff cap / reason 분류 정밀도 / per-note retry) 결정.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #2, §8
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15), #1 ollama 회복 (PR #16)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 unreachable backoff cap | **A 15분 exponential** | 30s → 60s → 120s → 240s → 480s → 900s cap. 회복 latency 짧으면서 오래 꺼지면 부하 미미. |
| Q2 timeout 분류 | **A unreachable 동일** (무한 retry) | timeout 99% 는 임시 (gemma cold start, 큰 입력). 영구 hang 케이스는 v0.2.4 dogfood 후 식별. roadmap §3 #2 In 과 deviation — 의식적. |
| Q3 per-note retry | **A retry-all 만** | UI 노이즈 회피. NoteCard 단건 버튼은 v0.2.4 dogfood 마찰 발생 시 추가. |
---
## 2. AiWorker.processJob 정책 변경
### 2.1 분기 로직
`classifyReason(err)` 결과로 분기:
```ts
const reason = classifyReason(err);
if (reason === 'unreachable' || reason === 'timeout') {
// 무한 retry 경로: attempts 증가 안 함, in-job loop 안에서 sleep + retry
const sleepMs = nextBackoffMs(this.unreachableBackoffStep);
this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, 5);
this.repo.setNextRunAt(job.noteId, new Date(Date.now() + sleepMs).toISOString(), msg);
await this.sleep(sleepMs);
// for 루프의 attempt 인덱스 그대로 — 다음 try 도 같은 attempt 번호로 재시도
attempt -= 1; // for 루프의 attempt++ 상쇄
continue;
} else {
// schema / other: 기존 max 3 retry 정책 그대로
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
if (this.telemetry) {
await this.telemetry.emit({ kind: 'ai_failed', payload: { ... } }).catch(() => {});
}
this.emit(job.noteId);
return;
}
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
}
```
성공 시 `unreachableBackoffStep = 0` 으로 reset.
### 2.2 backoff schedule
```ts
private readonly UNREACHABLE_BACKOFFS_MS = [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
private nextBackoffMs(step: number): number {
return this.UNREACHABLE_BACKOFFS_MS[Math.min(step, 5)];
}
```
기존 `backoffsMs = [0, 30_000, 120_000]` 은 schema/other 전용 그대로.
### 2.3 invariants
- unreachable/timeout: `markAiFailed` 절대 호출 안 함. `ai_failed` telemetry emit 안 함.
- schema/other: 기존 동작 (max 3 후 markAiFailed + emit).
- 결과: `ai_failed.reason` 통계에는 schema/other 만 누적 (Q2 = A 의 자연 결과).
---
## 3. NoteRepository 확장
```ts
// src/main/repository/NoteRepository.ts
findFailedIds(): string[];
// SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC
countFailed(): number;
// SELECT COUNT(*) FROM notes WHERE ai_status='failed' AND deleted_at IS NULL
retryAllFailed(now: string): { ids: string[] };
// 단일 transaction 안에서:
// UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=now WHERE id IN (...)
// INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, now)
// (이미 pending_jobs row 가 있으면 OR IGNORE — race 가드)
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void;
// UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?
// attempts 변경 없음 — unreachable/timeout 무한 retry 용
```
---
## 4. CaptureService + IPC
### 4.1 CaptureService 메서드
```ts
async retryAllFailed(): Promise<{ count: number }> {
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
for (const id of ids) {
await this.deps.enqueue(id);
}
if (this.deps.telemetry && ids.length > 0) {
await this.deps.telemetry.emit({
kind: 'ai_retry_manual',
payload: { failedCount: ids.length }
}).catch(() => {});
}
return { count: ids.length };
}
```
빈 배열 시 telemetry emit 안 함 — 사용자가 "재시도" 클릭해도 N=0 이면 noise.
### 4.2 IPC 채널 신규 2
| 채널 | 입력 | 출력 |
|------|------|------|
| `inbox:retryAllFailed` | (없음) | `{ count: number }` |
| `inbox:failedCount` | (없음) | `number` |
confirm dialog 불필요 — destructive 아님 (단순 재처리 큐 등록, 데이터 손실 없음).
---
## 5. Tray + Banner UI
### 5.1 Tray 메뉴
기존 (#1 cut 후):
```
- 사용 로그 내보내기...
- Ollama 재확인 (status.ok=false 시 enabled)
```
신규 (본 cut):
```
- 사용 로그 내보내기...
- Ollama 재확인 (status.ok=false 시 enabled)
- 지금 AI 처리 (실패 N건) (failedCount > 0 시 enabled, label dynamic with N)
```
`refreshTrayFailedCount(count: number)` setter — `refreshTrayOllama` 와 동일 패턴. `_failedCount` module-level state + 메뉴 rebuild.
`createTray` 의 9번째 callback `runRetryAllFailed`. 8 → 9 positional. v0.2.4 backlog #4 (TrayCallbacks object refactor) trigger 더 강화.
AiWorker.onUpdate 시점에 `refreshTrayFailedCount(repo.countFailed())` 호출.
### 5.2 FailedBanner
`src/renderer/inbox/components/FailedBanner.tsx` (신규):
```tsx
import React from 'react';
import { useInbox } from '../store.js';
export function FailedBanner(): React.ReactElement | null {
const count = useInbox((s) => s.failedCount);
const retryAllFailed = useInbox((s) => s.retryAllFailed);
if (count === 0) return null;
return (
<div className="banner warn" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button onClick={() => { retryAllFailed().catch((e) => console.warn('retryAllFailed failed', e)); }}>
</button>
</div>
);
}
```
스타일: warn variant 색상은 PendingBanner 와 다른 차별 (#fff7e6 / #d99500 의 ExpiryBanner 와도 다름) — 본 banner 는 빨강 톤 (#fce4e4 / #a33). 사용자 주의 필요한 영구 실패 신호.
### 5.3 Inbox 상단 stack 갱신
```
1. OllamaBanner (system - down)
2. RecoveryToast (회복 toast)
3. PendingBanner (AI 처리 N건 - 일시)
4. FailedBanner (AI 실패 N건 - 영구 - 신규)
5. ExpiryBanner (만료)
6. tagFilter chip
7. notes
```
위치 근거: system status > 진행 (transient) > 영구 실패 (actionable) > 트리아지 (expiry, also actionable but lower urgency) > filter > content.
---
## 6. zustand store
```ts
// InboxState 확장
failedCount: number;
retryAllFailed: () => Promise<void>;
```
initial: `failedCount: 0`.
`loadInitial` / `refreshMeta``Promise.all``inboxApi.getFailedCount()` 합류 → `set({ failedCount })`.
`retryAllFailed` action:
```ts
async retryAllFailed() {
const r = await inboxApi.retryAllFailed();
// 낙관적 갱신: failedCount = 0 으로 reset (worker 처리 진행 중)
// 실제 카운트는 AiWorker.onUpdate 트리거된 refreshMeta 에서 자연 동기.
// PendingBanner 가 처리 중 N 건 노출.
set({ failedCount: 0 });
// r.count 는 telemetry/log 정보용
}
```
---
## 7. Telemetry
### 7.1 신규 1 event
| event | payload | 발화 |
|-------|---------|------|
| `ai_retry_manual` | `{ failedCount: number }` (≥1) | retryAllFailed 시 ids.length>0 일 때만 |
빈 배열 시 emit 안 함 — sentinel.
### 7.2 zod schema
```ts
const AiRetryManualPayload = z.object({
failedCount: z.number().int().positive() // ≥1 enforced — 0 emit 자체가 invariant violation
}).strict();
```
### 7.3 stats.md 집계
신규 행 (수동 recheck 사용량 다음):
- AI 수동 재시도 사용량: `count` 회 / 누적 `Σfailedcount`
`DailyRow` 에 1 카운터 + sum 누적기 추가.
### 7.4 기존 `ai_failed` 영향
변경 없음. unreachable/timeout 가 markAiFailed 안 부르므로 자연히 reason 분포에서 제외. 결과: `ai_failed.reason` 분포 = schema + other 만. dogfood 통계 의미 명확화.
---
## 8. 테스트
| 영역 | 케이스 | 검증 |
|------|--------|------|
| AiWorker | unreachable 무한 retry | attempts 증가 안 함, markAiFailed 안 호출, ai_failed emit 안 함 |
| AiWorker | timeout 무한 retry (Q2=A) | unreachable 와 동일 경로 |
| AiWorker | schema fail max 3 | attempts 증가, 마지막에 markAiFailed + ai_failed emit |
| AiWorker | other fail max 3 | schema 와 동일 |
| AiWorker | unreachable backoff step | 1차 30s, 2차 60s, ..., 6차 900s cap |
| AiWorker | success 시 unreachableBackoffStep reset | 다음 unreachable 발생 시 30s 부터 |
| Repo | findFailedIds — failed + active 만 | trashed 또는 pending/done 제외 |
| Repo | countFailed | 정확 |
| Repo | retryAllFailed atomic | ai_status reset + pending_jobs 재투입 |
| Repo | retryAllFailed empty | `{ ids: [] }` |
| Repo | retryAllFailed pending_jobs 이미 존재 | OR IGNORE — race 안전 |
| Repo | setNextRunAt | attempts 변경 없이 next_run_at + last_error 만 |
| CaptureService | retryAllFailed — telemetry emit + worker.enqueue 호출 | per-id enqueue + ai_retry_manual emit |
| CaptureService | retryAllFailed 빈 결과 emit 없음 | count=0 sentinel |
| TelemetryEvents | zod parse `ai_retry_manual` | happy + extra field reject + 0 reject (≥1 invariant) |
| TelemetryStats | AI 수동 재시도 집계 | count + sum |
| Store | retryAllFailed action — failedCount=0 reset | 낙관적 갱신 |
총 ≥ 17 단위.
---
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. Repo: findFailedIds + countFailed + retryAllFailed + setNextRunAt + 단위 5개
T2. AiWorker: unreachable/timeout 무한 retry 로직 + 단위 6개
T3. Telemetry: ai_retry_manual 1 event + stats + 단위 3개
T4. CaptureService.retryAllFailed + IPC 2 채널 + preload + 단위 2개
T5. shared/types InboxApi + store retryAllFailed + failedCount + 단위 1개
T6. FailedBanner 컴포넌트 + App.tsx mount
T7. Tray "지금 AI 처리 (실패 N건)" 메뉴 + 9th callback + refreshTrayFailedCount + main wiring
T8. closure (gates + roadmap mark + memory backlog)
---
## 10. roadmap In/Out 일치
### 10.1 roadmap §3 #2 In 매핑
| roadmap | design |
|---------|--------|
| AiWorker unreachable 무한 retry, attempts 증가 안 함 | §2 ✓ |
| schema fail / invalid response / timeout 만 attempts 증가 (max 3 유지) | §2 — **timeout 은 deviation (Q2=A)**, schema/other 만 attempts 증가 |
| markAiFailed 한 노트 수동 re-enqueue | §3 retryAllFailed |
| 트레이 + Inbox "지금 AI 처리 (실패 N건)" | §5 ✓ |
| FailedBanner | §5.2 ✓ |
| IPC `inbox:retryAllFailed`, `inbox:failedCount` | §4 ✓ |
| Telemetry `ai_retry_manual {failedCount}` | §7 ✓ |
| 단위 테스트 | §8 ≥ 17 |
### 10.2 Out 유지
- per-note retry 버튼 (Q3=A) — Out
- failed reason 별 차등 정책 — Out (모두 동일 max 3, telemetry 통계만 분리)
- retry progress UI — Out (PendingBanner 가 자연 표현)
- retry rate-limit — Out
### 10.3 roadmap deviation
§3 #2 In 의 "timeout 만 attempts 증가" 와 본 design 의 Q2=A "timeout 무한 retry" 가 충돌. 의식적 변경 — `ai_failed.reason='timeout'` 통계가 부족할 수 있음. dogfood 데이터로 검증 후 v0.2.4 에서 hang 케이스 분리 가능.
---
## 11. 위험 / 완화
| 위험 | 완화 |
|------|------|
| unreachable 무한 retry 큐 폭주 | sleep await sequential. 같은 job 안 in-place loop, 새 job 추가 0. cap 15분. |
| retryAllFailed 가 큰 N (예: 100+) | enqueue in-memory queue push. AiWorker 가 sequential 처리 — provider 호출 1개씩. 폭주 0. |
| timeout 분류 잘못 — 영구 hang 노트가 무한 retry | telemetry markAiFailed 시점만 emit → timeout 무한 retry 노트 stats 안 보임. v0.2.4 dogfood 시 ai_status='pending' 의 attempts 분포로 영구 hang 식별. 필요 시 timeout cap 도입. |
| unreachableBackoffStep 이 process restart 시 reset | 의도. next_run_at 가 미래면 sleep, 과거면 즉시 retry — 자연. |
| schema 후 unreachable 발생 — backoff step 이 unreachableBackoffStep 와 별개 인덱스 | unreachableBackoffStep 은 unreachable/timeout 전용. schema 의 attempts 와 독립. 단위 테스트 회귀 가드. |
| retryAllFailed 와 AiWorker 큐의 race (이미 처리 중인 노트 재투입) | retryAllFailed SQL 이 ai_status='failed' 만 → 처리 중 ('pending') 노트는 자연 제외. atomic transaction. |
| pending_jobs 재투입 시 이미 pending_jobs row 존재 (예: race) | INSERT OR IGNORE — duplicate ignored. attempts/next_run_at 그대로 유지. 안전. |
---
## 12. 게이트 (PR 머지 조건)
- `npm run typecheck` 0 errors
- `npm test` — 344 + 17 = 361+
- `npm run test:e2e` 1/1
- main 머지
머지 후:
- roadmap §3 #2 ✓ 완료 마커
- v0.2.4 backlog 누적
---
## 13. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=A (15분 cap), Q2=A (timeout=unreachable), Q3=A (retry-all only). AiWorker unreachable/timeout 무한 retry + retryAllFailed atomic + FailedBanner + tray "지금 AI 처리" + ai_retry_manual telemetry. roadmap §3 #2 deviation 1건 (timeout) 의식적. |

View File

@@ -0,0 +1,294 @@
# v0.2.3 #5 만료 추천 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 3번째 항목 (#5 만료 추천) 의 mini-brainstorm 결정 + design. roadmap §3 #5 의 In/Out 위에서 §8 의 미결정 3항목 + UI 위치/0건 처리 추가 결정.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #5 (In/Out), §8 (미결정 항목)
- `docs/superpowers/specs/2026-05-01-v023-trash-design.md` (#4 trash, deleted_at 인프라)
- `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` (due_date 컬럼 + AI 추출 흐름)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 `due_date_edited_by_user` 필터 | **B 필터 없음** — AI 자동 + 사용자 수동 모두 후보 | 의도와 무관하게 "지나간 due_date" 는 트리아지 대상. AI 자동 가중치 차등은 v0.2.4 로. |
| Q2 만료 임박 (D-7) | **A 만료만** (`due_date < today`) | roadmap §3 #5 Out 명시. 임박은 의미 (주의 환기 vs trash) 가 달라 분리 surface 필요. v0.2.4. |
| Q3 멀티선택 default | **C unchecked default + "전체 선택" 토글 버튼** | 데이터 안전 우선 (v0.2.1 패턴). 일괄도 토글 한 번. |
| Q4 배너 위치 | **B PendingBanner 아래** | system(Ollama) → progress(Pending) → actionable(Expired) → filter(tagFilter) 순. |
| Q5 후보 0건 / snooze | **A collapse** (렌더링 생략) | PendingBanner `pendingCount===0` → null 패턴 일치. 빈 카피는 노이즈. |
---
## 2. 데이터 / 쿼리
### 2.1 NoteRepository 확장
```ts
// src/main/repository/NoteRepository.ts
findExpiredCandidates({ today }: { today: string }): Note[];
trashBatch(ids: string[], deletedAt: string): { trashedCount: number };
```
- `today`: `'YYYY-MM-DD'` 문자열 (KST 자정 기준 오늘 날짜). caller 가 KST 기준 계산 후 주입 (테스트에서 clock injection 용이).
- `due_date``'YYYY-MM-DD'` 저장 (slice §F1 invariant 일치).
- `findExpiredCandidates` SQL:
```sql
SELECT <note columns + JOIN tags + media> FROM notes
WHERE due_date IS NOT NULL
AND due_date < ?
AND deleted_at IS NULL
AND ai_status = 'done'
ORDER BY created_at DESC
```
- `trashBatch` 는 단일 `db.transaction()` 안에서 `repo.trash(id, deletedAt)` 반복. 이미 trash 된 id 는 silent skip (UPDATE 가 deleted_at 이 이미 set 인 row 에 영향 0건). 반환 `trashedCount` 는 실제 transition (active → trash) 발생 건수. pending_jobs 정리는 `trash()` 가 이미 처리.
### 2.2 KST 자정 today 계산
```ts
// src/main/util/kstDate.ts (재사용 또는 신설)
export function todayInKst(now: Date): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kst = new Date(now.getTime() + KST_OFFSET_MS);
return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD'
}
```
- ContinuityService 의 KST_OFFSET_MS 패턴 재사용. 신규 util 또는 ContinuityService 의 helper 추출.
- 단위 테스트: UTC 23:30 (KST 다음날 08:30) 케이스 검증.
---
## 3. IPC
| 채널 | 입력 | 출력 | 설명 |
|------|------|------|------|
| `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. |
| `inbox:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. |
CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산.
---
## 4. 상태 관리 (zustand)
```ts
// src/renderer/inbox/store.ts
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null; // KST 자정 epoch ms
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
```
### 4.1 동작 사양
- `loadExpired()`: IPC `inbox:listExpired` 호출 → `expiredCandidates` 갱신.
- `loadInitial()` + `refreshMeta()` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류.
- `trashExpiredBatch(ids)`: IPC `inbox:trashBatch` 호출 → 성공 시 `expiredCandidates` 에서 ids 제거 + `trashCount` 증가 + `notes` 에서도 제거 (낙관적 갱신, restore 와 동일 패턴 — main 은 push 안 함).
- `snoozeExpired()`: KST 자정 epoch ms 계산해 `expiredSnoozeUntilMs` 에 set. 컴포넌트에서 `Date.now() < snoozeUntil` 체크.
- in-memory only. 앱 재시작 시 다시 노출 (roadmap §3 #5 In 의 명시 사양).
### 4.2 KST 자정 epoch 계산
```ts
function nextKstMidnightMs(now: number): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = now + KST_OFFSET_MS;
const kstMidnight = Math.ceil(kstNow / 86_400_000) * 86_400_000;
return kstMidnight - KST_OFFSET_MS;
}
```
단위 테스트: KST 23:00 호출 시 다음날 00:00 KST 반환 (1시간 후), KST 00:01 호출 시 같은 날 자정 24시간 후 (23h59m 후).
---
## 5. UI — `ExpiryBanner`
위치: `<App.tsx>` 의 `<PendingBanner />` 아래 (showTrash=false 분기 안).
### 5.1 collapse 조건 (렌더 null)
```
expiredCandidates.length === 0
|| (expiredSnoozeUntilMs !== null && Date.now() < expiredSnoozeUntilMs)
```
### 5.2 unfolded 구조
```
┌─ ⏰ 오늘 기준 만료 N개 [▼ 펼침/▲ 접힘] [오늘 그만] ─┐
│ │
│ ☐ [전체 선택] │
│ ☐ 노트 제목 1 · due 2026-04-20 · #회의 │
│ ☐ 노트 제목 2 · due 2026-04-18 · #학습 │
│ ... │
│ │
│ [선택 휴지통 (M개)] (M=0 시 disabled) │
└──────────────────────────────────────────────────────────┘
```
**헤더 (1줄)**
- "⏰ 오늘 기준 만료 {N}개"
- 펼침 토글 버튼 (▼/▲)
- "오늘 그만" 버튼 — 클릭 시 `snoozeExpired()` → 배너 즉시 collapse
**펼침 영역**
- 첫 노출 시 default = **펼침** (사용자가 만료 N건 + 어떤 노트인지 동시 노출).
- 한 번 접으면 component-local useState 로 세션 동안 접힘 유지. reload 시 다시 펼침.
- "전체 선택" 체크박스 — 모든 row 동시 toggle. partial 선택 시 indeterminate 상태 표시.
- 노트 row: 체크박스 + 제목(truncate, max 1 line) + due_date + 태그 chip (1개, 없으면 생략).
- row 전체 clickable — 클릭 시 체크박스 toggle (편집/펼침 액션 없음, read-only triage 모드).
- "선택 휴지통 ({M}개)": M=0 시 disabled. 클릭 시 native confirm dialog ("선택한 {M}개를 휴지통으로 옮깁니다.\n\n복구는 휴지통 탭에서 가능합니다.") → 확인 시 `trashExpiredBatch(selectedIds)`.
### 5.3 confirm dialog
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.
---
## 6. Telemetry
### 6.1 신규 events
| event | payload | 발화 |
|-------|---------|------|
| `expired_banner_shown` | `{ candidateCount: number }` | `loadExpired()` 결과 `candidates.length > 0` 시. 같은 세션에 동일 후보 set 중복 emit 회피 (last shown signature 비교). |
| `expired_batch_trash` | `{ count: number }` | `trashBatch` 성공 직후 (count = trashedCount). |
### 6.2 중복 emit 회피 — signature
`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합).
### 6.3 zod 스키마
```ts
// src/main/services/TelemetryService.ts (TelemetryEvent discriminatedUnion 확장)
z.object({
kind: z.literal('expired_banner_shown'),
payload: z.object({ candidateCount: z.number().int().nonnegative() }).strict()
}).strict(),
z.object({
kind: z.literal('expired_batch_trash'),
payload: z.object({ count: z.number().int().nonnegative() }).strict()
}).strict(),
```
### 6.4 stats.md 집계 추가
| 행 | 산식 |
|----|------|
| 만료 배너 노출 | `expired_banner_shown` count |
| 만료 일괄 trash | `expired_batch_trash` total `count` 합 |
| 만료 trash ratio | `sum(expired_batch_trash.count) / sum(expired_banner_shown.candidateCount)` |
---
## 7. F5 export / F6 import / 백업
영향 0건. #4 가 이미 `deleted_at IS NULL` 을 export/active query 에 적용. 만료 후보는 active 노트의 부분집합이므로 별도 정책 불필요.
**Regression guard**: 단위 테스트로 "사용자 수동 due_date 도 만료 후보" + "trash 된 만료 노트는 후보 제외" 회귀 가드 추가.
---
## 8. 테스트
| 영역 | 단위 | 검증 |
|------|------|------|
| Repo | `findExpiredCandidates` happy path | due_date < today 만 반환, ORDER BY created_at DESC |
| Repo | `findExpiredCandidates` AI + 수동 mix | Q1=B 회귀 가드 — 둘 다 포함 |
| Repo | `findExpiredCandidates` deleted_at | trash 노트 제외 (#4 invariant 회귀 가드) |
| Repo | `findExpiredCandidates` ai_status | pending/failed 제외 |
| Repo | `findExpiredCandidates` due_date NULL | NULL 노트 제외 (NULL < string 평가 가드) |
| Repo | `trashBatch` atomic happy | N개 모두 trash, count=N |
| Repo | `trashBatch` 빈 배열 | count=0, no-op |
| Repo | `trashBatch` 일부 invalid id | valid 만 trash, count = valid 수 |
| Repo | `trashBatch` 이미 trash | 재호출 시 count=0 (idempotent) |
| util | `todayInKst` UTC vs KST 경계 | 23:30 UTC → 다음날 KST 날짜 |
| Service | `nextKstMidnightMs` | 자정 KST 정확 계산 |
| Telemetry | zod parse `expired_banner_shown` | candidateCount int ≥ 0 |
| Telemetry | zod parse `expired_batch_trash` | count int ≥ 0 |
| Telemetry | privacy invariant | payload 에 raw_text/title 포함 시 거부 (기존 invariant 회귀 가드) |
| Store | `loadExpired` integration | candidates set + count |
| Store | `trashExpiredBatch` 낙관적 갱신 | candidates 제거 + trashCount 증가 + notes 제거 |
| Store | `snoozeExpired` | snoozeUntilMs = 다음 KST 자정 epoch |
총 단위 ≥ 16개. e2e smoke 영향 없음 (만료 노트 fixture 추가 없이 기존 1/1 e2e 보존).
---
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. `findExpiredCandidates` repo + 단위 5개 (TDD)
T2. `trashBatch` repo + 단위 4개 (TDD)
T3. `todayInKst` util + `nextKstMidnightMs` 계산 + 단위 2개
T4. Telemetry 2 events 추가 (zod + stats.md 집계 + 단위 3개)
T5. CaptureService 메소드 + IPC 2 채널 + 단위
T6. zustand store 확장 + 단위 3개
T7. `ExpiryBanner` 컴포넌트 (펼침/접힘/체크박스/전체선택/오늘그만)
T8. App.tsx 통합 (PendingBanner 아래 mount)
T9. confirm dialog + trashBatch 호출 path 통합
T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure
---
## 10. roadmap In/Out 일치
### 10.1 roadmap §3 #5 In 처리 매트릭스
| roadmap 항목 | 본 design |
|-------------|----------|
| `findExpiredCandidates({today})` | §2.1 ✓ |
| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | §5 ✓ |
| IPC `inbox:listExpired`, `inbox:trashBatch` | §3 ✓ |
| Telemetry `expired_banner_shown` `{candidateCount}` | §6.1 ✓ |
| Telemetry `expired_batch_trash` `{count}` | §6.1 ✓ |
| 단위 테스트 | §8 ✓ (16개) |
### 10.2 roadmap §3 #5 Out 유지
- 시스템 알림 surface — Out
- 별 페이지 — Out
- snooze 영속화 — Out (in-memory + 자정 KST 리셋)
- "안 옮김" 가중치 감소 — Out
- 만료 임박 (D-7) 추천 — Out (Q2 confirmed)
---
## 11. 위험 / 완화
| 위험 | 완화 |
|------|------|
| `due_date IS NOT NULL` 누락 시 NULL < string 평가 (SQLite 의 NULL 비교 결과 NULL → falsy) | 명시적 `WHERE due_date IS NOT NULL` + 단위 테스트 회귀 가드 |
| 사용자가 "오늘 그만" 후 다른 만료 노트 추가 시 배너 안 뜸 (자정까지) | 의도된 동작. 자정 KST 리셋 시 다시 노출. roadmap §3 #5 In 명시. |
| 같은 세션에 candidates 가 자주 바뀌면 (capture 등) `expired_banner_shown` 이 과다 emit | signature 비교 (§6.2) 로 회피 |
| `trashBatch` 의 `today` 가 caller 마다 다른 시점이면 race | main 단일 진입점 (CaptureService) 에서 호출 시점 1회 계산. renderer 가 today 주입 안 함. |
| ExpiryBanner 가 PendingBanner 사이에 끼어 layout shift | 양쪽 다 collapse 조건 명확 (count=0 → null) — shift 는 사용자 액션 결과 (예측 가능) |
---
## 12. 게이트 (PR 머지 조건, roadmap §3.1 일치)
- `npm run typecheck` 0 에러
- `npm test` — 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상)
- `npm run test:e2e` 1/1 통과
- main 머지
머지 후:
- roadmap `§3 #5 만료 추천 (3번)` 다음 `✓ 완료` 마커
- `memory/project_v024_backlog.md` 에 deferred 항목 기록 (review 결과)
---
## 13. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). |
| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. |

View File

@@ -0,0 +1,342 @@
# v0.2.2 Dogfood 피드백 로드맵 (#7→#6 → v0.2.3) 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.2 dogfood 중 발견된 7개 항목 (`memory/project_v022_feedback.md` #1~#6 + 본 brainstorm 에서 추가된 #7 telemetry) 의 순차 작업 로드맵. 본 문서는 **순서·범위·게이트** 만 정의하며, 각 항목 내부 설계는 항목별 mini-brainstorm + writing-plans 에서 결정.
**선행 문서:**
- `memory/project_v022_feedback.md` (raw 피드백 6건)
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` (v0.2.1 로드맵, 본 문서의 패턴 원형)
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (slice §1.1 invariant 4 — 본문 미기록)
- `docs/superpowers/strategy/strategy.md` (#6 에서 §2.3·§4.3·§8 동반 갱신 대상)
---
## 1. 결정 요약
| 결정 | 값 | 근거 |
|------|-----|------|
| Cut 패턴 | **단일 cut v0.2.3** (7항목 한 묶음) | v0.2.1 패턴 반복. 항목 간 결합도 (특히 #7#4~#6 emit) 분리 시 회전 비용. |
| 우선순위 기준 | **데이터 안전 우선** (v0.2.1 패턴) | 측정 인프라 (#7) → schema migration v3 (#4) → 안전망 위에서 기능 진행. |
| 첫 항목 | **#7 Telemetry skeleton** | 다른 6 항목이 emit hook 만 추가. 측정 없는 기능 출시는 다음 cut 까지 1주 본인 라벨링으로 후퇴. |
| 항목당 게이트 | **머지 + 테스트 통과** (typecheck + 205+ 단위 + e2e smoke) | v0.2.2 기준선. |
| 다음 빌드 | **v0.2.3** (7항목 모두 머지 후 단일 cut) | slice §7 strict-pin patch 증분. |
| 신규 dependency | **0 목표** | 모두 stdlib + 기존 better-sqlite3 / electron 으로 충분. |
| Schema 변경 | **migration v3** — 3 컬럼 한 번에: `deleted_at TEXT NULL`, `last_recalled_at TEXT NULL`, `recall_dismissed_at TEXT NULL` | #4 휴지통 + #6 회상 메타 한 묶음. 별 migration 두 번 회피. |
| Trash 와 export/backup | **B 정책** — F6-L1 backup 포함 (byte-for-byte), F5 export 제외, F6-L3 import 시 `deleted_at IS NOT NULL` 우선 (삭제 보존) | 백업은 회복 용도, export 는 외부 노출 형식. |
| Decision-pending 처리 | **항목별 mini-brainstorm** | 본 문서는 순서·In/Out 만, 항목 내부는 per-item. |
---
## 2. 순차 작업 순서
```
v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]────────
개발 트랙 (main 직접 머지 또는 PR): │
① #7 Telemetry skeleton [작음, 인프라 1번] │
② #4 휴지통 + migration v3 [중, schema + 정책] │
③ #5 만료 추천 [작음, #4 destination]│
④ #1 Ollama 회복 polling [작음, 독립] │
⑤ #2 AI retry / 수동 trigger [중, AiWorker 정책] │
⑥ #3 태그 vocab 주입 [작음, 독립] │
⑦ #6 리마인드 1 spike [중, strategy 갱신] │
┌──────────┘
v0.2.3 cut (단일)
dogfood 재설치 + ≥ 1주 soak
telemetry export → 분석 →
v0.2.4 brainstorm
```
### 2.1 순서 결정 근거
1. **#7 (1번)** — 측정 인프라. 다른 항목이 emit hook 추가만 하도록 skeleton 먼저. Cross-cutting privacy invariant 강제도 1번에서 단위 테스트로 고정.
2. **#4 (2번)** — schema migration v3 가 #6 회상 메타 컬럼 동반. 휴지통 invariant (`deleted_at IS NULL` 모든 active 쿼리) 가 다른 항목에 영향. 회복 안전망 (pre-v3 snapshot, v0.2.1 메커니즘) 위에서 진행.
3. **#5 (3번)** — #4 휴지통 destination 직접 소비. 같은 영역 (Inbox 상단 배너).
4. **#1 (4번)** — #2 의 reliable health 의존성. polling 인프라 먼저.
5. **#2 (5번)** — #1 health 위에서 retry/manual trigger 정책 변경. AiWorker 의 unreachable infinite retry 로 #1 polling 결과 활용.
6. **#3 (6번)** — 독립 prompt 변경. PROMPT_VERSION 4. AI 영역 마지막에 묶어서 AiWorker 회귀 위험 격리.
7. **#6 (7번)** — strategy.md 갱신 + RecallBanner 1 spike. last_recalled_at / recall_dismissed_at 사용. 가장 마지막에 두는 이유: 다른 항목 telemetry hook 이 모두 박혀야 #6 측정 가치가 살아남.
---
## 3. 항목당 In (PR 범위) / Out (deferred)
각 항목 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm.
### #7 Telemetry skeleton (1번) ✓ 완료
**In:**
- `TelemetryService` (`src/main/services/TelemetryService.ts`):
- `emit(kind, payload)` → 비동기 append to `<profileDir>/telemetry/events-YYYY-MM-DD.jsonl`
- 일자별 rotation (KST 자정), 14일 후 rolling 삭제
- write 실패 시 silent log only (앱 동작 영향 없음)
- 이벤트 zod schema: `{ ts: ISO string, kind: enum, payload: object }`. payload shape 는 kind 별 fixed.
- **Privacy invariant** (slice §1.1 invariant 4 강화): payload 에 `raw_text` / `ai_title` / `ai_summary` / `user_intent` / 태그 name 포함 시 zod parser 거부. 단위 테스트로 고정.
- 기본 emit hook 박기:
- `capture` (CaptureService.submit 후): `{ noteId, rawTextLength, hasMedia }`
- `ai_succeeded` (AiWorker.processJob 성공): `{ noteId, durationMs, attempts }`
- `ai_failed` (AiWorker.processJob 실패): `{ noteId, reason: "unreachable"|"schema"|"timeout"|"other", attempts }`
- 트레이 메뉴 "사용 로그 내보내기...":
- 폴더 다이얼로그 → `events.jsonl` (최근 14일 concat) + `stats.md` (집계 마크다운) zip
- `stats.md` 내용: 항목별 일자별 카운트 표 + 핵심 ratio (AI 성공률, ollama uptime%, recall opened/shown, expired batched/shown 등)
- 트레이 콜백 (main 내부 — 별도 IPC 채널 불필요)
- 단위 테스트: emit, rotation, privacy invariant 거부, stats 집계, export zip
**Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정
### #4 휴지통 (2번) ✓ 완료
**In:**
- migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번)
- `NoteRepository`: `trash(id)` (`deleted_at = now()`), `restore(id)` (`deleted_at = NULL`), `emptyTrash()` (hard delete + media 정리). 기존 `delete()` 는 deprecate 후 `emptyTrash` 내부에서만 호출.
- **Active 쿼리 일괄 `WHERE deleted_at IS NULL` 추가**: `listNotes`, `countToday`, `findByTag`, search, F5 export, AiWorker `loop()` 진입 시 deleted_at 체크
- 휴지통 UI: Inbox 상단 탭 ("Inbox · 휴지통(N)") — 정밀 위치는 mini-brainstorm
- 휴지통 비우기 confirm dialog ("N개 영구 삭제. 되돌릴 수 없음.")
- F5 export 가 `deleted_at IS NOT NULL` 제외
- F6-L3 import 충돌 정책 추가: source 와 dest 중 `deleted_at IS NOT NULL` 우선 (삭제 보존)
- IPC: `inbox:trash` / `inbox:restore` / `inbox:emptyTrash` / `inbox:listTrash`
- Telemetry emit: `trash` / `restore` / `emptyTrash`
- 단위 테스트: trash/restore/emptyTrash, active query 제외, AiWorker skip, F5 export 제외, F6-L3 import 머지
**Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그
### #5 만료 추천 (3번) ✓ 완료
**In:**
- `NoteRepository.findExpiredCandidates({today})`:
- `WHERE due_date < today AND deleted_at IS NULL AND ai_status = 'done'`
- ORDER BY `created_at DESC`
- Inbox 상단 **만료 배너** (펼침 가능):
- "오늘 기준 만료 N개" 헤더
- 펼치면 노트 카드 리스트 + 체크박스 멀티선택
- "선택 휴지통" 버튼 → 일괄 trash + telemetry emit
- "오늘 그만" → in-memory snooze (자정 KST 리셋)
- IPC: `inbox:listExpired`, `inbox:trashBatch`
- Telemetry emit: `expired_banner_shown` (`{ candidateCount }`), `expired_batch_trash` (`{ count }`)
- 단위 테스트: 후보 query, 멀티선택 batch trash, snooze 동작, deleted_at 제외 확인
**Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천
### #1 Ollama 회복 (4번) ✓ 완료
**In:**
- HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정):
- `runOnce()` 가 setInterval 로 자동 발화
- 회복 시 `onUpdate` fire → 구독 (renderer OllamaBanner) 자동 갱신
- 실패 N회 후 polling 중단 정책 — mini-brainstorm
- 수동 "재확인" 버튼: `OllamaBanner` + 트레이 컨텍스트 메뉴
- IPC: `inbox:ollamaRecheck`
- Telemetry emit: `ollama_unreachable` (`{ reason }`), `ollama_recovered` (`{ downtimeMs }`), `ollama_recheck_manual` (`{}`)
- 단위 테스트: polling fire, manual recheck, 회복 status 전이 + telemetry emit
**Out:** 사용자 설정 가능 polling 주기, 회복 toast 알림, 모델 정상성 (tags 외) 체크
### #2 AI retry / 수동 trigger (5번) ✓ 완료
**In:**
- `AiWorker.processJob()` 정책 변경:
- **ollama unreachable** 일 때 `attempts` 증가 안 하고 `next_run_at` 만 backoff (무한 retry while unreachable)
- schema fail / invalid response / timeout 만 `attempts` 증가 (기존 max 3 유지)
- reason 분류는 `LocalOllamaProvider` 결과 + zod 결과로 결정
- `markAiFailed` 한 노트 수동 re-enqueue 가능 (hard fail 도 회수 경로)
- 트레이 + Inbox 메뉴 **"지금 AI 처리 (실패 N건)"** → 모든 ai_status='failed' → pending_jobs 재투입
- `FailedBanner` (PendingBanner 형제, 실패 N건 + retry 버튼)
- IPC: `inbox:retryAllFailed`, `inbox:failedCount`
- Telemetry emit: `ai_failed` (#7 의 기본 hook 에 reason 분류 추가), `ai_retry_manual` (`{ failedCount }`)
- 단위 테스트: unreachable infinite retry, retry-all trigger, unreachable vs schema fail 구분, attempts 증가 정책
**Out:** per-note retry 버튼 (NoteCard), failed reason 별 차등 정책, retry progress UI, retry rate-limit
### #3 태그 vocab (6번) ✓ 완료
**In:**
- `NoteRepository.getTopUsedTags(N=20)`:
- `SELECT t.name, COUNT(*) c FROM tags t JOIN note_tags nt ON nt.tag_id=t.id JOIN notes n ON n.id=nt.note_id WHERE n.deleted_at IS NULL GROUP BY t.id ORDER BY c DESC LIMIT 20`
- `buildPrompt()` 에 vocab 주입 라인:
- "기존 태그를 우선 재사용. 새 태그는 vocab 에 없는 의미일 때만 만들기:" + kebab-case 리스트
- vocab 빈 케이스 (신규 사용자) → 라인 자체 생략
- `PROMPT_VERSION` 3 → **4**
- AI 응답 후 vocab hit/miss 분류 → telemetry emit
- Telemetry emit: `tag_vocab_hit` (`{ tagId, vocabSize }`), `tag_vocab_miss` (`{ vocabSize }`)
- 단위 테스트: vocab 합성, 빈 vocab, 길이 cap, prompt version bump, hit/miss 분류
**Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책
### #6 리마인드 1 spike (7번) ✓ 완료
**In:**
- `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시
- Inbox 상단 **`RecallBanner`** — "오늘 회상해볼 노트" 1건 추천:
- algo: `WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','-7 day')) AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','-30 day')) AND ai_status='done' AND deleted_at IS NULL AND (due_date IS NULL OR due_date >= today) ORDER BY created_at ASC LIMIT 1`
- 사용자 액션 3개:
- "열어보기" → 노트 카드 스크롤 + `last_recalled_at = now()`
- "다음에" → in-memory snooze 1일 (영속화 X)
- "더 이상" → `recall_dismissed_at = now()`
- IPC: `inbox:listRecallCandidate`, `inbox:markRecallOpened`, `inbox:dismissRecall`
- Telemetry emit: `recall_shown` (`{ noteId, ageDays }`), `recall_opened`, `recall_dismissed`, `recall_snoozed`
- 단위 테스트: algo selection, dismiss 만료 (30일 후 재추천), last_recalled 갱신, deleted_at 제외, 후보 0건 케이스
**Out:** 잠금해제 hook (F4-A), 무작위 토스트 (F4-D), ambient if-then (F4-B), 임베딩 유사도 추천 (#3 vocab 후속), spaced repetition (Leitner/SM-2), 다중 후보 추천
### 3.1 공통 게이트 (모든 항목)
각 항목 머지 전 필수:
- `npm run typecheck` 통과 (현재 0 에러)
- `npm test` 통과 (현재 205/205, 항목 신규 단위 추가)
- `npm run test:e2e` 통과 (현재 1/1)
- 항목 신규 단위 테스트 ≥ 1개 (TDD)
- main 머지
---
## 4. 항목당 작업 흐름 + Cross-cutting
```
[항목 N 시작]
├─ mini-brainstorm ← decision-pending 답변
│ - 본 문서 §3 의 "Out" 후보 일부가 In 으로 승격 가능
│ - per-item spec doc → docs/superpowers/specs/2026-MM-DD-v023-<slug>.md
├─ writing-plans ← TDD 구현 계획
├─ 구현 (executing-plans 또는 직접)
│ - 브랜치: feat/v023-<slug> (예: feat/v023-trash, feat/v023-recall)
│ - 게이트 통과 후 main 머지
└─ 다음 항목 시작
```
### 4.1 Cross-cutting 정책
| 영역 | 정책 |
|------|------|
| **버전 관리** | 7개 모두 머지될 때까지 `package.json` `0.2.2` 유지. v0.2.3 cut 은 7번 후 단일. |
| **CHANGELOG** | 기존 `CHANGELOG.md``[0.2.3]` section append (v0.2.2 에서 확립한 패턴). v0.2.3 cut 직전 한 번만 수정. |
| **브랜치 전략** | `feat/v023-<slug>` 단명. main 머지 후 삭제. 작은 항목 (#1, #3, #6 strategy 갱신) 은 main 직접 push 도 허용. |
| **테스트 추가 정책** | 항목당 최소 단위 1개. e2e smoke 영향 시 단언 동기화. AiWorker 변경 (#1, #2, #3) 은 integration (Ollama) 영향 시 검토. |
| **Slice invariant 위반 시** | 본 로드맵 결과로 invariant 변경 — slice §1.1 §7 도 PR 안에 동봉 수정. |
| **신규 dependency** | slice §7 strict-pin 그대로. 0 신규 dep 목표 — 위반 시 PR 안에 §7.2 갱신 + 합리화 동봉. |
| **로깅 정책** | slice §1.1 invariant 4 **강화**: telemetry payload 에 raw_text/title/summary/intent/tag name 포함 절대 금지. 위반 시 silent invariant 위반. #7 단위 테스트로 zod parser 가 거부. |
| **Strategy.md 동반 갱신** | #6 항목 (7번) 에서만. 다른 항목은 strategy.md 미수정. |
| **Schema invariant 추가** | `deleted_at IS NOT NULL` 노트는 모든 active 쿼리 (Inbox 리스트 / 카운트 / 검색 / 태그 필터 / AiWorker 처리 / F5 export) 에서 제외. F6-L1 backup 만 예외. 위반 시 dogfood-feedback 재오픈. |
---
## 5. v0.2.3 Cut 단계
7번 항목 머지 후:
```
[v0.2.2 dogfood 환경에서]
1. 트레이 → "지금 백업" 1회 클릭 ← F6-L1 첫 실증
2. 트레이 → "내보내기..." 1회 ← F5 schema-agnostic 백업
3. 트레이 → "사용 로그 내보내기..." 1회 ← #7 의 첫 실증 (없으면 v0.2.2 raw 데이터 손실)
4. Inkling 종료
[빌드 머신에서]
5. package.json version: 0.2.2 → 0.2.3
6. CHANGELOG.md 에 [0.2.3] section append
7. npm run dist
8. dist/Inkling Setup 0.2.3.exe 검증
[dogfood 머신에서]
9. Setup 0.2.3.exe 실행 → 같은 폴더에 설치
10. 첫 실행 → migration v3 자동 적용 (deleted_at + last_recalled_at + recall_dismissed_at)
11. 트레이 메뉴 "사용 로그 내보내기..." 항목 존재 확인
12. ≥ 1주 soak 시작
```
### 5.1 업그레이드 안전망
| 위험 | 완화 |
|------|------|
| migration v3 결함으로 DB 손상 | 2가지 복원 경로 (v0.2.1 부터): (a) `<dbFile>.pre-v3.bak` 자동 snapshot 으로 SQLite 복원 (v0.2.2 인스톨러 재설치 필요), (b) F5 export → v0.2.3 의 F6-L3 import 로 schema-agnostic 복원 (더 빠름) |
| `deleted_at IS NULL` 누락 — 휴지통 노트가 active 쿼리에 새는 회귀 | 단위 테스트로 모든 active 쿼리 확인. 실수 시 dogfood-feedback 즉시 재오픈. |
| Telemetry payload 에 본문 누출 | `TelemetryService.emit` 의 zod parser 가 거부. CI 단위 테스트로 고정. |
| AiWorker unreachable infinite retry 가 큐 폭주 | next_run_at 의 backoff cap (15분) — mini-brainstorm 에서 확정. |
| 자동시작 토글 / 데이터 디렉터리 손실 | v0.2.1 동일 — `HKCU\...\Run` + `<profileDir>` 보존됨 |
---
## 6. 측정
### 6.1 로드맵 측정
| 메트릭 | 임계값 | 측정 방법 |
|--------|--------|----------|
| 항목 평균 PR 사이즈 | < 800 lines diff | git log 통계 |
| 항목 평균 머지 간격 | < 5일 | git log 시간차 |
| 회귀 테스트 추가 | 항목당 ≥ 1개 단위 | `tests/unit` 카운트 |
| v0.2.3 cut 후 1주 데이터 손실 | 0회 | telemetry + 본인 라벨링 보강 |
| typecheck/test 회귀 | 0회 | CI · 로컬 |
| Telemetry 본문 누출 | 0건 | events.jsonl grep + zod parser |
### 6.2 dogfood soak 측정 (#7 의 본격 사용처)
`stats.md` 가 다음을 답해야 함:
| 질문 | 데이터 |
|------|--------|
| AI 가 실제로 동작 중인가? | `ai_succeeded / (ai_succeeded + ai_failed)` ratio 일자별 |
| Ollama unreachable 빈도? | `ollama_unreachable` count + 평균 `downtimeMs` |
| 수동 trigger 가 쓰이고 있나? | `ai_retry_manual` / `ollama_recheck_manual` count |
| 휴지통이 회수 도구로 동작? | `restore / trash` ratio |
| 만료 추천이 nudging 으로 동작? | `expired_batch_trash / expired_banner_shown` ratio |
| 회상 spike 가 의미 있나? | `recall_opened / recall_shown` ratio + `recall_dismissed` count |
| Tag vocab 재사용? | `tag_vocab_hit / (hit + miss)` ratio (목표: 시간 흐름에 따라 상승) |
### 6.3 silent invariant 후보
본 로드맵 결과로 slice §1.3 종료 조건에 추가 권장:
> **"Telemetry 본문 누출 0회"** — events.jsonl 의 어떤 payload 에도 raw_text/title/summary/intent/tag name 미포함. 발생 시 즉시 silent invariant 위반.
> **"`deleted_at IS NULL` 망각 0회"** — active 쿼리 회귀 시 즉시 dogfood-feedback 재오픈.
이 추가는 #7 / #4 항목 머지 시 slice §1.3 동봉 수정.
---
## 7. 본 로드맵의 종료 조건
**모두 만족해야 종결**:
1. #7·#4·#5·#1·#2·#3·#6 7개 항목 모두 main 머지
2. `CHANGELOG.md [0.2.3]` section + `package.json` 0.2.3 + slice §1.3 silent invariant 2개 추가 동봉 갱신 완료
3. v0.2.3 cut → dogfood 머신 재설치 → migration v3 적용 확인 → 첫 실행 정상 + 트레이 메뉴 신규 항목 ("사용 로그 내보내기...") 동작 확인
4. ≥ 1주 dogfood soak 완료 (데이터 손실 0회 + telemetry 본문 누출 0건 확인)
5. `events.jsonl` + `stats.md` export → 분석 → v0.2.4 brainstorm 진입
5 가 끝나면 본 로드맵 종결.
---
## 8. 미결정 항목 (각 항목 mini-brainstorm 에서 답변)
본 로드맵은 순서·In/Out 만 정의. 다음 결정들은 빨리 마주치게 됨:
- **#7**: events.jsonl rotation 주기 (자정 KST 확정), stats.md 집계 ratio 의 정확한 컬럼 list, payload schema 별 zod 파서 합성 정책, write 실패 시 백오프
- **#4**: 휴지통 UI 정밀 위치 (Inbox 탭 vs 트레이 별 윈도우 vs 별 페이지), 휴지통 비우기 confirm 카피, F5 export 의 trash 옵션 (제외 강제 vs 사용자 토글)
- **#5**: 후보 `due_date_edited_by_user` 필터 여부 (수동 입력만 vs AI 자동 추출 포함), 만료 임박 (D-7) 포함 여부, 멀티선택 default 상태 (전체 선택 vs 비선택)
- **#1**: polling 주기 (10/30/60s), 실패 N회 후 polling 중단, exponential backoff 적용
- **#2**: unreachable backoff cap (15분 후보), reason 분류 정밀도 (timeout vs unreachable 구분), per-note retry 승격 여부
- **#3**: top-N 값 (20 후보), vocab cache invalidation 정책 (write-through vs 매 prompt 시 fresh), 빈 vocab 임계값
- **#6**: dismiss 만료 30일 vs 14일 vs 60일, 후보 0건 시 RecallBanner 숨김 vs 빈 상태 카피
- **#5+#6 coexistence**: 둘 다 Inbox 상단 배너 noting. stack 순서 (만료 위 → 회상 아래 가 자연 — 시간 민감도 우선), 동시 N건 시 우선 표시 정책, 빈 상태 시 영역 collapse 여부. #5#6 순서 머지라 #6 mini-brainstorm 에서 #5 와 통합 layout 결정.
---
## 9. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — v0.2.2 dogfood 7항목 (#7 telemetry 신설 포함) 단일 cut 로드맵, 데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B 채택), trash↔backup/export B 정책, #6 = 1 spike 흡수 (B 채택). |

View File

@@ -0,0 +1,327 @@
# v0.2.3 #1 Ollama 회복 polling 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #1, §8
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 polling 주기 | **A 60s** | 회복 latency ≤ 1분 충분. `/api/tags` 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. |
| Q2 실패 N회 후 중단 | **A 절대 중단 안 함** | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. |
| Q3 exponential backoff | **A constant 60s** | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. |
---
## 2. HealthChecker 확장
### 2.1 시그너처
```ts
// src/main/services/HealthChecker.ts
export interface HealthCheckerOptions {
intervalMs?: number; // default 60_000
onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire
onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability)
now?: () => number; // testability
}
export type HealthTelemetryEvent =
| { kind: 'ollama_unreachable'; reason: string }
| { kind: 'ollama_recovered'; downtimeMs: number }
| { kind: 'ollama_recheck_manual' };
export class HealthChecker {
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
/**
* @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire.
* IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능.
*/
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult>;
start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만)
stop(): void; // clearInterval (idempotent)
lastStatus(): HealthResult;
}
```
### 2.2 상태 전이 로직
`runOnce()` 안에서 `result = await provider.healthCheck()` 후:
| 전이 | 동작 |
|------|------|
| ok=true → ok=true (변화 없음) | no-op |
| ok=true → ok=false | `unreachableSince = now()`. `onUpdate(result)` 호출. `onTelemetry({kind:'ollama_unreachable', reason})` |
| ok=false → ok=true | `downtimeMs = now() - unreachableSince`. `onUpdate(result)`. `onTelemetry({kind:'ollama_recovered', downtimeMs})`. `unreachableSince = null` |
| ok=false → ok=false (reason 동일) | no-op |
| ok=false → ok=false (reason 다름) | `onUpdate(result)` (UI 갱신). telemetry emit **안 함** (ratio 노이즈 회피) |
### 2.3 start/stop
- `start()`: `runOnce()` 즉시 1회 + `setInterval(runOnce, intervalMs)` 등록. timer 이미 있으면 no-op.
- `stop()`: `clearInterval(timer)`. timer null 로 set.
- App quit hook (`app.on('before-quit')`) 에서 `health.stop()` — leak 방지.
---
## 3. main wiring + IPC
### 3.1 main/index.ts 변경
기존:
```ts
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h }));
```
신규:
```ts
const health = new HealthChecker(provider, {
onUpdate: (status) => pushOllamaStatus(getInboxWindow, status),
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
else if (ev.kind === 'ollama_recovered') void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
}
});
health.start();
app.on('before-quit', () => health.stop());
```
### 3.2 IPC 채널
| 채널 | 방향 | 용도 |
|------|------|------|
| `inbox:ollamaStatus` | renderer → main | 기존 — `health.lastStatus()` 반환. startup / refreshMeta 시 fetch. |
| `inbox:ollamaRecheck` | renderer → main → renderer | 신규 — main 이 `health.runOnce()` 호출, 결과 status push, telemetry `ollama_recheck_manual` emit. |
| `ollama:status` (push) | main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) |
`inbox:ollamaStatus` 는 변경 없음 (기존 IPC 호환).
`pushOllamaStatus(getInboxWindow, status)` helper 추가 (`pushNoteUpdated` 의 자매):
```ts
// src/main/ipc/inboxApi.ts
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}
```
`inbox:ollamaRecheck` handler — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability):
```ts
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire
return deps.health.lastStatus();
});
```
---
## 4. store + UI
### 4.1 store.ts 확장
`InboxState` 에 신규 action + push subscriber:
```ts
recheckOllama: () => Promise<void>;
```
`loadInitial``useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴):
```ts
// App.tsx useEffect
const unsubOllama = inboxApi.onOllamaStatus((status) => {
set({ ollamaStatus: status });
});
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
```
`recheckOllama` action:
```ts
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
}
```
### 4.2 InboxApi + preload 확장
```ts
// shared/types.ts InboxApi
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
// preload/index.ts
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
onOllamaStatus: (cb) => {
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
ipcRenderer.on('ollama:status', listener);
return () => ipcRenderer.off('ollama:status', listener);
}
```
### 4.3 OllamaBanner 변경
`status.ok === false` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래):
```tsx
<button onClick={() => void recheckOllama()}></button>
```
기존 banner 스타일 유지 (warn variant).
### 4.4 Tray 메뉴
기존 `createTray` 의 컨텍스트 메뉴에 항목 추가:
```ts
{
label: 'Ollama 재확인',
enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled
click: () => void deps.recheckOllama()
}
```
`createTray` 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화.
---
## 5. Telemetry
### 5.1 신규 3 events
| event | payload | 발화 |
|-------|---------|------|
| `ollama_unreachable` | `{ reason: string }` (max 500) | ok=true → ok=false 전이 (HealthChecker.onTelemetry) |
| `ollama_recovered` | `{ downtimeMs: number }` (≥0) | ok=false → ok=true 전이 |
| `ollama_recheck_manual` | `{}` (empty) | `inbox:ollamaRecheck` IPC handler |
### 5.2 zod schemas
```ts
// telemetryEvents.ts
const OllamaUnreachablePayload = z.object({
reason: z.string().min(1).max(500)
}).strict();
const OllamaRecoveredPayload = z.object({
downtimeMs: z.number().nonnegative()
}).strict();
const EmptyPayload = z.object({}).strict();
```
`reason` 의 source 는 `LocalOllamaProvider.healthCheck()` 가 반환하는 generic message — `'connection refused'`, `'not installed'`, `'timeout'`, `'http 500'` 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence.
### 5.3 stats.md 집계
신규 행 (휴지통 회수율 다음):
```
- Ollama unreachable 빈도: {count}건
- 평균 downtimeMs (recovered): {avg}
- 수동 recheck 사용량: {count}건
```
`DailyRow` 에 3 새 카운터 추가.
---
## 6. 테스트
| 영역 | 단위 | 검증 |
|------|------|------|
| HealthChecker | `start()` idempotent | 2회 호출 → timer 1개. |
| HealthChecker | `start()` 즉시 1회 + 60s 마다 | `vi.useFakeTimers()` advance, runOnce 호출 횟수. |
| HealthChecker | `stop()` cleanup | clearInterval. timer null. |
| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry `ollama_unreachable {reason}` 1회. |
| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry `ollama_recovered {downtimeMs}` 1회. downtimeMs ≈ now-unreachableSince. |
| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. |
| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. |
| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). |
| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. |
| IPC handler | `inbox:ollamaRecheck` | runOnce + telemetry.emit recheck_manual + status 반환. |
| Store | `recheckOllama` action | inboxApi.ollamaRecheck → set ollamaStatus. |
| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. |
총 ≥ 12 단위. e2e 영향 없음.
---
## 7. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD)
T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개)
T3. main/index.ts wiring (`onUpdate` + `onTelemetry` + `start()` + `before-quit stop`) + 테스트는 T5 의 IPC 통해
T4. IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + 단위 1개
T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개
T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration)
T7. closure (gates + roadmap mark + memory backlog)
---
## 8. roadmap In/Out 일치
### 8.1 roadmap §3 #1 In 매핑
| roadmap | design |
|---------|--------|
| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ |
| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ |
| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 |
| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ |
| IPC `inbox:ollamaRecheck` | §3.2 ✓ |
| Telemetry `ollama_unreachable {reason}`, `ollama_recovered {downtimeMs}`, `ollama_recheck_manual {}` | §5 ✓ |
| 단위 테스트 | §6 ≥ 12 |
### 8.2 Out 유지
- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정).
- 회복 toast 알림 — Out (banner 자동 사라짐만).
- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용).
---
## 9. 위험 / 완화
| 위험 | 완화 |
|------|------|
| polling 이 app quit 시 leak | `app.on('before-quit')` 에서 `health.stop()`. 단위 테스트로 stop() 동작 가드. |
| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. |
| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. |
| recheck 가 polling 과 동시 발화 race | `runOnce()` async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). |
| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. |
---
## 10. 게이트 (PR 머지 조건, roadmap §3.1 일치)
- `npm run typecheck` 0 에러
- `npm test` — 327 + 12+ = 339+
- `npm run test:e2e` 1/1
- main 머지
머지 후:
- roadmap `### #1 Ollama 회복 (4번)``✓ 완료`
- `memory/project_v024_backlog.md` review deferred 항목 누적
---
## 11. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). |
| 2026-05-01 | §2.1 / §3.2 보강 — `runOnce({ manual?: boolean })` 인자 추가, `ollama_recheck_manual` 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. |

View File

@@ -0,0 +1,385 @@
# #4 휴지통 (soft delete + migration v3) 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v<N>.bak snapshot 메커니즘 (v0.2.1 도입)
- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상
---
## 1. 결정 요약
| 영역 | 값 | 근거 |
|------|-----|------|
| Schema | **migration v3**`deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. |
| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. |
| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. |
| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. |
| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. |
| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. |
| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). |
| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. |
| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). |
| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. |
| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. |
| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. |
### 1.1 v0.2.3 #4 roadmap 와의 차이
| 항목 | roadmap §3 #4 | 본 spec |
|------|---------------|---------|
| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) |
| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) |
**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선.
---
## 2. Data model
### 2.1 Migration v3 — `m003_soft_delete.ts`
```sql
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
```
- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed.
- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6.
- `recall_dismissed_at`: #6 가 사용. 위와 동일.
- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리.
m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고.
**pre-v3 snapshot:** `<dbFile>.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번.
### 2.2 `Note` 타입 (`@shared/types`) 확장
```typescript
export interface Note {
// ... 기존 필드 ...
deletedAt: string | null; // #4 가 사용
lastRecalledAt: string | null; // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate)
recallDismissedAt: string | null; // #6 가 사용 (위와 동일)
}
```
세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별.
### 2.3 Schema invariant 추가
slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신):
> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈.
---
## 3. NoteRepository 변경
### 3.1 신규 메서드
| 메서드 | SQL | 부수 효과 |
|--------|-----|-----------|
| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. |
| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. |
| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. |
| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. |
| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. |
### 3.2 기존 메서드 변경
`delete(id)`**deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제.
### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가)
| 메서드 | 현재 | 변경 후 |
|--------|------|---------|
| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` |
| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` |
| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` |
| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음**`trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob``deletedAt` 가드는 이미 dequeue 한 race 만 처리. |
| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. |
NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부.
---
## 4. CaptureService 변경
### 4.1 메서드 변경
```typescript
async deleteNote(noteId: string): Promise<void> {
this.repo.trash(noteId, new Date().toISOString());
// media 는 그대로 둔다 (restore 시 필요)
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
```
### 4.2 신규 메서드
```typescript
async restoreNote(noteId: string): Promise<void> {
this.repo.restore(noteId);
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
async permanentDeleteNote(noteId: string): Promise<void> {
this.repo.permanentDelete(noteId);
await this.store.deleteNoteDirectory(noteId);
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch (e) { /* best-effort */ }
}
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
return { count: noteIds.length };
}
```
### 4.3 Telemetry interface 확장
`CaptureService.ts``TelemetryEmitter` 인터페이스에 4 union 멤버 추가:
```typescript
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
): Promise<void>;
}
```
`TelemetryService.ts``EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부.
`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가:
```
| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |
```
핵심 ratio:
- `restore / trash` — 휴지통이 회수 도구로 동작?
---
## 5. AiWorker 가드
`processJob` 진입 시 deletedAt 체크 추가:
```typescript
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
```
`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover.
result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리.
---
## 6. IPC
### 6.1 신규 채널 (5개)
`src/main/ipc/inboxApi.ts``registerInboxApi` 에 추가:
| 채널 | 핸들러 | 응답 |
|------|--------|------|
| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` |
| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` |
| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` |
| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` |
| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` |
confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행.
### 6.2 기존 `inbox:delete` 처리
기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토.
---
## 7. Renderer (Inbox)
### 7.1 zustand store 확장
```typescript
interface InboxState {
// 기존 ...
showTrash: boolean; // false = Inbox view, true = 휴지통 view
trashNotes: Note[]; // 휴지통 노트 cache
trashCount: number; // 헤더 탭 라벨 (`휴지통(M)`)
toggleShowTrash(): void;
loadTrash(): Promise<void>;
restoreNote(id: string): Promise<void>;
permanentDeleteNote(id: string): Promise<void>;
confirmEmptyTrash(): Promise<void>;
}
```
`toggleShowTrash``showTrash` 토글 + 진입 시 `loadTrash()` 호출.
`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환.
`upsertNote(note)` / `removeNote(id)``notes``trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정.
### 7.2 UI 추가
`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆):
```tsx
<button onClick={() => setShowTrash(false)} aria-pressed={!showTrash}>
Inbox({notes.length})
</button>
<button onClick={() => setShowTrash(true)} aria-pressed={showTrash}>
({trashCount})
</button>
```
`showTrash === true` 시:
- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`.
- `trashNotes.map(n => <NoteCard note={n} mode="trash" />)`
### 7.3 NoteCard prop `mode`
```tsx
type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' };
```
`mode === 'trash'` 시:
- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응)
- IntentBanner: hidden
- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X
- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체
- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요)
- EditableField (title / summary): read-only 모드 (input 비활성)
빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`):
> "휴지통이 비어있습니다."
### 7.4 Confirm dialog 카피
`dialog.showMessageBox` 옵션:
**bulk emptyTrash:**
- type: `question`
- buttons: `['휴지통 비우기', '취소']`
- defaultId: 1, cancelId: 1
- title: `Inkling`
- message: `휴지통의 노트 ${count}개를 영구 삭제합니다`
- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.`
**per-card 영구 삭제:**
- buttons: `['영구 삭제', '취소']`
- message: `이 노트를 영구 삭제합니다`
- detail: 위와 동일
---
## 8. F5 export / F6-L3 import / F6-L1 backup
### 8.1 ExportService
`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10).
### 8.2 ImportService
`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존.
충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가:
- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip.
- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로).
- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT.
- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족.
### 8.3 BackupService
무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함.
---
## 9. 단위 테스트 (TDD 가이드)
### 9.1 Migration v3
- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인
- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL
- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음)
- pre-v3.bak snapshot 자동 생성 (한 번만)
### 9.2 NoteRepository
- `trash(id, deletedAt)``deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리)
- `restore(id)``deleted_at` NULL 복원
- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리
- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확
- `listTrashed()``deleted_at DESC` 정렬, IS NOT NULL 만 반환
- `list()` / `listAll()` / `countToday()``deleted_at IS NULL` 만 반환 (active query exclusion)
- `findById()` 는 휴지통 노트도 반환 (모든 노트)
- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant)
### 9.3 AiWorker
- `processJob``deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출)
- 정상 노트는 그대로 처리 (회귀 없음)
### 9.4 CaptureService
- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제)
- `restoreNote` 가 restore 호출 + telemetry `restore` emit
- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit
- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확)
### 9.5 ExportService (F5)
- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성)
- `index.jsonl` 도 trash 미포함
### 9.6 ImportService (F6-L3)
- source 의 `deletedAt` 값이 import 후 보존
- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두
### 9.7 Telemetry events
- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과
- payload `.strict()``rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지)
- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산
### 9.8 e2e smoke (Playwright)
- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시
- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox
- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리
---
## 10. Out (deferred to v0.2.4+)
- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후)
- 휴지통 검색 (full-text 또는 태그 필터)
- trash 안 노트 편집 (read-only invariant 깨지면 회귀)
- per-note 영속 보호 플래그 (lock 같은 것)
- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후
- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션)
- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제)
- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용
---
## 11. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. |

View File

@@ -0,0 +1,321 @@
# v0.2.3 #6 리마인드 1 spike — Design Spec
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #6 (7번째 / 마지막 cut)
## 1. Goal
Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (`RecallBanner`) 도입. 7일 이상 안 본 노트 중 가장 오래된 1건을 제시하여 사용자가 자기 기록을 재방문할 기회 제공. 4종 telemetry (`recall_shown` / `recall_opened` / `recall_dismissed` / `recall_snoozed`) 로 효과 측정 인프라 마련.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | 다음에 snooze 영속화 | **A** in-memory | `expiredSnoozeUntilMs` 패턴 일관, schema migration v4 회피, dogfood telemetry 보고 v0.2.4 영속화 결정 |
| Q2 | `ageDays` 의미 | **B** `last_recalled_at ?? created_at` 기준 | algo 의 "7일 안 본 노트" trigger 와 동일 axis, 재추천 분포 측정 가치 |
자명 결정 (질문 없이 패턴 따름):
- Banner 위치: `ExpiryBanner` 다음 (stack 끝, 시간 민감도 가장 낮음)
- 0건 시: `null` return (`ExpiryBanner` 패턴)
- Snooze duration: KST 다음 자정 (`snoozeExpired` 패턴)
- "열어보기" 동작: `scrollIntoView` (NoteCard 항상 expanded — expand 동작 X)
## 3. Architecture & data flow
```
Inbox 마운트 시:
loadInitial() → recallCandidate fetch (별도 fetch, 단일 노트 또는 null)
RecallBanner render (recallCandidate !== null && !snoozed):
┌─ "오늘 회상해볼 노트" + 노트 제목 + (N일 전)
├─ [열어보기] → scrollIntoView(noteCardRef) + markRecallOpened(id)
│ → telemetry: recall_opened
├─ [다음에] → store.snoozeRecall() (KST 다음 자정까지 in-memory)
│ → telemetry: recall_snoozed
└─ [더 이상] → dismissRecall(id) (DB: recall_dismissed_at = now)
→ telemetry: recall_dismissed
Banner 첫 렌더 시 자동 emit: recall_shown { noteId, ageDays }
다음 fetch 트리거:
- markRecallOpened / dismissRecall 후 store 가 자동 다음 후보 fetch
- refreshMeta (focus / inbox:noteUpdated) 도 fetch
```
### 3.1 Invariants
1. **단일 후보 fetch**`LIMIT 1` + `ORDER BY created_at ASC` (가장 오래된 1건)
2. **KST 보정** — SQL 의 `date('now')` 자리 모두 `date('now','+9 hours')`
3. **마감 임박 노트 제외**`due_date < today` 인 노트는 ExpiryBanner 영역 (#5) 이라 회상 후보에서 빠짐
4. **Snooze in-memory**`recallSnoozeUntilMs` store 변수, KST 다음 자정 (ExpiryBanner 패턴)
5. **emit 순서**`recall_shown` (banner 첫 렌더) → `recall_opened/dismissed/snoozed` (사용자 액션)
6. **Snooze 시 `recall_shown` 1회만** — 같은 후보가 다시 보여도 `recall_shown` 재emit 안 함 (notes 1건당 session 1 shown — `recallShownIds: Set<string>` in-memory)
## 4. Components
### 4.1 `NoteRepository`
#### `findRecallCandidate(): Note | null`
```sql
SELECT * FROM notes
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
AND ai_status = 'done'
AND deleted_at IS NULL
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
ORDER BY created_at ASC
LIMIT 1
```
기존 `hydrate(row)` 사용 (이미 `last_recalled_at` / `recall_dismissed_at` 매핑 있음).
#### `markRecallOpened(id: string, now: string): void`
```sql
UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?
```
#### `dismissRecall(id: string, now: string): void`
```sql
UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?
```
### 4.2 `CaptureService` (5 신규 메서드)
```typescript
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();
}
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
const note = this.repo.findById(noteId);
if (!note) throw new Error(`note not found: ${noteId}`);
this.repo.markRecallOpened(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_opened',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
async dismissRecall(noteId: string): Promise<{ note: Note }> {
this.repo.dismissRecall(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_dismissed',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
async emitRecallShown(noteId: string): Promise<void> {
const note = this.repo.findById(noteId);
if (!note) return;
const ageDays = this.computeAgeDays(note);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_shown',
payload: { noteId, ageDays }
}).catch(() => {});
}
}
async emitRecallSnoozed(noteId: string): Promise<void> {
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_snoozed',
payload: { noteId }
}).catch(() => {});
}
}
private computeAgeDays(note: Note): number {
const ref = note.lastRecalledAt ?? note.createdAt;
const refMs = new Date(ref).getTime();
const nowMs = Date.now();
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
}
```
### 4.3 IPC (5 신규 channels)
```typescript
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
```
### 4.4 Preload + InboxApi shared type
```typescript
// preload/index.ts
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
```
```typescript
// shared/types.ts InboxApi
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
```
### 4.5 `telemetryEvents.ts` zod
```typescript
const RecallShownPayload = z.object({
noteId: z.string().min(1),
ageDays: z.number().int().nonnegative()
}).strict();
// recall_opened / recall_dismissed / recall_snoozed → 기존 NoteIdPayload 재사용
```
union 15 → **19** (recall_shown + recall_opened + recall_dismissed + recall_snoozed).
### 4.6 `telemetryStats.ts`
- DailyRow +4 cols (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`)
- accumulators: `recallShownCount`, `recallOpenedCount`, `recallDismissedCount`, `recallSnoozedCount`, `recallAgeDaysSum`
- summary lines:
```
- 회상 추천: shown {N} / opened {O} / dismissed {D} / snoozed {S} (열림율 {O/N}%)
- 회상 평균 ageDays: {avg}
```
N=0 시 `(데이터 없음)`
### 4.7 `TelemetryService.EmitInput` union 15 → 19
### 4.8 Renderer store (`src/renderer/inbox/store.ts`)
```typescript
interface InboxState {
// ... existing ...
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
recallShownIds: Set<string>; // session-local, "1 shown per note per session"
loadRecallCandidate: () => Promise<void>;
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>; // store action 명, IPC 와 구별
snoozeRecall: () => Promise<void>;
}
```
`refreshMeta` + `loadInitial` 가 `loadRecallCandidate` 도 호출.
`openRecall(id)`:
- `inboxApi.markRecallOpened(id)` → DB 갱신
- `loadRecallCandidate()` → 다음 후보 fetch
- (스크롤은 RecallBanner 컴포넌트가 자체 처리)
`dismissRecallNote(id)`:
- `inboxApi.dismissRecall(id)` → DB 갱신
- `loadRecallCandidate()` → 다음 후보 fetch
`snoozeRecall()`:
- `recallSnoozeUntilMs = nextKstMidnight()` (`snoozeExpired` 패턴)
- 현재 candidate noteId 기준 `inboxApi.emitRecallSnoozed(id)`
### 4.9 RecallBanner 컴포넌트
**파일**: `src/renderer/inbox/components/RecallBanner.tsx` (신규)
- 위치: `<ExpiryBanner />` 다음 (App.tsx)
- 첫 렌더 시 `useEffect` 가 `recallShownIds` 체크 후 미emit 시 `inboxApi.emitRecallShown(id)` 호출 + Set 에 추가
- Banner UI: 노트 제목 + ageDays + 3개 버튼 (열어보기 / 다음에 / 더 이상)
- `null` return: candidate=null OR snoozed (Date.now < snoozeUntilMs)
- snoozeUntilMs 만료 시 setTimeout re-render 트리거 (ExpiryBanner 패턴)
### 4.10 NoteCard ref 시스템 (scroll target)
App.tsx 가 `noteRefs: Map<noteId, HTMLDivElement | null>` ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 호출.
구체 구현:
- `App.tsx` 가 `useRef<Map<string, HTMLDivElement | null>>(new Map())` 보유
- 각 `<NoteCard>` 에 `ref={(el) => { noteRefs.current.set(note.id, el); }}` 전달 (NoteCard 가 ref forwardRef 지원 필요)
- RecallBanner 가 `noteRefs` prop 으로 받아 사용
**대안 (단순)**: `document.getElementById(\`note-${id}\`)` — App.tsx 의 NoteCard 가 `id={\`note-${note.id}\`}` 만 추가하면 됨. **이 spike 에선 이 단순 방식 채택** (ref 시스템 복잡도 회피).
## 5. Privacy invariant
- `recall_shown.payload`: `{ noteId, ageDays }` — noteId 기존 패턴, ageDays 정수
- `recall_opened/dismissed/snoozed.payload`: `{ noteId }` — `NoteIdPayload` 재사용
- `.strict()` zod 가드 + extra field 거부 테스트
## 6. Tests (≥17개)
### NoteRepository.test.ts (5)
1. 빈 db → null
2. last_recalled_at 5일 전 노트 제외 (7일 이내)
3. last_recalled_at 8일 전 노트 후보 (7일 초과)
4. recall_dismissed_at 25일 전 제외, 35일 전 후보
5. deleted_at / ai_status='pending' / due_date < today 모두 제외
### CaptureService.test.ts (4)
6. listRecallCandidate → repo.findRecallCandidate
7. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
8. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
9. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)
### telemetryEvents.test.ts (3)
10. recall_shown valid parse (noteId + ageDays)
11. recall_shown extra field 거부 (privacy)
12. recall_opened/dismissed/snoozed valid parse (noteId only)
### telemetryStats.test.ts (2)
13. shown/opened/dismissed/snoozed 누적 + 열림율 계산
14. 평균 ageDays 계산
### store.recall.test.ts (신규, 3)
15. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
16. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
17. dismissRecallNote → 후보 다시 fetch
총 신규 단위 **17개**. 기존 단위 386 + 17 = **403** 예상.
## 7. Out of scope
(roadmap §3 #6 + 본 cut 결정)
- 잠금해제 hook (F4-A, strategy.md)
- 무작위 토스트 (F4-D)
- ambient if-then (F4-B)
- 임베딩 유사도 추천 (#3 vocab 후속)
- spaced repetition (Leitner / SM-2)
- 다중 후보 추천 (현재 `LIMIT 1` only)
- snooze 영속화 (Q1=A in-memory)
- 사용자 정의 회상 주기 (7일 hardcoded)
- "회상 history" 보기 (last_recalled_at 만 저장, 이전 history X)
- RecallBanner 컴포넌트 단위 테스트 (Inkling 패턴: store 단위만 테스트)
## 8. Gates (roadmap §3.1)
- typecheck 0
- 단위 386 → 403 (+17), 모두 통과
- e2e 1/1
- 새 SQL: 복합 조건 — `idx_notes_ai_status` + `idx_notes_created_at` 활용. 별도 인덱스 불필요.
## 9. `strategy.md` 갱신 (별도 task)
roadmap §3 #6 In 절: §2.3 / §4.3 / §8 갱신:
- Capitalize 본격 진입 (회상 surface 도입)
- "오늘 회상" surface 정의
- F4-A/B/D deferred 항목의 측정 인프라 마련 명시 (recall_* telemetry 가 그 기반)
## 10. Roadmap relation
- v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
- 머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프
- v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정

View File

@@ -0,0 +1,255 @@
# v0.2.3 #3 태그 vocab — Design Spec
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #3 (6번째 cut)
## 1. Goal
기존 `tags` 테이블의 자주 쓰인 태그들을 AI prompt 에 vocabulary 로 주입해, AI 가 의미 일치 시 새 태그 생성 대신 기존 태그를 재사용하도록 유도. 효과는 `tag_vocab_hit` / `tag_vocab_miss` telemetry 로 측정.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | vocab pool 범위 | **C** AI+user 통합 + kebab-case 필터 | 사용자가 형식 맞춰 단 태그도 재사용 가치 있음, 단 형식 안 맞는 한글/공백 태그는 prompt 오염 |
| Q2 | telemetry emit 단위 | **A** 태그별 (per-tag hit/miss) | roadmap §3 #3 합의 시그니처 + 기존 누적 카운터 통계 모델과 정합 |
| Q3 | prompt 강제력 강도 | **B** "Prefer" (우선) | "MUST" 는 semantic mismatch 시 false hit, "For reference" 는 효과 미미; "Prefer" 는 우선순위 신호 + escape hatch 보장 |
| Q4 | 기존 노트 재처리 | **A** 자연 진화 (X) | invariant (user-edited 결과 보호) 와 합치, 새 노트만으로 hit/miss 충분 수집, B 는 사용자 결과 변경 |
## 3. Architecture & data flow
```
AiWorker.processJob()
├─ const vocab = repo.getTopUsedTags(20) ← SQL fetch (kebab-case 필터)
├─ provider.generate({ ..., vocab }) ← 새 input 필드
│ └─ LocalOllamaProvider.generate()
│ └─ buildPrompt(rawText, todayKst, candidates, vocab)
│ └─ vocab.length > 0 시 prompt 라인 추가
├─ AI response (tags: ['design', 'meeting', ...])
├─ repo.updateAiResult(...) ← 기존 흐름, tag insert
└─ for tag of res.tags: ← per-tag hit/miss 분류
if vocabSet.has(tag):
tagId = repo.getTagIdByName(tag) ← insert 후 보장
emit tag_vocab_hit { tagId, vocabSize }
else:
emit tag_vocab_miss { vocabSize }
```
### 3.1 Invariants
1. **매 generate 마다 SQL fetch** — vocab 캐싱/invalidation 안 함 (out of scope)
2. **vocab 빈 케이스 (N=0)** → prompt 라인 자체 생략, AI 자유롭게 새 태그 생성
3. **tagId** 는 hit 시 db tag id (`getTagIdByName` lookup, `updateAiResult` 후 호출이라 insert 보장)
4. **PROMPT_VERSION 3 → 4** (marker only, retry 트리거 X)
5. **vocab snapshot 동결** — 같은 generate call 의 `vocab` 배열로 hit/miss 판정. 처리 중 다른 노트가 새 태그 추가해도 이번 노트 분류엔 영향 X
6. **emit 순서**`updateAiResult` 후 emit (tagId 확보 보장)
## 4. Components
### 4.1 `NoteRepository`
#### `getTopUsedTags(limit = 20): string[]`
```sql
SELECT t.name, COUNT(*) c
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
JOIN notes n ON n.id = nt.note_id
WHERE n.deleted_at IS NULL
GROUP BY t.id
ORDER BY c DESC, t.id ASC
LIMIT ?
```
JS-side 후처리:
```typescript
return rows
.map((r) => r.name)
.filter((n) => /^[a-z0-9-]+$/.test(n));
```
- `source` 무시 (AI+user 통합 — Q1=C)
- `t.id ASC` tiebreaker (deterministic)
- regex 필터로 한글/공백/대문자 태그 제외
#### `getTagIdByName(name: string): number | null`
```sql
SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1
```
대소문자 무시 (tag table `name COLLATE NOCASE` 와 정합).
### 4.2 `prompt.ts`
```typescript
export const PROMPT_VERSION = 4; // bump from 3
export function buildPrompt(
rawText: string,
todayKst: string,
candidates: ParseResult[] = [],
vocab: string[] = []
): string {
const candidateBlock = ...; // 기존 로직 유지
const vocabBlock = vocab.length > 0
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
: '';
return `... ${candidateBlock} ${vocabBlock} ...`;
}
```
### 4.3 `InferenceProvider` + `LocalOllamaProvider`
```typescript
export interface GenerateInput {
text: string;
todayKst: string;
dueDateCandidates: ParseResult[];
vocab?: string[]; // optional, 미전달 시 buildPrompt 가 빈 배열 처리
}
```
`LocalOllamaProvider.generate()``buildPrompt(text, todayKst, candidates, input.vocab ?? [])` 호출.
### 4.4 `AiWorker.processJob`
generate 호출 직전:
```typescript
const vocab = this.repo.getTopUsedTags(20);
const res = await this.provider.generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates,
vocab
});
```
`updateAiResult` 후 emit 루프:
```typescript
const vocabSet = new Set(vocab);
for (const tagName of res.tags) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null && this.telemetry) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else if (this.telemetry) {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
```
### 4.5 `telemetryEvents.ts` — zod schema
```typescript
const TagVocabHitPayload = z.object({
tagId: z.number().int().positive(),
vocabSize: z.number().int().nonnegative()
}).strict();
const TagVocabMissPayload = z.object({
vocabSize: z.number().int().nonnegative()
}).strict();
```
`TelemetryEventSchema` discriminatedUnion 13 → **15** entries.
### 4.6 `telemetryStats.ts` — 누적
- `DailyRow``tag_vocab_hit: number`, `tag_vocab_miss: number` 추가
- accumulator 분기 2개
- table 컬럼 2개 추가
- summary 라인:
```
- 태그 vocab: hit/miss = {N}/{M} (적중률 {X}%)
```
N+M=0 시 `(데이터 없음)` 표기
### 4.7 `TelemetryService.EmitInput` union 확장 (15 entries)
### 4.8 `AiWorker.AiTelemetryEmitter` interface 확장
```typescript
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: ... }
| { kind: 'ai_failed'; payload: ... }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
): Promise<void>;
}
```
## 5. Privacy invariant
- `tag_vocab_hit.payload.tagId` — 숫자 id 만, 태그 이름 X
- `tag_vocab_miss.payload` — `vocabSize` 만 (tagId 없음)
- prompt 본문에 vocab 이름 들어가지만 **prompt 는 telemetry 가 아님** (모델 컨텍스트, local Ollama 머신 내부에서만 처리)
- `.strict()` zod 가드 + extra field 거부 테스트로 invariant 보호
## 6. Tests (≥19개)
### NoteRepository.test.ts (7)
1. 빈 db → `[]`
2. 정렬 (count desc, id asc tiebreaker)
3. kebab-case 필터 — 한글/공백/대문자 태그 제외
4. AI+user source 통합 카운트
5. `deleted_at IS NULL` 필터
6. LIMIT 적용 (>20 시 잘림)
7. `getTagIdByName` — 존재 시 id, 없으면 null
### prompt.test.ts (4)
8. `PROMPT_VERSION === 4`
9. vocab=[] → 라인 자체 생략
10. vocab 1+ → "Prefer reusing..." 문구 + comma-separated 리스트
11. vocab 라인 위치 (candidate block 뒤, JSON rules 앞)
### AiWorker.test.ts (4)
12. vocab fetch + provider.generate 에 vocab 전달 + hit emit
13. miss emit (vocab 밖의 tag), vocabSize 정확
14. vocab=[] 시 모든 응답 태그 miss
15. 응답 태그 3개 → 3개 emit (per-tag 검증)
### telemetryEvents.test.ts (3)
16. `tag_vocab_hit` valid parse
17. `tag_vocab_hit` extra field 거부 (privacy)
18. `tag_vocab_miss` valid parse, tagId 필드 없음
### telemetryStats.test.ts (1)
19. hit 5 + miss 3 → daily row + summary "적중률 62.5%"
기존 단위 363 + **19** = **382** 예상. Q3 phrasing 변경으로 LocalOllamaProvider 기존 테스트 일부 string assertion 수정 가능 (±5).
## 7. Out of scope
(roadmap §3 #3 + 본 cut 결정)
- 임베딩 유사도 dedup ("회의" ↔ "meeting" semantic 매핑)
- 사용자 controlled vocabulary 화이트리스트
- 자동 normalize ("회의" ↔ "미팅")
- top-N 튜닝 (N=20 hardcoded)
- vocab cache invalidation 정책 (매번 SQL fetch)
- vocab 시간 범위 필터 (최근 N일 → 전체 사용)
- 기존 `ai_status='done'` 노트 일괄 재처리 (Q4=A 자연 진화)
- 명시적 "AI 결과 재처리" trigger UI (v0.2.4 backlog)
- `promptVersion` 을 telemetry payload 에 포함 (v0.2.4 검토 — 단일 버전 cut 에선 무의미)
- `idx_note_tags_tag_id` 인덱스 추가 (현재 dogfood 규모에선 불필요, v0.2.4 검토)
## 8. Gates (roadmap §3.1 공통)
- typecheck 0
- 단위 363 → 382 (+19), 모두 통과
- e2e 1/1
- 새 SQL: `getTopUsedTags` (3-table JOIN) + `getTagIdByName` (single-table) — 인덱스 영향 dogfood 규모에서 무시
## 9. Roadmap relation
- v0.2.3 dogfood feedback #3 (6번째 cut)
- 다음 cut: #6 리마인드 1 spike (7번째, 마지막)
- v0.2.4 후속: top-N 튜닝, controlled vocabulary, normalize, embeddings dedup

View File

@@ -0,0 +1,334 @@
# v0.2.3.1 Ollama 설정 In-App UI — Design Spec
> 작성: 2026-05-04 · v0.2.3 dogfood unblock 용 patch cut. 환경변수 의존 제거, 사용자 친화 endpoint/model 변경 path.
## 1. Goal
Inkling 사용자가 트레이 메뉴 / OllamaBanner 에서 Ollama endpoint + model 을 직접 변경 가능하도록. 현재는 `INKLING_OLLAMA_ENDPOINT` env var 만 지원 — Windows 의 dynamic port 점유 (Hyper-V/WSL2 NAT) 같은 환경 이슈에 즉시 대응 못함. patch cut 으로 dogfood unblock 후 1주 soak 진입.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | Model 설정 포함 여부 | **B** Endpoint + Model 둘 다 | endpoint 만으로는 커버 불충분 (LAN 서버 fallback 시 model 도 다를 수 있음) |
| Q2 | Model input 형태 | **A** Freetext | "급한" patch cut, healthCheck 가 검증, dropdown 은 v0.2.4 영역 |
| Q3 | Settings 영속화 | **B** JSON file (`<profileDir>/settings.json`) | migration v4 회피, 항목 2개 라 transaction 불필요, user 직접 편집 가능 |
자명 결정 (질문 없이 패턴 따름):
- env var precedence: **settings > env > default**
- in-flight job 처리: **AbortController abort + provider re-create**
- UI placement: **트레이 메뉴 "Ollama 설정..." + OllamaBanner 의 "설정" 링크**
- validation: **save 전 healthCheck**
## 3. Architecture & data flow
```
사용자 액션:
1. 트레이 → "Ollama 설정..." 클릭 (또는 OllamaBanner 의 "설정" 링크)
2. Settings modal 열림 — endpoint + model 입력란 + "저장" 버튼
3. 사용자 endpoint/model 입력 → "저장" 클릭
저장 흐름:
├─ Renderer: inboxApi.saveOllamaSettings({ endpoint, model })
├─ Main IPC: 임시 LocalOllamaProvider 생성 → healthCheck()
│ ├─ ok=true → JSON 영속화 + provider/health 교체
│ └─ ok=false → 저장 거부 + reason 반환 (modal 안에 inline 에러)
├─ 기존 in-flight AI job 처리:
│ └─ AbortController abort → 현재 generate 중단 → unreachable 분류 →
│ AiWorker 의 무한 retry 가 새 endpoint 로 재시도 (자동 회복)
└─ HealthChecker.recheck() → OllamaBanner 즉시 갱신
부팅 흐름 (precedence: settings > env > default):
index.ts:
const settings = await settingsSvc.load() // JSON 또는 빈 객체
const endpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? 'http://localhost:11434'
const model = settings.ollama?.model ?? 'gemma4:e4b'
```
### 3.1 핵심 invariants
1. **저장 = 검증 통과 전제** — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지
2. **Provider mutability via re-create**`setEndpoint()` 메서드 추가 X. `ProviderHolder` 가 새 인스턴스 보유, listeners 알림. `AbortController` 가 in-flight 중단
3. **Settings precedence**: settings.json > env var > hardcoded default. UI 가 source of truth
4. **단일 settings file**`<profileDir>/settings.json`. atomic write (`writeFile` temp → `rename`). 손상 시 빈 객체 fallback (no app crash)
5. **HealthChecker rebind**`ProviderHolder.onReplace` 통해 새 provider 받아 polling endpoint 즉시 갱신
6. **Backward compat** — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0
7. **Cross-platform 자동**`app.getPath('userData')` + `node:path.join` + `node:fs/promises` 가 OS 별 경로/separator/UTF-8 자동. 별도 분기 0
## 4. Components
### 4.1 `SettingsService` (신규)
**파일**: `src/main/services/SettingsService.ts`
```typescript
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';
const OllamaSettingsSchema = z.object({
endpoint: z.string().url(),
model: z.string().min(1)
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
export class SettingsService {
private filePath: string;
private cache: Settings | null = null;
constructor(profileDir: string) {
this.filePath = join(profileDir, 'settings.json');
}
async load(): Promise<Settings> {
if (this.cache !== null) return this.cache;
try {
const raw = await readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache = SettingsSchema.parse(parsed);
} catch {
this.cache = {}; // 파일 없음 또는 손상 → 빈 객체 fallback
}
return this.cache;
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
this.cache = next;
}
}
```
### 4.2 `ProviderHolder` (신규)
**파일**: `src/main/ai/ProviderHolder.ts`
```typescript
import type { LocalOllamaProvider } from './LocalOllamaProvider.js';
export class ProviderHolder {
private current: LocalOllamaProvider;
private listeners: Array<(p: LocalOllamaProvider) => void> = [];
constructor(initial: LocalOllamaProvider) {
this.current = initial;
}
get(): LocalOllamaProvider {
return this.current;
}
replace(next: LocalOllamaProvider): void {
this.current = next;
for (const fn of this.listeners) fn(next);
}
onReplace(fn: (p: LocalOllamaProvider) => void): void {
this.listeners.push(fn);
}
}
```
### 4.3 `LocalOllamaProvider` AbortController + 사용처 변경
**파일**: `src/main/ai/LocalOllamaProvider.ts` (수정)
```typescript
export class LocalOllamaProvider implements InferenceProvider {
private abortController: AbortController | null = null;
async generate(input: GenerateInput): Promise<AiResponse> {
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
signal: this.abortController.signal
});
// ... 기존 응답 처리 ...
} finally {
clearTimeout(timer);
this.abortController = null;
}
}
abort(): void {
this.abortController?.abort();
}
}
```
기존 `signal: controller.signal` 부분에서 `controller` 가 method-local 이었음 → `this.abortController` 로 이동 (외부 abort 가능).
### 4.4 `AiWorker` + `HealthChecker` 가 `ProviderHolder` 사용
`AiWorker` constructor: `provider: InferenceProvider``private holder: ProviderHolder`
- `processJob``this.provider.generate(...)``this.holder.get().generate(...)`
- `provider: this.provider.name``provider: this.holder.get().name`
`HealthChecker` 도 동일 패턴 + `onReplace` listener 등록 → 새 provider 즉시 polling.
### 4.5 IPC + Preload + InboxApi types
```typescript
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
return s.ollama ?? null;
});
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
const r = await trial.healthCheck();
if (!r.ok) return { ok: false, reason: r.reason };
await deps.settings.setOllama(value);
// in-flight 중단 후 holder 교체
deps.providerHolder.get().abort();
deps.providerHolder.replace(trial);
await deps.health.recheck();
return { ok: true };
});
```
```typescript
// src/preload/index.ts
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
```
```typescript
// src/shared/types.ts InboxApi
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
```
### 4.6 `OllamaSettingsModal` 컴포넌트
**파일**: `src/renderer/inbox/components/OllamaSettingsModal.tsx`
- Props: `open: boolean`, `onClose: () => void`
- 입력란 2개 (endpoint, model) + "저장" / "취소"
- 마운트 시 `inboxApi.loadOllamaSettings()` → 초기값 prefill
- 저장 시 `saveOllamaSettings(...)` → 성공 닫기 + 토스트, 실패 inline 에러
- React `<dialog>` 또는 portal — 별도 BrowserWindow X (단순함)
### 4.7 OllamaBanner "설정" 링크
기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가:
```typescript
<button onClick={() => setSettingsOpen(true)}></button>
```
modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김.
### 4.8 트레이 메뉴 + IPC 채널
`tray.ts``createTray` 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가:
```typescript
{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() }
```
`index.ts``runOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings')` 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기.
### 4.9 `index.ts` 부팅 흐름 변경
```typescript
const settingsSvc = new SettingsService(paths.profileDir);
const settings = await settingsSvc.load();
const resolvedEndpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? 'http://localhost:11434';
const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b';
logger.info('ai.endpoint', {
endpoint: resolvedEndpoint,
source: settings.ollama?.endpoint
? 'settings'
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
});
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const holder = new ProviderHolder(provider);
const health = new HealthChecker(holder, { ... });
const aiWorker = new AiWorker(repo, holder, { ... });
```
## 5. Privacy invariant
- `settings.json` 은 local only, telemetry emit X
- 잠재적 `ollama_settings_changed` event 추가 시 endpoint URL 노출 → privacy 위반 → **emit 안 함** (본 cut)
- 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시 `{ count: number }` payload (URL 자체 X) 형태로 추가
## 6. Tests (≥10개)
### SettingsService.test.ts (신규, 6)
1. `load()` 파일 없음 → 빈 객체
2. `load()` 손상 JSON (parse 실패) → 빈 객체 fallback (no throw)
3. `load()` 캐시 동작 — 두 번째 호출 시 file read 안 함
4. `setOllama()` zod 검증 실패 (non-URL endpoint) → throw
5. `setOllama()` 정상 저장 → 디스크 file 존재 + 내용 일치
6. `setOllama()` atomic write — temp file 남지 않음 (rename 후 cleanup)
### ProviderHolder.test.ts (신규, 2)
7. `replace()` 시 listener 발화 + `get()` 가 새 인스턴스 반환
8. listener 여러 개 등록 시 모두 발화
### LocalOllamaProvider.test.ts (확장, 2)
9. `abort()` 호출 시 in-flight `generate()` rejects (AbortError)
10. constructor `model` 파라미터 적용 (default `gemma4:e4b` 외 임의 model)
총 신규 단위 **10개**. 기존 403 + 10 = **413**.
(Renderer modal 컴포넌트 단위 테스트 X — Inkling 패턴 따라 store-only. IPC handler 자체도 service-level test 가 logic 보유.)
## 7. Out of scope
- Multi-provider abstraction (OpenAI, Anthropic, etc) — strategy.md local-first 정책 충돌, v0.2.4+
- Settings UI 안에서 다른 기능 (telemetry retention, vocab top-N 등) — 별 cut
- Cross-machine settings sync — 단일 머신 dogfood 패턴
- Model dropdown / 자동 list refresh — Q2=A 결정 (freetext)
- `ollama pull` 자동 안내 — over-scope
- Settings export/import / version migration — over-scope
- Settings 변경 history / undo — over-scope
- Settings UI 안에 model healthCheck 결과 시각화 (loading spinner 등) — minimal toast 만
- `ollama_settings_changed` telemetry — privacy invariant 보호 (v0.2.4 검토 시 count-only)
- Settings 변경 로그 파일 — env-debug 영역, v0.2.4 검토
## 8. Gates (roadmap §3.1)
- typecheck 0
- 단위 403 → 413 (+10)
- e2e 1/1 (smoke 회귀 X)
- backward compat: settings.json 없는 부팅 → env var → default 폴백 정상
- cross-platform: SettingsService.test 가 `app.getPath` mock 으로 Win/macOS/Linux 시뮬레이션 (별도 case 또는 path matrix)
## 9. Roadmap relation
- v0.2.3 dogfood unblock 패치. 정식 v0.2.3 cut (#1~#7) 와 별개 patch
- 머지 후 v0.2.3.1 binary 재빌드 + Gitea release (existing `v0.2.3` tag → `v0.2.3.1` 신규 tag, release 별도)
- ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm

View File

@@ -0,0 +1,54 @@
# v0.2.4 Patch Cleanup — Design Spec (Brief)
> 작성: 2026-05-05 · 0.2.3.1 semver 위반 (`X.Y.Z.W` 4-part) → 0.2.4 minor bump 이용해 backlog 의 simple cleanup 5건 + 사용자 가치 1건 합쳐서 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.
## 1. Goal
PR #21 머지 후 0.2.3.1 binary 빌드 시도가 electron-builder 의 semver validation 으로 실패. 0.2.4 minor bump 으로 우회. 이번 cut 에는 dogfood unblock 외 backlog 의 risk 낮은 cleanup + 사용자 가치 항목 동봉.
## 2. Scope (5 backlog 항목 + version bump)
| backlog # | 항목 | 가치 | 작업량 |
|---|---|---|---|
| #1 | `TelemetryService.emit``now()` 2번 호출 → 1번 추출 | cosmetic (KST midnight straddle 이론) | 1줄 |
| #2 | `DAY_MS = 24*60*60*1000` magic number → 모듈 상단 상수 | cosmetic | 1줄 |
| #6 | `media.gc.run()` `.catch` 누락 → backup pattern 통일 | consistency | 1줄 |
| #13 | NoteCard `mode='trash'``onDeleted` dead-code prop 제거 | API 청소 | 작음 |
| #44 | 트레이 메뉴 + Inbox footer 에 "Inkling 0.2.4" 버전 정보 | **사용자 dogfood 가치** | 1 task |
| - | version bump 0.2.3.1 → 0.2.4 | semver 표준 | trivial |
## 3. Out of scope
- **#45 (자동실행 버그)**: Windows registry 디버깅 필요, simple X. 별도 cut.
- **#3/#4/#26 (KST 통합 / TrayCallbacks refactor)**: multi-file, 크다. 별도.
- **#5/#22 (Union 통합 / hydrate cleanup)**: repo-wide.
- **#39~#43 (PR #21 deferred)**: telemetry masking 등 의미 있는 결정 필요. v0.2.5 brainstorm 영역.
- 기타 backlog 39건.
## 4. Architecture changes
본 cut 은 의미 있는 architecture 변경 없음. 기존 pattern 강화만:
- `TelemetryService.emit` 의 atomic timestamp 보장 (now() 1회)
- 모듈 상단 magic number 상수화 패턴 (다른 파일은 이미 그 패턴, TelemetryService 만 예외)
- `.catch` consistency (backup.runDaily / telemetry.cleanupOldFiles 와 동일 wrapper)
- React props 청소 (현재 호출되지 않는 prop 제거)
- 신규 surface: 트레이 메뉴 "Inkling 정보..." → modal 또는 dialog
## 5. Tests
테스트 추가 없음 (모두 cosmetic / refactor). 기존 단위 413/413 회귀 X 확인만.
#44 의 modal 은 컴포넌트 단위 테스트 X (Inkling 패턴 — store-only).
## 6. Gates
- typecheck 0
- 단위 413/413 (회귀 X)
- e2e 1/1
- backward compat: 기존 사용자 영향 0 (cosmetic + 새 surface)
## 7. Roadmap relation
- 0.2.3 cut 7/7 (PR #13~#19) + 0.2.3.1 patch (PR #21) 누적 후 binary 빌드를 위한 v0.2.4 minor bump
- v0.2.5 brainstorm 트리거: dogfood ≥1주 soak + telemetry export + backlog 39건 (=45-5-1) + 신규 피드백 일괄 triage
- backlog 명명 `v024-backlog.md` → 본 cut 후 `v025-backlog.md` 로 rename 검토 (또는 v024-backlog.md 유지하고 내용만 갱신)

View File

@@ -53,6 +53,8 @@ AI가 제목, 요약, 태그, 프로젝트 후보를 생성합니다. 다만 사
하루 또는 주간 리뷰에서 AI가 메모를 업무 산출물로 바꿔줍니다.
오늘 회상 (RecallBanner, v0.2.3 #6): Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 가장 오래된 순으로 제시합니다. 사용자는 "열어보기"(노트 카드 스크롤 + last_recalled_at 갱신), "다음에"(KST 자정까지 in-memory snooze), "더 이상"(recall_dismissed_at 갱신, 30일 후 재추천) 중 선택합니다. 본 surface 가 Capitalize 단계의 첫 본격 진입점입니다.
예:
“이번 주 결정 근거”
@@ -140,6 +142,8 @@ Confluence 공유 후보 추천
직장에서의 동기와 몰입은 의미 있는 일에서 진전이 보일 때 강해집니다. Amabile와 Kramer의 “Progress Principle”은 지식 근로자의 감정·동기·창의성에 작은 진전 경험이 중요하다는 점을 강조합니다. Inkling의 주간 리포트는 “기록 수”보다 업무 진전의 증거를 보여줘야 합니다.
측정 인프라 (v0.2.3 #6): recall_shown / recall_opened / recall_dismissed / recall_snoozed 4종 telemetry 가 본 cut 으로 자리잡았습니다. 향후 F4-A (잠금해제 hook), F4-B (ambient if-then), F4-D (무작위 토스트) 항목 진입 시 본 telemetry 가 효과 측정 기반으로 확장됩니다.
5. 스트릭은 처벌이 아니라 회복 친화적으로 설계한다
기획서에 스트릭과 뱃지가 포함되어 있는데, 이 장치는 조심해서 써야 합니다. 게임화 연구는 전반적으로 긍정적 효과를 보이지만, 효과 크기와 안정성은 맥락에 따라 다르고, 특히 동기·행동 효과는 고품질 연구만 보면 덜 안정적일 수 있습니다. 따라서 Inkling은 경쟁·압박형 게임화가 아니라 자기효능감 회복형 게임화가 맞습니다.
@@ -280,6 +284,8 @@ AI 자동 정리는 Inkling의 핵심 강점입니다. 다만 사용자가 완
8. 관계성 보상: “내 메모가 동료의 시간을 아껴준다”
Inbox surface stack (v0.2.3 기준): Ollama 회복 → Pending 진행 → Failed 실패 → Expiry 마감 임박 → Recall 회상 추천. 시간 민감도 순으로 위에서 아래. RecallBanner 가 가장 가벼운 surface 로 stack 끝에 놓입니다.
기록 습관은 개인 생산성뿐 아니라 팀 학습과도 연결됩니다. Edmondson의 심리적 안전감 연구는 팀원이 대인관계 위험을 감수하고 질문·실수·학습 행동을 할 수 있는 분위기가 팀 학습과 관련된다는 점을 제시합니다. 업무 메모를 팀 지식으로 공유하게 만들려면 “감시받는다”가 아니라 동료를 돕는다는 감각이 필요합니다.
따라서 Confluence 내보내기 UX는 이렇게 설계합니다.

View File

@@ -0,0 +1,162 @@
# v0.2.4 Backlog
> v0.2.3 cut (7항목 / PR #13~#19) 동안 final reviewer + PR review round 1 에서 발견된 minor / nit 중 의도적으로 deferred 한 항목 누적. v0.2.3 dogfood soak 후 신규 피드백 + 본 리스트 일괄 triage → v0.2.4 cut 결정.
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
**최종 갱신:** 2026-05-05 (v0.2.4 patch cut — backlog 5건 처리)
**총 항목 수:** 45 (잔여 39 = 45 [#1 stale + #2/#6/#13/#44/#45 본 cut 처리 5건] 단 #45 는 별도 cut, 아래 표 참조)
## 처리 이력
| 항목 | 상태 | Cut |
|---|---|---|
| #1 (`now()` 2번 호출) | 이미 fix (PR #13 round 1 — backlog stale) | - |
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 patch (commit `c87c248`) |
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 patch (commit `d3dfe1e`) |
| #45 (자동실행 풀림 버그) | 별도 cut 예정 (Windows registry 디버깅) | TBD |
**잔여 39건.** v0.2.5 brainstorm 시 신규 dogfood 피드백 + 잔여 39건 일괄 triage.
## Defer 사유 카테고리
각 항목은 머지 전 inline fix 보다 v0.2.4 영역으로 미룬 명시적 사유 가짐:
1. **Cross-cutting refactor** — 한 PR 안에서 부분만 고치면 inconsistency. 일괄 cleanup task 영역. (예: KST helper 4 callsite 통합, `createTray` positional callbacks 전체 객체화)
2. **Data-dependent** — dogfood telemetry 분포 보고 결정해야 의미. (예: top-N 튜닝, recall_shown lifetime dedup 정책)
3. **Cosmetic / style** — 동작 영향 0, 다른 일괄 cleanup task. (예: `now()` 두 번 호출, `as any[]` 통합)
## How to apply
v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
- (a) 그대로 cleanup
- (b) #4~#6 영향 받아 변형
- (c) defer-further 결정
- (d) drop (만에 하나 outdated)
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
1. **`now()` 두 번 호출** — `TelemetryService.emit` (`src/main/services/TelemetryService.ts:58, :60`) 가 같은 emit 안에서 `this.now()` 두 번. 이론적 midnight straddle 가능 (ts vs filePath 다른 KST 일자), 실제 영향 cosmetic. cleanup: `const nowDate = this.now()` 한 번 추출.
2. **`DAY_MS = 24*60*60*1000` magic number** — `cleanupOldFiles:39` + `readAllRecent:78` (+ `KST_OFFSET_MS` 간접). 모듈 상단에 `const DAY_MS = 24 * 60 * 60 * 1000;` 추출.
3. **KST helper duplication**`TelemetryService.todayKstIso` + `telemetryStats.kstDate` + `AiWorker.todayKstAsDate`/`todayKstAsIso`. 4번째 caller (예: 회상 schedule, 만료 batching) 등장 시 `src/main/util/kst.ts` 로 통합.
4. **`createTray` positional 폭주** — `tray.ts:51` 가 7 positional callbacks. #1 ollama 회복 / #4 휴지통 비우기 등 트레이 메뉴 추가 시 8+ 도달 → readability threshold 넘김. `TrayCallbacks` object 로 refactor.
5. **`AiFailedReason` union 3 곳 중복** — `'unreachable' | 'schema' | 'timeout' | 'other'``telemetryEvents.ts:15` (zod), `TelemetryService.ts:21` (EmitInput), `AiWorker.ts:19, :34` (classifier + emitter) 에 분산. `export type AiFailedReason` 하나로 통합. (단 zod enum + TS literal 의 inherent dual-define 은 어쩔 수 없음 — `z.infer` 통해 type 파생만)
6. **`media.gc.run()``.catch` 누락** — T11 에서 `telemetry.cleanupOldFiles``.catch` 일관성 처리 시 `media.gc` 도 같은 패턴 (`.catch` 없음) 발견. `backup.runDaily()` 와 컨벤션 통일 위해 `.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }))` 추가.
7. **stats.md 의 reason 분포 미포함**`telemetryStats.aggregateStats` 가 AI 성공률만 계산, `ai_failed.payload.reason` 의 분포 (unreachable/schema/timeout/other counts) 는 미집계. roadmap §6.2 의 "Ollama unreachable 빈도?" 질문이 부분적으로만 답해짐. v0.2.3 dogfood 후 실제 reason 분포 보고 결정.
## v0.2.3 #4 휴지통 누적 (2026-05-01)
8. **stats.md exhaustiveness check**`telemetryStats.aggregateStats` 의 7-arm if/else if 가 union 확장 시 silent fall-through. `else { const _: never = ev; }` 추가로 컴파일 단계 가드.
9. **휴지통 회수율 ratio 의미 코멘트**`restore / trash` 가 event-level ratio (한 노트 trash-restore 반복 시 100% 가능). spec §6.2 의 "회수 도구 동작?" 질문에는 충분, 단 unique-note 회수율로 오해할 여지. 코드 옆 1줄 코멘트.
10. **`restore` 시 AI 결과 보존 + pending_jobs 미재생성** — restore 가 `deleted_at = NULL` 만, pending_jobs 안 재생성. 사용자가 trash 도중 AI fail 한 노트를 restore 시 재처리 경로 부재. v0.2.3 dogfood 에서 빈도 보고 결정 — drop / per-note retry 버튼 / 자동 재처리 중.
11. **`restoreNote(id)` precondition 노출** — store 의 낙관적 갱신이 `trashNotes` 에 노트가 있어야 동작. 명령 팔레트 / 프로그래밍 호출 케이스 시 silently no-op. 현재는 trash view 한정이라 OK. main 이 trash/restore 시 `pushNoteUpdated` 보내도록 변경하면 더 견고.
12. **`inbox:trashCount` cap 200 silent undercount** — UI 만 200 cap, `repo.emptyTrash()` SQL 은 unbounded. 350 노트 trash 시 dialog "200개 영구 삭제" 표시되지만 실제 350 모두 삭제. `repo.countTrashed()` 추가로 둘 다 정확히. **(잠재 UX 버그 — pull-forward 후보)**
13. **NoteCard `mode='trash'` 의 `onDeleted` dead-code** — trash 카드는 `onPermanentDelete`/`onRestore` 만 사용. `onDeleted` prop 은 호출되지 않음 (App.tsx 가 pass-through). API 깔끔히 — `onDeleted?` optional + trash 분기 미전달.
14. **탭 ARIA 패턴**`aria-pressed` 로 toggle 버튼 표현. canonical 은 `role="tab"` + `aria-selected`. screen reader 동작 OK 지만 a11y audit 시 정정 후보.
15. **`inbox:delete` 채널 rename** — semantic 이 hard → soft 인데 채널 이름 그대로. v0.2.4 에서 `inbox:trash` 로 rename 검토 (기존 호출 0건 보장 후).
16. **per-note 영구 삭제 telemetry 사용량** — v0.2.3 dogfood 에서 `permanent_delete` event 빈도 확인. 거의 0 이면 v0.2.4 에서 per-card "영구 삭제" 버튼 제거 + bulk emptyTrash 만 (UX 단순화). 빈번하면 유지.
## v0.2.3 #5 만료 추천 누적 (2026-05-01)
17. **dialog 버튼 순서 vs spec §5.3** — spec 은 `['취소','옮기기'], default=0`, 구현은 `['옮기기','취소'], defaultId=1, cancelId=1` (`inboxApi.ts:117`). 효과 동일 (default = cancel). v0.2.4 에서 spec 또는 impl 한쪽 통일.
18. **`loadExpired()` 미사용** — `loadInitial`/`refreshMeta` 가 inline fetch, App.tsx 도 호출 안 함 (test 만 exercise). v0.2.4 dogfood 후에도 consumer 미발생 시 제거 검토.
19. **store `KST_OFFSET_MS` inline duplication**`store.ts:166``snoozeExpired` 가 inline KST 계산. `@main/util/kstDate.ts` 와 동일 알고리즘이지만 alias 경계 (main vs renderer) 로 import 불가. `src/shared/util/kstDate.ts` 로 lift 검토. (#3, #34 와 합산 가능)
20. **telemetry emit `.catch(() => {})` 가 silent**`CaptureService.listExpired`/`trashExpiredBatch` 가 그대로. v0.2.4 telemetry 하드닝 시 debug log path (project pattern 통일) 추가 검토.
21. **TelemetryService.test.ts 의 noteId 가드 widening**`e.kind !== 'empty_trash' && e.kind !== 'expired_banner_shown' && e.kind !== 'expired_batch_trash'` 체인이 #6 추가 시 더 길어짐. `hasNoteId(ev)` type predicate helper 추출 검토.
22. **NoteRepository hydrate 의 `as any[]` 일괄 cleanup**`findExpiredCandidates` round 1 review 의 nit 가 단독 fix 시 다른 hydrate-using methods 와 inconsistency. `db.prepare().all()` 의 row type 을 `Record<string, unknown>[]` 또는 explicit row interface 로 통일하는 repo-wide refactor.
## v0.2.3 #1 ollama 회복 누적 (2026-05-01)
23. **`createTray` 8 positional callbacks** — #1 cut 에서 8개 도달, v0.2.4 backlog #4 와 정합 (TrayCallbacks object refactor 약속). #2 retry 또는 #6 reminder cut 에서 추가 항목 (예: "재시도 N건") 등장 시 9+ 회피 위해 본격 refactor.
24. **Banner CSS 스타일 inline 중복** — ExpiryBanner (`#fff7e6 / #d99500 / #946100` 황색) / OllamaBanner (동) / FailedBanner (`#fce4e4 / #a33` 적색) / RecallBanner (`#e8f0fe / #4a7ec0` 청색) 모두 색상 hardcode. v0.2.4 에서 CSS variables 또는 banner shared component (`<Banner severity="warning|error|info" />`) 추출 검토.
25. **HealthChecker `inFlight` 가드의 manual emit ordering** — manual emit 이 inFlight 체크 전 발생해 user 가 빠르게 N번 클릭하면 N개 manual telemetry. spec 의도 (1:1 보장) 와 정합이지만, 향후 dedup 정책 (예: 1초 윈도우) 으로 변형 가능성. v0.2.4 dogfood soak 결과로 결정.
## v0.2.3 #2 AI retry 누적 (2026-05-02)
26. **`createTray` 9 positional callbacks** — #2 cut 에서 9개 도달 (refreshTrayFailedCount 포함). #4 `TrayCallbacks` object refactor 가 이제 readability blocker. #3 / #6 cut 어느 쪽이든 추가 callback 더 들어오기 전에 우선 처리.
27. **`refreshTrayFailedCount` exported singleton state** — `tray.ts``_failedCount` module-scoped state + setter 패턴. 모듈 캡슐화로 작동하지만 multi-window 또는 multi-tray 시 broken. v0.2.4 refactor 시 TrayController class 또는 store-driven 으로 정리.
28. **`AiWorker.unreachableBackoffStep` 단일 카운터 vs job-level** — 모든 job 이 step counter 공유. 1 job timeout → step↑, 다른 job 정상 처리해도 step reset. 현재는 cross-job correlation 없으니 OK 가정 (Ollama daemon 단일이라 모든 job 이 같은 백엔드 의존). multi-provider 가 들어오면 provider-level step 으로 분리 필요.
## v0.2.3 #3 태그 vocab 누적 (2026-05-02)
29. **`getTopUsedTags(20)` magic number** — `AiWorker.processJob:137``repo.getTopUsedTags(20)` hardcoded. spec §7 Out 에 "top-N 튜닝" 명시. v0.2.4 dogfood telemetry (`tag_vocab_hit/miss` ratio) 보고 `VOCAB_TOP_N` 모듈 상수 추출 + 튜닝 결정.
30. **`getTopUsedTags` LIMIT-then-filter 의미** — SQL 가 limit 만큼 가져온 후 JS regex 가 후처리 → top-20 안에 한글/공백 태그 섞이면 결과 length < limit. dogfood 규모 OK 가정 + 테스트 lock-in (v0.2.3 round 1 m2 fix). v0.2.4 에서 vocab pool 확장 시 SQL `GLOB` 으로 SQL-side 필터 대안 검토 (또는 `LIMIT ?*2` overfetch+slice).
31. **`vocabSet` strict-eq vs DB COLLATE NOCASE 불일치** — `vocabSet = new Set(vocab)` 은 JS 대소문자 strict, `tags.name` 은 COLLATE NOCASE. 현재는 kebab-case 필터로 vocab 이 항상 lowercase + AI prompt 도 lowercase 강제라 충돌 없지만, vocab pool 확장 시 (예: `'Design'` 사용자 직접 추가) `getTagIdByName('Design')` 은 매치하지만 `vocabSet.has('Design')` 은 miss → tagId 없는 hit 가 silently skip. v0.2.4 에서 `vocabSet = new Set(vocab.map(v => v.toLowerCase()))` + `vocabSet.has(tagName.toLowerCase())` 로 normalize 검토.
32. **AiWorker per-tag emit serial await**`for (const tag of new Set(...))` 안의 `await this.telemetry.emit(...)` 가 직렬. 3 태그 시 file-append 3 round-trip. `Promise.all` 로 병렬화 가능, 단 `ai_succeeded` emit 도 serial 이라 패턴 일관성 우선 skip. v0.2.4 telemetry 하드닝 시 일괄 변경 검토.
33. **`PROMPT_VERSION` telemetry payload 미포함** — v0.2.3 cut 에선 단일 버전 (4) 만 굴러가서 무의미. v0.2.4/v0.2.5 prompt 튜닝 후 어느 버전이 어떤 hit-rate 만든지 추적 시 `tag_vocab_hit/miss` payload 에 `promptVersion` 추가 검토. spec §7 Out 명시.
## v0.2.3 #6 RecallBanner 누적 (2026-05-02)
34. **KST midnight inline calc 4번째 복제**`store.ts``snoozeRecall` (#6) + `snoozeExpired` (#5) + `NoteCard.todayKstIso` + 다른 1곳, 그리고 `kstDate.ts` util 도 별도 존재. 4 callsite 모두 동일 알고리즘. v0.2.4 에서 `nextKstMidnightMs()` / `kstTodayIso()` 단일 util 통합 + alias 경계 (main vs renderer) 해결책. backlog #3, #19 와 합산.
35. **`recall_shown` per-banner-lifetime emit 보장** — useState→useRef 로 race 차단했지만 RecallBanner 컴포넌트 unmount/remount 시 reset. 사용자가 페이지 이동 후 돌아오면 같은 노트가 재emit 가능. v0.2.4 dogfood telemetry 에서 동일 noteId 의 `recall_shown` 빈도 보고 결정 (per-noteId 24h dedup 또는 per-noteId 영속 마커).
36. **`emitRecallShown` / `emitRecallSnoozed` 가 fire-and-forget 인데 `ipcMain.handle` 사용** — 더 honest 한 패턴은 `ipcMain.on` (return value 없음). 현재는 다른 IPC 와 패턴 일관성 우선. v0.2.4 IPC 정리 시 `handle` vs `on` 구분 일괄 검토.
37. **NoteCard `id="note-${id}"` load-bearing** — RecallBanner 의 `scrollIntoView` target. 단순 DOM lookup 이라 shadow DOM / portal 미지원. v0.2.4 에서 다른 surface (예: 검색 결과에서 스크롤) 등장 시 ref-forwarding 패턴 검토.
## v0.2.3.1 Ollama Settings 누적 (2026-05-04)
39. **`ollama_unreachable.reason` 에 endpoint URL 노출 (PII 우회)** — `LocalOllamaProvider.healthCheck` 가 catch err 시 `reason: \`unreachable: ${err.message}\`` 로 emit. `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능. v0.2.3.1 의 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 PII 우회 노출 경로 확대. v0.2.4 telemetry 하드닝 시: error class only (network/dns/timeout/...) 또는 host 마스킹 (`<host>:11434`) 정책. PR #21 round 1 m2 deferred.
40. **Settings 저장 vs HealthChecker 60s tick race**`saveOllamaSettings` IPC 가 `health.runOnce()` 호출, 동시에 60s 주기 tick 도 `inFlight` 가드 통해 같이 실행 시도. 정확성 영향 0 (가드로 dedup), 단 modal 닫기 직전 banner flicker 가능. PR #21 round 1 i1 acknowledge only. v0.2.4 dogfood 에서 실제 빈도 확인 후 결정 (visible 빈도 낮으면 무시).
41. **`OllamaSettingsModal` 인라인 스타일** — 60+ 줄 inline style. backlog #24 (banner CSS 추출) 와 합산. v0.2.4 에서 CSS module / theme variables 추출 시 함께.
42. **Modal 의 client-side URL validation 부재** — endpoint freetext 가 잘못된 형식 (예: 빈 문자열, 한글) 일 때 server-side healthCheck 만 검증. zod URL error message 가 opaque ("Invalid url"). v0.2.4 에서 client-side z.string().url() pre-check + 친화적 에러 메시지.
43. **`createTray` 10번째 positional callback** — v0.2.3.1 cut 에서 10개 도달 (`runOpenOllamaSettings` 추가). backlog #4/#26 (TrayCallbacks object refactor) blocker 수준. v0.2.4 첫 cleanup 항목 후보.
## v0.2.3 / v0.2.3.1 dogfood 발견 (2026-05-05)
> 본 cut 들의 머지 후 사용자가 dogfood 중 발견한 항목. PR review deferred 와 달리 raw UX/bug 발견.
44. **버전 및 프로그램 정보 표시 방법 부재** — 현재 사용자가 설치된 Inkling 의 버전 (package.json `0.2.3.1`) 을 UI 에서 확인할 path 없음. 트레이 메뉴 / Inbox 푸터 / 별도 "About Inkling" 모달 어느 surface 에도 정보 없음. 핸드오프 후 다른 머신에서 같은 버전인지 사용자가 직접 검증 불가. v0.2.4 에서 트레이 메뉴 "Inkling 0.2.3.1 정보..." 또는 Inbox 우하단 footer 형태로 추가 검토. 곁들여: 빌드 commit SHA, electron/node 버전, OS, profileDir 경로 등 디버그 정보 노출 (사용자가 issue report 시 첨부 가능).
45. **윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그** — 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 시 체크박스가 풀려서 표시됨. 코드 (`src/main/tray.ts:47-58`) 가 `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings().openAtLogin` 이 false 반환. 추정 원인:
- (a) Windows registry 에 쓴 exe path 와 현재 프로세스 path 가 다름 (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
- v0.2.4 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
## post-cut next-step (status, not backlog)
38. **v0.2.3 cut 7/7 완료 → binary 빌드 단계** — slice §7 strict-pin patch 증분으로 v0.2.3 binary 빌드 + dogfood 핸드오프. ≥1주 soak 후 telemetry export 분석으로 v0.2.4 brainstorm 트리거. (✓ 2026-05-02 빌드 완료, hotfix #20 + publish:null 포함, release 재생성 완료)
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분
- 2-layer privacy invariant (zod outer + payload `.strict()`) 가 강한 defense
- KST 처리 일관성 — 4 callsites 동일 패턴
- backward compat — 기존 13 테스트 (Capture 4 + AiWorker 9) 무수정 통과
- 신규 dep 0 (zip 회피로 폴더 + 2 file 정책)
- TelemetryService surface 가 깔끔한 foundation — 다음 항목들이 (a) zod schema 추가, (b) EmitInput arm 추가, (c) emit 호출만 하면 됨

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.2.0",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.2.0",
"version": "0.2.4",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.2.2",
"version": "0.2.4",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",
@@ -22,13 +22,18 @@
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"predist": "npm run rebuild:electron && npm run build",
"dist": "electron-builder --win --x64",
"dist": "electron-builder",
"predist:dir": "npm run rebuild:electron && npm run build",
"dist:dir": "electron-builder --dir --win --x64"
"dist:dir": "electron-builder --dir",
"predist:win": "npm run rebuild:electron && npm run build",
"dist:win": "electron-builder --win --x64",
"predist:mac": "npm run rebuild:electron && npm run build",
"dist:mac": "electron-builder --mac --arm64"
},
"build": {
"appId": "xyz.altair823.inkling",
"productName": "Inkling",
"publish": null,
"files": [
"out/**/*",
"package.json"
@@ -47,6 +52,13 @@
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"shortcutName": "Inkling"
},
"mac": {
"target": [
{ "target": "dmg", "arch": ["arm64"] }
],
"category": "public.app-category.productivity",
"identity": null
}
},
"dependencies": {

View File

@@ -1,7 +1,8 @@
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
import { ProviderHolder } from './ProviderHolder.js';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@@ -15,8 +16,30 @@ function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
}
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
if (err instanceof ZodError) return 'schema';
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
return 'unreachable';
}
if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) {
return 'timeout';
}
return 'other';
}
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
): Promise<void>;
}
export interface AiWorkerOptions {
backoffsMs?: number[];
unreachableBackoffsMs?: number[];
onUpdate?: (note: Note) => void;
logger?: {
info: (msg: string, meta?: Record<string, unknown>) => void;
@@ -24,6 +47,7 @@ export interface AiWorkerOptions {
error: (msg: string, meta?: Record<string, unknown>) => void;
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
}
interface Job { noteId: string; attempts: number; }
@@ -33,19 +57,24 @@ export class AiWorker {
private running = false;
private drainResolvers: Array<() => void> = [];
private backoffsMs: number[];
private unreachableBackoffsMs: number[];
private unreachableBackoffStep = 0;
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
constructor(
private repo: NoteRepository,
private provider: InferenceProvider,
private holder: ProviderHolder,
opts: AiWorkerOptions = {}
) {
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
this.unreachableBackoffsMs = opts.unreachableBackoffsMs ?? [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
}
async enqueue(noteId: string): Promise<void> {
@@ -93,45 +122,107 @@ export class AiWorker {
}
private async processJob(job: Job): Promise<void> {
// `max` 는 schema/other 분기 (attempts 증가) 의 cap 이다.
// unreachable/timeout 분기는 `attempt -= 1; continue` 로 인덱스 stay — max 와 무관 무한 retry.
const max = this.backoffsMs.length;
for (let attempt = job.attempts; attempt < max; attempt++) {
const startMs = this.now().getTime();
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const res = await this.provider.generate({
const vocab = this.repo.getTopUsedTags(20);
const res = await this.holder.get().generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates
dueDateCandidates: candidates,
vocab
});
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, {
title: res.title,
summary: res.summary,
tags: res.tags,
provider: this.provider.name,
provider: this.holder.get().name,
dueDate: res.dueDate ?? null
});
this.unreachableBackoffStep = 0; // 성공 시 step reset
this.logger.info('ai.done', {
noteId: job.noteId,
attempt,
dueDateSource: res.dueDate !== null ? 'ai' : 'none',
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
durationMs: this.now().getTime() - startMs,
attempts: attempt + 1
}
}).catch(() => {});
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
const vocabSet = new Set(vocab);
for (const tagName of new Set(res.tags)) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
}
this.emit(job.noteId);
return;
} catch (err) {
const isLast = attempt === max - 1;
const reason = classifyReason(err);
const msg = (err as Error).message;
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg });
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg, reason });
if (reason === 'unreachable' || reason === 'timeout') {
// 무한 retry: attempts 증가 안 함, in-place loop + sleep.
// markAiFailed / ai_failed emit 안 함 — ratio 통계는 schema/other 만 누적.
const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep);
// step 이 cap 도달 후엔 인덱스 stay — increment 는 무의미하지만 안전한 no-op.
// (Math.min 가드: cap 넘어가도 length-1 로 묶임.)
if (this.unreachableBackoffStep < this.unreachableBackoffsMs.length - 1) {
this.unreachableBackoffStep += 1;
}
const nextRunAt = new Date(Date.now() + sleepMs).toISOString();
this.repo.setNextRunAt(job.noteId, nextRunAt, msg);
await this.sleep(sleepMs);
attempt -= 1; // for 루프 attempt++ 상쇄 — 같은 attempt 인덱스로 재시도
continue;
}
// schema / other: 기존 max 3 retry 정책
const isLast = attempt === max - 1;
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_failed',
payload: {
noteId: job.noteId,
reason,
attempts: attempt + 1
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
}
@@ -140,6 +231,11 @@ export class AiWorker {
}
}
private nextBackoffMs(step: number): number {
const idx = Math.min(step, this.unreachableBackoffsMs.length - 1);
return this.unreachableBackoffsMs[idx]!;
}
private emit(noteId: string): void {
if (!this.onUpdate) return;
const note = this.repo.findById(noteId);

View File

@@ -5,6 +5,7 @@ export interface GenerateInput {
text: string;
todayKst: string; // ISO YYYY-MM-DD in KST
dueDateCandidates: ParseResult[];
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
}
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
@@ -13,4 +14,6 @@ export interface InferenceProvider {
readonly name: string;
generate(input: GenerateInput): Promise<AiResponse>;
healthCheck(): Promise<HealthResult>;
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort?: () => void;
}

View File

@@ -2,6 +2,7 @@ import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
export interface LocalOllamaOptions {
endpoint?: string;
@@ -18,10 +19,11 @@ export class LocalOllamaProvider implements InferenceProvider {
private timeoutMs: number;
private temperature: number;
private numPredict: number;
private abortController: AbortController | null = null;
constructor(opts: LocalOllamaOptions = {}) {
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
this.model = opts.model ?? 'gemma4:e4b';
this.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
this.model = opts.model ?? DEFAULT_OLLAMA_MODEL;
this.timeoutMs = opts.timeoutMs ?? 120_000;
this.temperature = opts.temperature ?? 0.2;
this.numPredict = opts.numPredict ?? 512;
@@ -29,20 +31,20 @@ export class LocalOllamaProvider implements InferenceProvider {
}
async generate(input: GenerateInput): Promise<AiResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates),
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
signal: controller.signal
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
@@ -55,9 +57,15 @@ export class LocalOllamaProvider implements InferenceProvider {
return parseAiResponse(parsed);
} finally {
clearTimeout(timer);
this.abortController = null;
}
}
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort(): void {
this.abortController?.abort();
}
async healthCheck(): Promise<HealthResult> {
try {
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });

View File

@@ -0,0 +1,36 @@
import type { InferenceProvider } from './InferenceProvider.js';
/**
* v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시
* 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer.
*
* 사용 패턴:
* const holder = new ProviderHolder(initialProvider);
* aiWorker = new AiWorker(repo, holder, opts);
* health = new HealthChecker(holder, opts);
*
* // 사용자가 Settings 저장 시:
* holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용)
* holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용
*/
export class ProviderHolder {
private current: InferenceProvider;
private listeners: Array<(p: InferenceProvider) => void> = [];
constructor(initial: InferenceProvider) {
this.current = initial;
}
get(): InferenceProvider {
return this.current;
}
replace(next: InferenceProvider): void {
this.current = next;
for (const fn of this.listeners) fn(next);
}
onReplace(fn: (p: InferenceProvider) => void): void {
this.listeners.push(fn);
}
}

View File

@@ -1,21 +1,27 @@
import type { ParseResult } from '../services/dueDateParser.js';
export const PROMPT_VERSION = 3;
export const PROMPT_VERSION = 4;
export function buildPrompt(
rawText: string,
todayKst: string,
candidates: ParseResult[] = []
candidates: ParseResult[] = [],
vocab: string[] = []
): string {
const candidateBlock = candidates.length > 0
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched token: "${c.matchedToken ?? '?'}" (confidence: ${c.confidence ?? 'low'})`).join('\n')}\n`
: '';
const vocabBlock = vocab.length > 0
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
: '';
// candidateBlock & vocabBlock are self-delimited with leading/trailing \n
return `You organize raw personal notes into structured metadata.
Today's date in Korea Standard Time (KST): ${todayKst}
${candidateBlock}
${candidateBlock}${vocabBlock}
Input note (raw text, may be fragmented, any language):
---
${rawText}

View File

@@ -1,8 +1,9 @@
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';
const migrations = [m001, m002];
const migrations = [m001, m002, m003];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

@@ -0,0 +1,15 @@
// v3: soft delete (#4) introduces deleted_at.
// last_recalled_at + recall_dismissed_at are pre-allocated for #6 (recall) —
// dormant until then to avoid a v4 migration round-trip.
import type Database from 'better-sqlite3';
export const version = 3;
export function up(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
`);
}

View File

@@ -15,19 +15,23 @@ import { HotkeyService } from './services/HotkeyService.js';
import { IntentService } from './services/IntentService.js';
import { HealthChecker } from './services/HealthChecker.js';
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
import { ProviderHolder } from './ai/ProviderHolder.js';
import { AiWorker } from './ai/AiWorker.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
} from './windows/quickCaptureWindow.js';
import { createTray, refreshTray } from './tray.js';
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
import { BackupService } from './services/BackupService.js';
import { ExportService } from './services/ExportService.js';
import { ImportService } from './services/ImportService.js';
import { SyncService } from './services/SyncService.js';
import { TelemetryService } from './services/TelemetryService.js';
import { SettingsService } from './services/SettingsService.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
@@ -43,6 +47,11 @@ app.whenReady().then(async () => {
const paths = resolveProfilePaths('default');
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
void telemetry.cleanupOldFiles()
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
.catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) }));
if (app.isPackaged && process.platform === 'win32') {
const initFlag = join(paths.profileDir, '.autostart-init');
if (!existsSync(initFlag)) {
@@ -57,22 +66,51 @@ app.whenReady().then(async () => {
const continuity = new ContinuityService(db);
const intent = new IntentService(repo);
const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434';
const settingsSvc = new SettingsService(paths.profileDir);
const settings = await settingsSvc.load();
const resolvedEndpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? DEFAULT_OLLAMA_ENDPOINT;
const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL;
logger.info('ai.endpoint', {
endpoint: resolvedEndpoint,
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
model: resolvedModel,
source: settings.ollama?.endpoint
? 'settings'
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
});
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));
const worker = new AiWorker(repo, provider, {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
const health = new HealthChecker(providerHolder, {
onUpdate: (status) => {
logger.info('ai.health', { ...status } as Record<string, unknown>);
pushOllamaStatus(getInboxWindow, status);
refreshTrayOllama(status.ok);
},
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') {
void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
} else if (ev.kind === 'ollama_recovered') {
void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
} else if (ev.kind === 'ollama_recheck_manual') {
void telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {});
}
}
});
health.start();
const worker = new AiWorker(repo, providerHolder, {
onUpdate: (note) => {
pushNoteUpdated(getInboxWindow, note);
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
refreshTray(repo.countToday());
refreshTrayFailedCount(repo.countFailed());
},
logger
logger,
telemetry
});
const notify = new NotificationService({
@@ -84,13 +122,14 @@ app.whenReady().then(async () => {
const capture = new CaptureService(repo, store, {
enqueue: (id) => worker.enqueue(id),
celebrate: (id) => notify.celebrate(id)
celebrate: (id) => notify.celebrate(id),
telemetry
});
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow
getInboxWindow, settings: settingsSvc, providerHolder
});
const hotkeys = new HotkeyService();
@@ -107,7 +146,9 @@ app.whenReady().then(async () => {
await worker.loadFromDb();
const gc = new MediaGc(db, store);
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
void gc.run()
.then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>))
.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }));
const exportSvc = new ExportService(repo, store);
const importSvc = new ImportService(repo, store);
@@ -119,7 +160,14 @@ app.whenReady().then(async () => {
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
let backupOnQuitDone = false;
let trayInterval: NodeJS.Timeout | null = null;
app.on('before-quit', (e) => {
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
health.stop();
if (trayInterval !== null) {
clearInterval(trayInterval);
trayInterval = null;
}
if (backupOnQuitDone) return;
e.preventDefault();
backup.runDaily()
@@ -283,16 +331,52 @@ app.whenReady().then(async () => {
logger.warn('sync.exception', { reason: String(e) });
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
}
},
/* runExportTelemetry */ async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '사용 로그를 내보낼 폴더 선택',
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
buttonLabel: '여기로 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return;
try {
const r = await telemetry.exportTo(result.filePaths[0]!);
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
new Notification({
title: 'Inkling',
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
silent: true
}).show();
} catch (e) {
logger.warn('telemetry.export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '사용 로그 내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
},
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); },
/* runOpenOllamaSettings */ () => {
const win = getInboxWindow();
if (win) win.webContents.send('inbox:openOllamaSettings');
}
);
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
refreshTray(repo.countToday());
const trayInterval = setInterval(() => {
refreshTrayFailedCount(repo.countFailed());
trayInterval = setInterval(() => {
refreshTray(repo.countToday());
}, 60_000);
app.on('before-quit', () => { clearInterval(trayInterval); });
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();

View File

@@ -1,12 +1,16 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
const { ipcMain } = electron;
const { ipcMain, dialog } = electron;
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
import type { IntentService } from '../services/IntentService.js';
import type { Note } from '@shared/types';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
export interface InboxIpcDeps {
repo: NoteRepository;
@@ -15,6 +19,8 @@ export interface InboxIpcDeps {
health: HealthChecker;
intent: IntentService;
getInboxWindow: () => BrowserWindow | null;
settings: SettingsService;
providerHolder: ProviderHolder;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -52,6 +58,117 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
await deps.capture.restoreNote(noteId);
});
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['영구 삭제', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: '이 노트를 영구 삭제합니다',
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false };
await deps.capture.permanentDeleteNote(noteId);
return { confirmed: true };
});
ipcMain.handle('inbox:emptyTrash', async () => {
const fullCount = deps.repo.countTrashed();
if (fullCount === 0) return { confirmed: true, count: 0 };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['휴지통 비우기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false, count: 0 };
const result = await deps.capture.emptyTrash();
return { confirmed: true, count: result.count };
});
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
deps.repo.listTrashed(opts)
);
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired());
ipcMain.handle(
'inbox:trashExpiredBatch',
async (_e, payload: { ids: string[] }) => {
if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['옮기기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`,
detail: '복구는 휴지통 탭에서 가능합니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { trashedCount: 0, confirmed: false };
const result = await deps.capture.trashExpiredBatch(payload.ids);
return { trashedCount: result.trashedCount, confirmed: true };
}
);
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true });
return deps.health.lastStatus();
});
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
return s.ollama ?? null;
});
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
const r = await trial.healthCheck();
if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' };
try {
await deps.settings.setOllama(value);
} catch (e) {
return { ok: false, reason: `persist failed: ${(e as Error).message}` };
}
deps.providerHolder.get().abort?.();
deps.providerHolder.replace(trial);
// 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신
await deps.health.runOnce();
return { ok: true };
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
@@ -59,3 +176,9 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
if (!w || w.isDestroyed()) return;
w.webContents.send('note:updated', note);
}
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}

View File

@@ -1,6 +1,7 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { Note, NoteMedia, NoteTag } from '@shared/types';
import { todayInKstString } from '../util/kstDate.js';
export interface CreateNoteInput { rawText: string; }
@@ -28,6 +29,7 @@ export interface ImportNoteInput {
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
deletedAt?: string | null;
}
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
@@ -38,6 +40,8 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
@@ -83,17 +87,25 @@ export class NoteRepository {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = opts.cursor
? (this.db
.prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`)
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(opts.cursor, limit) as any[])
: (this.db
.prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`)
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as any[]);
return rows.map((r) => this.hydrate(r));
}
listAll(): Note[] {
const rows = this.db
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
.all() as any[];
return rows.map((r) => this.hydrate(r));
}
@@ -146,6 +158,140 @@ export class NoteRepository {
tx();
}
findFailedIds(): string[] {
const rows = this.db
.prepare(
`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC`
)
.all() as Array<{ id: string }>;
return rows.map((r) => r.id);
}
countFailed(): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`
)
.get() as { c: number };
return row.c;
}
/**
* 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입.
* 단일 transaction. v0.2.3 #2 retryAllFailed.
*
* INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip).
*/
retryAllFailed(now: string): { ids: string[] } {
const ids: string[] = [];
const tx = this.db.transaction(() => {
const rows = this.db
.prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`)
.all() as Array<{ id: string }>;
if (rows.length === 0) return;
const reset = this.db.prepare(
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
);
const insert = this.db.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
);
for (const r of rows) {
reset.run(now, r.id);
insert.run(r.id, now);
ids.push(r.id);
}
});
tx();
return { ids };
}
/**
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
*/
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void {
this.db
.prepare(
`UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?`
)
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
/**
* v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
* - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
* - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
* - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
* KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
*/
findRecallCandidate(): Note | null {
const row = this.db
.prepare(
`SELECT * FROM notes
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
AND ai_status = 'done'
AND deleted_at IS NULL
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
ORDER BY created_at ASC
LIMIT 1`
)
.get() as Record<string, unknown> | undefined;
return row ? this.hydrate(row) : null;
}
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
markRecallOpened(id: string, now: string): void {
this.db
.prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
.run(now, now, id);
}
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
dismissRecall(id: string, now: string): void {
this.db
.prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
.run(now, now, id);
}
/**
* v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
* source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
* deleted_at IS NULL 만 (휴지통 노트 태그 제외).
*
* Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨.
* 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case
* 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음.
*/
getTopUsedTags(limit = 20): string[] {
const rows = this.db
.prepare(
`SELECT t.name, COUNT(*) AS c
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
JOIN notes n ON n.id = nt.note_id
WHERE n.deleted_at IS NULL
GROUP BY t.id
ORDER BY c DESC, t.id ASC
LIMIT ?`
)
.all(limit) as Array<{ name: string; c: number }>;
return rows
.map((r) => r.name)
.filter((n) => KEBAB_CASE_RE.test(n));
}
/**
* v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장.
* tags.name COLLATE NOCASE 라 case-insensitive lookup.
*/
getTagIdByName(name: string): number | null {
const row = this.db
.prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`)
.get(name) as { id: number } | undefined;
return row ? row.id : null;
}
updateUserAiFields(
id: string,
fields: { title?: string; summary?: string; tags?: string[] }
@@ -221,6 +367,84 @@ export class NoteRepository {
.run(date, now, id);
}
trash(id: string, deletedAt: string): void {
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
.run(deletedAt, deletedAt, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
});
tx();
}
/**
* Atomically transition a batch of notes from active → trash.
* Returns the number of notes that actually transitioned (i.e. were active
* before the call). Already-trashed and unknown ids are silent skips —
* counting them would inflate `expired_batch_trash` telemetry.
*
* Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup
* invariant (§9.2 of #4 spec).
*/
trashBatch(ids: string[], deletedAt: string): { trashedCount: number } {
if (ids.length === 0) return { trashedCount: 0 };
let trashedCount = 0;
const tx = this.db.transaction((batch: string[]) => {
for (const id of batch) {
const row = this.db
.prepare(`SELECT deleted_at FROM notes WHERE id = ?`)
.get(id) as { deleted_at: string | null } | undefined;
if (!row || row.deleted_at !== null) continue;
this.trash(id, deletedAt);
trashedCount += 1;
}
});
tx(ids);
return { trashedCount };
}
restore(id: string): void {
const now = new Date().toISOString();
this.db
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
.run(now, id);
}
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
emptyTrash(): { noteIds: string[] } {
// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
// and avoids per-row prepare overhead. RETURNING is house-style elsewhere
// (updateAiResult/updateUserAiFields/getAllPendingJobs).
const rows = this.db
.prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
.all() as Array<{ id: string }>;
return { noteIds: rows.map((r) => r.id) };
}
listTrashed(opts: { limit: number }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as any[];
return rows.map((r) => this.hydrate(r));
}
/**
* Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
* tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
* follow-ups) where listTrashed() is wasteful.
*/
countTrashed(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
.get() as { c: number };
return row.c;
}
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
delete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
@@ -239,6 +463,10 @@ export class NoteRepository {
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
* insert/fork 는 source 의 deletedAt 그대로 보존.
*/
importNote(input: ImportNoteInput): ImportNoteResult {
const existing = this.findRawTextById(input.id);
@@ -246,6 +474,16 @@ export class NoteRepository {
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
if (input.deletedAt != null) {
const destRow = this.db
.prepare('SELECT deleted_at FROM notes WHERE id=?')
.get(input.id) as { deleted_at: string | null } | undefined;
if (destRow && destRow.deleted_at === null) {
this.trash(input.id, input.deletedAt);
}
}
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
@@ -257,8 +495,8 @@ export class NoteRepository {
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
@@ -271,6 +509,7 @@ export class NoteRepository {
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.deletedAt ?? null,
input.createdAt,
input.updatedAt
);
@@ -297,7 +536,9 @@ export class NoteRepository {
getPendingCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
)
.get() as { c: number };
return row.c;
}
@@ -319,11 +560,36 @@ export class NoteRepository {
const startIso = new Date(kstMidnightUtc).toISOString();
const endIso = new Date(nextKstMidnightUtc).toISOString();
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`)
.prepare(
`SELECT COUNT(*) AS c FROM notes
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
)
.get(startIso, endIso) as { c: number };
return row.c;
}
/**
* Notes whose due_date is strictly before today (KST calendar) and that are
* still active (not trashed) and AI-processed. Includes both AI-extracted and
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
*
* Caller may inject `now` for testability; defaults to `new Date()`.
*/
findExpiredCandidates(now: Date = new Date()): Note[] {
const today = todayInKstString(now);
const rows = this.db
.prepare(
`SELECT * FROM notes
WHERE due_date IS NOT NULL
AND due_date < ?
AND deleted_at IS NULL
AND ai_status = 'done'
ORDER BY created_at DESC, id DESC`
)
.all(today) as any[];
return rows.map((r) => this.hydrate(r));
}
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
const rows = this.db
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
@@ -375,6 +641,9 @@ export class NoteRepository {
intentPromptedAt: row.intent_prompted_at,
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
deletedAt: row.deleted_at ?? null,
lastRecalledAt: row.last_recalled_at ?? null,
recallDismissedAt: row.recall_dismissed_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: tags as NoteTag[],

View File

@@ -1,9 +1,28 @@
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
import type { Note } from '@shared/types';
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
| { kind: 'expired_batch_trash'; payload: { count: number } }
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
| { kind: 'recall_opened'; payload: { noteId: string } }
| { kind: 'recall_dismissed'; payload: { noteId: string } }
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
| { kind: 'recall_snoozed'; payload: { noteId: string } }
): Promise<void>;
}
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
telemetry?: TelemetryEmitter;
}
export interface SubmitInput {
@@ -12,6 +31,8 @@ export interface SubmitInput {
}
export class CaptureService {
private lastExpiredShownSig: string | null = null;
constructor(
private repo: NoteRepository,
private store: MediaStore,
@@ -39,13 +60,188 @@ export class CaptureService {
}
this.repo.insertMedia(rows);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'capture',
payload: {
noteId: id,
rawTextLength: input.text.length,
hasMedia: input.images.length > 0
}
}).catch(() => {});
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
return { noteId: id };
}
async deleteNote(noteId: string): Promise<void> {
this.repo.delete(noteId);
await this.store.deleteNoteDirectory(noteId);
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
// 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt !== null) return;
this.repo.trash(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
async restoreNote(noteId: string): Promise<void> {
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt === null) return;
this.repo.restore(noteId);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
}
async permanentDeleteNote(noteId: string): Promise<void> {
// 존재하지 않는 노트는 emit skip — 메트릭 오염 방지.
const note = this.repo.findById(noteId);
if (!note) return;
this.repo.permanentDelete(noteId);
// best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir
// 은 future janitor 가 정리). emptyTrash 와 동일 패턴.
try { await this.store.deleteNoteDirectory(noteId); }
catch { /* best-effort */ }
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch { /* best-effort */ }
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
}
return { count: noteIds.length };
}
/**
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
*/
async listExpired(now: Date = new Date()): Promise<Note[]> {
const candidates = this.repo.findExpiredCandidates(now);
if (candidates.length === 0) {
// empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit.
// (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식)
this.lastExpiredShownSig = null;
return candidates;
}
const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`;
if (sig !== this.lastExpiredShownSig) {
this.lastExpiredShownSig = sig;
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'expired_banner_shown',
payload: { candidateCount: candidates.length }
}).catch(() => {});
}
}
return candidates;
}
/**
* 만료 후보 일괄 trash. 빈 배열은 즉시 no-op.
* 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 —
* stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계).
*/
async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> {
if (ids.length === 0) return { trashedCount: 0 };
const r = this.repo.trashBatch(ids, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'expired_batch_trash',
payload: { count: r.trashedCount }
}).catch(() => {});
}
return r;
}
/**
* 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset + worker.enqueue 재투입.
* 빈 결과는 telemetry emit 안 함 (failedCount ≥ 1 invariant).
* v0.2.3 #2 retry-all manual trigger.
*/
async retryAllFailed(): Promise<{ count: number }> {
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
for (const id of ids) {
await this.deps.enqueue(id);
}
if (ids.length > 0 && this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'ai_retry_manual',
payload: { failedCount: ids.length }
}).catch(() => {});
}
return { count: ids.length };
}
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();
}
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`);
this.repo.markRecallOpened(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_opened',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */
async dismissRecall(noteId: string): Promise<{ note: Note }> {
this.repo.dismissRecall(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_dismissed',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
/** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */
async emitRecallShown(noteId: string): Promise<void> {
const note = this.repo.findById(noteId);
if (!note) return;
const ageDays = this.computeAgeDays(note);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_shown',
payload: { noteId, ageDays }
}).catch(() => {});
}
}
/** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */
async emitRecallSnoozed(noteId: string): Promise<void> {
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_snoozed',
payload: { noteId }
}).catch(() => {});
}
}
/** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */
private computeAgeDays(note: Note): number {
const ref = note.lastRecalledAt ?? note.createdAt;
const refMs = new Date(ref).getTime();
const nowMs = Date.now();
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
}
}

View File

@@ -32,7 +32,9 @@ export class ContinuityService {
get(): WeeklyContinuity {
const rows = this.db
.prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`)
.prepare(
`SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC`
)
.all() as Array<{ created_at: string }>;
const dates = rows.map((r) => new Date(r.created_at));
if (dates.length === 0) {

View File

@@ -1,12 +1,86 @@
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { ProviderHolder } from '../ai/ProviderHolder.js';
export type HealthTelemetryEvent =
| { kind: 'ollama_unreachable'; reason: string }
| { kind: 'ollama_recovered'; downtimeMs: number }
| { kind: 'ollama_recheck_manual' };
export interface HealthCheckerOptions {
intervalMs?: number;
onUpdate?: (status: HealthResult) => void;
onTelemetry?: (event: HealthTelemetryEvent) => void;
now?: () => number;
}
const DEFAULT_INTERVAL_MS = 60_000;
export class HealthChecker {
// sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op),
// ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0.
private last: HealthResult = { ok: true };
constructor(private provider: InferenceProvider) {}
private timer: NodeJS.Timeout | null = null;
private unreachableSince: number | null = null;
// m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째
// runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선
// 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피.
private inFlight: Promise<HealthResult> | null = null;
private intervalMs: number;
private now: () => number;
async runOnce(): Promise<HealthResult> {
this.last = await this.provider.healthCheck();
return this.last;
constructor(
private holder: ProviderHolder,
private opts: HealthCheckerOptions = {}
) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.now = opts.now ?? Date.now;
}
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
// n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나
// 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장.
if (opts?.manual === true) {
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
}
if (this.inFlight !== null) return this.inFlight;
this.inFlight = this.doRunOnce();
try { return await this.inFlight; }
finally { this.inFlight = null; }
}
private async doRunOnce(): Promise<HealthResult> {
const next = await this.holder.get().healthCheck();
const prev = this.last;
const okChanged = prev.ok !== next.ok;
const reasonChanged = prev.reason !== next.reason;
if (okChanged) {
if (next.ok === false) {
this.unreachableSince = this.now();
this.opts.onTelemetry?.({ kind: 'ollama_unreachable', reason: next.reason ?? 'unknown' });
} else {
const downtimeMs = this.unreachableSince !== null ? this.now() - this.unreachableSince : 0;
this.unreachableSince = null;
this.opts.onTelemetry?.({ kind: 'ollama_recovered', downtimeMs });
}
this.opts.onUpdate?.(next);
} else if (reasonChanged) {
this.opts.onUpdate?.(next);
}
this.last = next;
return next;
}
start(): void {
if (this.timer !== null) return;
void this.runOnce();
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
lastStatus(): HealthResult { return this.last; }

View File

@@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput {
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags
tags: parsed.tags,
deletedAt: parsed.deletedAt
};
}

View File

@@ -6,6 +6,9 @@ export class MediaGc {
async run(): Promise<{ removed: number }> {
const dirs = await this.store.listNoteDirs();
// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own
// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted
// notes here would defeat restore.
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
const known = new Set(rows.map((r) => r.id));
let removed = 0;

View File

@@ -0,0 +1,47 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';
const OllamaSettingsSchema = z.object({
endpoint: z.string().url(),
model: z.string().min(1)
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
export class SettingsService {
private filePath: string;
private cache: Settings | null = null;
constructor(profileDir: string) {
this.filePath = join(profileDir, 'settings.json');
}
async load(): Promise<Settings> {
if (this.cache !== null) return this.cache;
try {
const raw = await readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache = SettingsSchema.parse(parsed);
} catch {
this.cache = {};
}
return this.cache;
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
this.cache = next;
}
}

View File

@@ -0,0 +1,144 @@
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
import { aggregateStats } from './telemetryStats.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
function todayKstIso(now: Date): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
export interface TelemetryServiceOptions {
silent?: boolean;
}
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
| { kind: 'expired_batch_trash'; payload: { count: number } }
| { kind: 'ollama_unreachable'; payload: { reason: string } }
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
| { kind: 'recall_opened'; payload: { noteId: string } }
| { kind: 'recall_dismissed'; payload: { noteId: string } }
| { kind: 'recall_snoozed'; payload: { noteId: string } };
export class TelemetryService {
constructor(
private dir: string,
private now: () => Date = () => new Date(),
private retentionDays: number = 14,
private opts: TelemetryServiceOptions = {}
) {}
async cleanupOldFiles(): Promise<{ removed: string[] }> {
const removed: string[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return { removed };
}
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
for (const name of entries) {
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
if (!m) continue;
const fileDate = m[1]!;
if (fileDate < cutoffIso) {
try {
await unlink(join(this.dir, name));
removed.push(name);
} catch {
// ignore — best-effort cleanup
}
}
}
return { removed };
}
async emit(input: EmitInput): Promise<void> {
// 회차 1 review (PR #13) — `now()` 한 번만 호출. KST 자정 경계에서 ts 와 파일명 일자가
// 어긋나는 것을 방지.
const nowDate = this.now();
const ts = nowDate.toISOString();
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
try {
await mkdir(this.dir, { recursive: true });
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
} catch (err) {
if (this.opts.silent) return;
throw err;
}
}
async readAllRecent(): Promise<TelemetryEvent[]> {
const events: TelemetryEvent[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return events;
}
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
const cutoffIso = todayKstIso(new Date(cutoffMs));
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
const fileNames = entries
.filter((n) => {
const m = datePattern.exec(n);
return m !== null && m[1]! >= cutoffIso;
})
.sort();
for (const name of fileNames) {
let raw: string;
try {
raw = await readFile(join(this.dir, name), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
try {
events.push(validateEvent(parsed));
} catch {
continue;
}
}
}
return events;
}
async exportTo(outDir: string): Promise<{ eventCount: number }> {
const events = await this.readAllRecent();
await mkdir(outDir, { recursive: true });
const eventsContent = events.map((e) => JSON.stringify(e)).join('\n') + (events.length > 0 ? '\n' : '');
await writeFile(join(outDir, 'events.jsonl'), eventsContent, 'utf8');
const stats = aggregateStats(events, this.now());
await writeFile(join(outDir, 'stats.md'), stats.md, 'utf8');
return { eventCount: stats.eventCount };
}
}

View File

@@ -33,6 +33,7 @@ export interface ParsedNote {
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
deletedAt: string | null; // 신규 v0.2.3 #4
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
@@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote {
aiGeneratedAt: get('ai_generated_at'),
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
deletedAt: get('deleted_at'),
tags: fm.tags,
images: fm.images,
exportVersion

View File

@@ -0,0 +1,94 @@
import { z } from 'zod';
const CapturePayload = z.object({
noteId: z.string().min(1),
rawTextLength: z.number().int().nonnegative(),
hasMedia: z.boolean()
}).strict();
const AiSucceededPayload = z.object({
noteId: z.string().min(1),
durationMs: z.number().nonnegative(),
attempts: z.number().int().nonnegative()
}).strict();
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
const AiFailedPayload = z.object({
noteId: z.string().min(1),
reason: AiFailedReason,
attempts: z.number().int().nonnegative()
}).strict();
const NoteIdPayload = z.object({
noteId: z.string().min(1)
}).strict();
const EmptyTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
const ExpiredBannerShownPayload = z.object({
candidateCount: z.number().int().nonnegative()
}).strict();
const ExpiredBatchTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
const OllamaUnreachablePayload = z.object({
reason: z.string().min(1).max(500)
}).strict();
const OllamaRecoveredPayload = z.object({
downtimeMs: z.number().nonnegative()
}).strict();
const EmptyPayload = z.object({}).strict();
const AiRetryManualPayload = z.object({
failedCount: z.number().int().positive()
}).strict();
const TagVocabHitPayload = z.object({
tagId: z.number().int().positive(),
vocabSize: z.number().int().nonnegative()
}).strict();
const TagVocabMissPayload = z.object({
vocabSize: z.number().int().nonnegative()
}).strict();
const RecallShownPayload = z.object({
noteId: z.string().min(1),
ageDays: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}

View File

@@ -0,0 +1,191 @@
import type { TelemetryEvent } from './telemetryEvents.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function kstDate(ts: string): string {
const d = new Date(ts);
const k = new Date(d.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
interface DailyRow {
date: string;
capture: number;
ai_succeeded: number;
ai_failed: number;
trash: number;
restore: number;
permanent_delete: number;
empty_trash: number;
expired_banner_shown: number;
expired_batch_trash: number;
ollama_unreachable: number;
ollama_recovered: number;
ollama_recheck_manual: number;
ai_retry_manual: number;
tag_vocab_hit: number;
tag_vocab_miss: number;
recall_shown: number;
recall_opened: number;
recall_dismissed: number;
recall_snoozed: number;
}
export interface StatsResult {
md: string;
eventCount: number;
}
export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
const eventCount = events.length;
const byDay = new Map<string, DailyRow>();
let aiSucceeded = 0;
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
let trashCount = 0;
let restoreCount = 0;
let expiredBannerShownCandidatesSum = 0;
let expiredBatchTrashCountSum = 0;
let ollamaDowntimeSum = 0;
let ollamaRecoveredCount = 0;
let ollamaRecheckManualCount = 0;
let aiRetryManualCount = 0;
let aiRetryManualFailedSum = 0;
let tagVocabHitCount = 0;
let tagVocabMissCount = 0;
let recallShownCount = 0;
let recallOpenedCount = 0;
let recallDismissedCount = 0;
let recallSnoozedCount = 0;
let recallAgeDaysSum = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = {
date: day,
capture: 0, ai_succeeded: 0, ai_failed: 0,
trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0,
expired_banner_shown: 0, expired_batch_trash: 0,
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
ai_retry_manual: 0,
tag_vocab_hit: 0, tag_vocab_miss: 0,
recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0
};
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
else if (ev.kind === 'ai_succeeded') {
row.ai_succeeded += 1;
aiSucceeded += 1;
durationSum += ev.payload.durationMs;
durationN += 1;
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
} else if (ev.kind === 'trash') {
row.trash += 1;
trashCount += 1;
} else if (ev.kind === 'restore') {
row.restore += 1;
restoreCount += 1;
} else if (ev.kind === 'permanent_delete') {
row.permanent_delete += 1;
} else if (ev.kind === 'empty_trash') {
row.empty_trash += 1;
} else if (ev.kind === 'expired_banner_shown') {
row.expired_banner_shown += 1;
expiredBannerShownCandidatesSum += ev.payload.candidateCount;
} else if (ev.kind === 'expired_batch_trash') {
row.expired_batch_trash += 1;
expiredBatchTrashCountSum += ev.payload.count;
} else if (ev.kind === 'ollama_unreachable') {
row.ollama_unreachable += 1;
} else if (ev.kind === 'ollama_recovered') {
row.ollama_recovered += 1;
ollamaDowntimeSum += ev.payload.downtimeMs;
ollamaRecoveredCount += 1;
} else if (ev.kind === 'ollama_recheck_manual') {
row.ollama_recheck_manual += 1;
ollamaRecheckManualCount += 1;
} else if (ev.kind === 'ai_retry_manual') {
row.ai_retry_manual += 1;
aiRetryManualCount += 1;
aiRetryManualFailedSum += ev.payload.failedCount;
} else if (ev.kind === 'tag_vocab_hit') {
row.tag_vocab_hit += 1;
tagVocabHitCount += 1;
} else if (ev.kind === 'tag_vocab_miss') {
row.tag_vocab_miss += 1;
tagVocabMissCount += 1;
} else if (ev.kind === 'recall_shown') {
row.recall_shown += 1;
recallShownCount += 1;
recallAgeDaysSum += ev.payload.ageDays;
} else if (ev.kind === 'recall_opened') {
row.recall_opened += 1;
recallOpenedCount += 1;
} else if (ev.kind === 'recall_dismissed') {
row.recall_dismissed += 1;
recallDismissedCount += 1;
} else if (ev.kind === 'recall_snoozed') {
row.recall_snoozed += 1;
recallSnoozedCount += 1;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
const trashRecoveryRate = trashCount === 0
? 'N/A'
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
const expiredTrashRatio = expiredBannerShownCandidatesSum === 0
? 'N/A'
: `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`;
const avgDowntime = ollamaRecoveredCount === 0
? 'N/A'
: `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`;
const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0);
const tagVocabTotal = tagVocabHitCount + tagVocabMissCount;
const tagVocabSummary = tagVocabTotal === 0
? '(데이터 없음)'
: `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`;
const recallSummary = recallShownCount === 0
? '(데이터 없음)'
: `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`;
const recallAvgAge = recallShownCount === 0
? '(데이터 없음)'
: `${Math.round(recallAgeDaysSum / recallShownCount)}`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
lines.push(`생성: ${generatedAt.toISOString()}`);
lines.push(`총 이벤트: ${eventCount}`);
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |');
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`);
lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}`);
lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`);
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}`);
lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}`);
lines.push(`- 태그 vocab: ${tagVocabSummary}`);
lines.push(`- 회상 추천: ${recallSummary}`);
lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}

View File

@@ -1,6 +1,36 @@
import electron from 'electron';
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
const { app, Tray, Menu, nativeImage } = electron;
import { platform, release, EOL } from 'node:os';
const { app, Tray, Menu, nativeImage, dialog, shell, clipboard } = electron;
function showAboutDialog(): void {
const version = app.getVersion();
const electronVersion = process.versions.electron ?? '?';
const nodeVersion = process.versions.node ?? '?';
const profileDir = app.getPath('userData');
// OS EOL 사용 — 클립보드 → Notepad 등에서 줄바꿈 정상.
const detail = [
`버전: ${version}`,
`Electron: ${electronVersion}`,
`Node: ${nodeVersion}`,
`OS: ${platform()} ${release()}`,
`데이터 위치: ${profileDir}`
].join(EOL);
void dialog.showMessageBox({
type: 'info',
title: 'Inkling 정보',
message: `Inkling ${version}`,
detail,
buttons: ['확인', '데이터 위치 열기', '정보 복사'],
defaultId: 0,
cancelId: 0
}).then((r) => {
if (r.response === 1) void shell.openPath(profileDir);
if (r.response === 2) clipboard.writeText(`Inkling ${version}${EOL}${detail}`);
}).catch(() => {
// dialog reject 는 일반 사용에서 발생 X — main process crash 등 예외 케이스 silent.
});
}
let tray: TrayType | null = null;
let _showInbox: () => void = () => {};
@@ -9,7 +39,13 @@ let _runBackup: () => void = () => {};
let _runExport: () => void = () => {};
let _runImport: () => void = () => {};
let _runSync: () => void = () => {};
let _runExportTelemetry: () => void = () => {};
let _runOllamaRecheck: () => void = () => {};
let _ollamaOk = true;
let _todayCount = 0;
let _runRetryAllFailed: () => void = () => {};
let _failedCount = 0;
let _runOpenOllamaSettings: () => void = () => {};
function buildMenu() {
const items: MenuItemConstructorOptions[] = [];
@@ -25,6 +61,18 @@ function buildMenu() {
items.push({ label: '내보내기...', click: _runExport });
items.push({ label: '백업에서 복원...', click: _runImport });
items.push({ label: '지금 동기화', click: _runSync });
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
items.push({
label: 'Ollama 재확인',
enabled: !_ollamaOk,
click: _runOllamaRecheck
});
items.push({
label: `지금 AI 처리 (실패 ${_failedCount}건)`,
enabled: _failedCount > 0,
click: _runRetryAllFailed
});
items.push({ label: 'Ollama 설정...', click: () => _runOpenOllamaSettings() });
if (app.isPackaged) {
const { openAtLogin } = app.getLoginItemSettings();
items.push({
@@ -42,6 +90,7 @@ function buildMenu() {
} else {
items.push({ type: 'separator' });
}
items.push({ label: 'Inkling 정보...', click: showAboutDialog });
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
return Menu.buildFromTemplate(items);
}
@@ -52,7 +101,11 @@ export function createTray(
runBackup: () => void,
runExport: () => void,
runImport: () => void,
runSync: () => void
runSync: () => void,
runExportTelemetry: () => void,
runOllamaRecheck: () => void,
runRetryAllFailed: () => void,
runOpenOllamaSettings: () => void
): TrayType {
_showInbox = showInbox;
_showCapture = showCapture;
@@ -60,6 +113,10 @@ export function createTray(
_runExport = runExport;
_runImport = runImport;
_runSync = runSync;
_runExportTelemetry = runExportTelemetry;
_runOllamaRecheck = runOllamaRecheck;
_runRetryAllFailed = runRetryAllFailed;
_runOpenOllamaSettings = runOpenOllamaSettings;
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
@@ -78,3 +135,22 @@ export function refreshTray(todayCount: number): void {
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
tray.setContextMenu(buildMenu());
}
/**
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
*/
export function refreshTrayOllama(ok: boolean): void {
_ollamaOk = ok;
if (tray === null) return;
tray.setContextMenu(buildMenu());
}
/**
* v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신.
*/
export function refreshTrayFailedCount(count: number): void {
_failedCount = count;
if (tray === null) return;
tray.setContextMenu(buildMenu());
}

28
src/main/util/kstDate.ts Normal file
View File

@@ -0,0 +1,28 @@
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
*
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
*/
export function todayInKstString(now: Date): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
).toISOString().slice(0, 10);
}
/**
* Epoch ms of the next 00:00 KST strictly after `now`.
*
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
* deadline ("오늘 그만").
*/
export function nextKstMidnightMs(now: number): number {
const kstNow = now + KST_OFFSET_MS;
// Floor to KST midnight, then add one day.
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
return nextKstMidnight - KST_OFFSET_MS;
}

View File

@@ -19,11 +19,39 @@ const api: InklingApi = {
getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'),
// 신규 v0.2.3 #4:
restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
onNoteUpdated: (cb) => {
const listener = (_e: unknown, note: Note) => cb(note);
ipcRenderer.on('note:updated', listener);
return () => ipcRenderer.off('note:updated', listener);
}
},
onOllamaStatus: (cb) => {
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
ipcRenderer.on('ollama:status', listener);
return () => ipcRenderer.off('ollama:status', listener);
},
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
onOpenOllamaSettings: (cb: () => void) => {
const handler = () => cb();
ipcRenderer.on('inbox:openOllamaSettings', handler);
return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
},
}
};

View File

@@ -9,96 +9,158 @@ import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';
import { ExpiryBanner } from './components/ExpiryBanner.js';
import { FailedBanner } from './components/FailedBanner.js';
import { RecallBanner } from './components/RecallBanner.js';
import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';
export function App(): React.ReactElement {
const {
notes,
loading,
loadInitial,
refreshMeta,
upsertNote,
removeNote,
continuity,
tagFilter,
setTagFilter
notes, trashNotes, trashCount, showTrash,
loading, loadInitial, refreshMeta, upsertNote, removeNote,
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);
useEffect(() => {
void loadInitial();
const unsub = inboxApi.onNoteUpdated((note) => {
const unsubNote = inboxApi.onNoteUpdated((note) => {
upsertNote(note);
void refreshMeta();
});
const unsubOllama = inboxApi.onOllamaStatus((status) => {
useInbox.setState({ ollamaStatus: status });
});
const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsub(); window.removeEventListener('focus', onFocus); };
return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); };
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
}, [loadInitial, refreshMeta, upsertNote]);
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
color: active ? '#fff' : '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
});
return (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
<button
onClick={() => { if (showTrash) void toggleShowTrash(); }}
aria-pressed={!showTrash}
style={tabBtnStyle(!showTrash)}
>
Inbox({notes.length})
</button>
<button
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
aria-pressed={showTrash}
style={tabBtnStyle(showTrash)}
>
({trashCount})
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</div>
</div>
<main className="main">
<OllamaBanner />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
{tagFilter !== null && (
<div
style={{
background: '#eaf3ff',
color: '#0a4b80',
padding: '6px 12px',
borderRadius: 6,
margin: '8px 0',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
<span>🔎 : <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length})</span>
<button
onClick={() => setTagFilter(null)}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
color: '#0a4b80',
cursor: 'pointer',
fontSize: 12
}}
title="필터 해제"
>
</button>
</div>
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
borderRadius: 6, margin: '8px 0', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span>🔎 : <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length})</span>
<button
onClick={() => setTagFilter(null)}
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
title="필터 해제"
>
</button>
</div>
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
onUpdated={(u) => upsertNote(u)}
/>
))
)}
</>
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
))
{showTrash && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
<div style={{ fontSize: 13, color: '#666' }}>
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
</div>
<button
onClick={() => void emptyTrash()}
disabled={trashCount === 0}
style={{
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
border: 'none', borderRadius: 4, padding: '4px 10px',
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
}}
>
({trashCount})
</button>
</div>
{trashNotes.length === 0 ? null : (
trashNotes.map((n) => (
<NoteCard
key={n.id} note={n} mode="trash"
onUpdated={(u) => upsertNote(u)}
onRestore={() => void restoreNote(n.id)}
onPermanentDelete={() => void permanentDeleteNote(n.id)}
/>
))
)}
</>
)}
</main>
<TagUndoToast />
<OllamaSettingsModal
open={ollamaSettingsOpen}
onClose={() => setOllamaSettingsOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import type { Note } from '@shared/types';
import { useInbox } from '../store.js';
export function ExpiryBanner(): React.ReactElement | null {
const candidates = useInbox((s) => s.expiredCandidates);
const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs);
const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch);
const snoozeExpired = useInbox((s) => s.snoozeExpired);
// n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거.
// 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장.
const [, setTick] = useState(0);
useEffect(() => {
if (snoozeUntilMs === null) return;
const remaining = snoozeUntilMs - Date.now();
if (remaining <= 0) return;
const t = setTimeout(() => setTick((n) => n + 1), remaining);
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// Q5=A: 0건 / snooze 활성 시 collapse
if (candidates.length === 0) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
return <ExpiryBannerInner
candidates={candidates}
onTrash={(ids) => {
trashExpiredBatch(ids).catch((e) => {
// eslint-disable-next-line no-console
console.warn('trashExpiredBatch failed', e);
});
}}
onSnooze={() => snoozeExpired()}
/>;
}
interface InnerProps {
candidates: Note[];
onTrash: (ids: string[]) => void;
onSnooze: () => void;
}
function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement {
const [expanded, setExpanded] = useState<boolean>(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
// candidates 가 변하면 selected 의 stale id 정리
useEffect(() => {
const valid = new Set(candidates.map((c) => c.id));
setSelected((prev) => {
const next = new Set<string>();
for (const id of prev) if (valid.has(id)) next.add(id);
return next;
});
}, [candidates]);
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
const someSelected = selected.size > 0 && !allSelected;
function toggleAll() {
if (allSelected) setSelected(new Set());
else setSelected(new Set(candidates.map((c) => c.id)));
}
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div style={{
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<button
onClick={() => setExpanded((e) => !e)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
aria-expanded={expanded}
>
{expanded ? '▲ 접기' : '▼ 펼치기'}
</button>
<button
onClick={onSnooze}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#946100',
border: '1px solid #d99500', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
{expanded && (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '8px 0 4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={allSelected}
ref={(el) => { if (el) el.indeterminate = someSelected; }}
onChange={toggleAll}
/>
<span style={{ color: '#666' }}> ({selected.size}/{candidates.length})</span>
</label>
<div>
{candidates.map((n) => (
<label
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0', cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.aiTitle ?? n.rawText.slice(0, 60)}
</span>
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
</span>
)}
</label>
))}
</div>
<button
onClick={() => onTrash(Array.from(selected))}
disabled={selected.size === 0}
style={{
marginTop: 8,
background: selected.size === 0 ? '#999' : '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12,
cursor: selected.size === 0 ? 'not-allowed' : 'pointer'
}}
>
({selected.size})
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { useInbox } from '../store.js';
export function FailedBanner(): React.ReactElement | null {
const count = useInbox((s) => s.failedCount);
const retryAllFailed = useInbox((s) => s.retryAllFailed);
if (count === 0) return null;
return (
<div style={{
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button
onClick={() => {
retryAllFailed().catch((e) => {
// eslint-disable-next-line no-console
console.warn('retryAllFailed failed', e);
});
}}
style={{
background: '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
);
}

View File

@@ -8,8 +8,11 @@ import { pushTagUndo } from './TagUndoToast.js';
interface Props {
note: Note;
onDeleted: () => void;
onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용)
onUpdated: (n: Note) => void;
mode?: 'inbox' | 'trash'; // default 'inbox'
onRestore?: () => void;
onPermanentDelete?: () => void;
}
const aiBadgeStyle: React.CSSProperties = {
@@ -104,7 +107,8 @@ function DueDateBadge({
);
}
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash';
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
@@ -115,7 +119,7 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
async function handleDelete() {
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
await inboxApi.deleteNote(note.id);
onDeleted();
onDeleted?.();
}
async function saveTitle(next: string) {
@@ -180,10 +184,11 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
return (
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{showIntentBanner && (
{!isTrash && showIntentBanner && (
<IntentBanner
noteId={note.id}
onResolved={(intentText) => {
@@ -206,86 +211,122 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
)}
{local.aiStatus === 'done' && (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
{isTrash ? (
<>
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
{local.aiSummary ?? '(요약 없음)'}
</div>
{local.dueDate !== null && (
<div style={{ marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
</div>
)}
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12
}}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
)}
</>
)}
</>
)}
@@ -310,9 +351,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
)}
</div>
</div>
);

View File

@@ -1,8 +1,13 @@
import React from 'react';
import { useInbox } from '../store.js';
export function OllamaBanner(): React.ReactElement | null {
interface OllamaBannerProps {
onOpenSettings?: () => void;
}
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
const status = useInbox((s) => s.ollamaStatus);
const recheckOllama = useInbox((s) => s.recheckOllama);
if (status.ok) return null;
const isMissing = status.reason?.includes('not installed');
const message = isMissing
@@ -10,7 +15,37 @@ export function OllamaBanner(): React.ReactElement | null {
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
return (
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span> {message}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<span style={{ flex: 1 }}> {message}</span>
<button
onClick={() => {
recheckOllama().catch((e) => {
// eslint-disable-next-line no-console
console.warn('recheckOllama failed', e);
});
}}
style={{
background: 'transparent', color: '#946100',
border: '1px solid #d99500', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
{onOpenSettings && (
<button
onClick={onOpenSettings}
style={{
background: 'transparent', color: 'inherit',
border: '1px solid currentColor', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer',
marginLeft: 6
}}
>
</button>
)}
</div>
{status.reason ? (
<span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
: {status.reason}

View File

@@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../api.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
interface Props {
open: boolean;
onClose: () => void;
}
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 마운트/open 시 현재 설정 fetch
useEffect(() => {
if (!open) return;
void inboxApi.loadOllamaSettings().then((s) => {
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
setError(null);
});
}, [open]);
if (!open) return null;
async function handleSave() {
if (saving) return; // m4 fix: synchronous double-click 가드
setSaving(true);
setError(null);
try {
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
if (r.ok) {
onClose();
} else {
setError(r.reason);
}
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<div
onKeyDown={(e) => {
if (e.key === 'Escape' && !saving) onClose();
if (e.key === 'Enter' && !saving) void handleSave();
}}
tabIndex={-1}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}
>
<div style={{
background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
}}>
<h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama </h2>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Endpoint URL
</label>
<input
type="text"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
autoFocus
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Model
</label>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemma4:e4b"
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
{error && (
<div style={{
background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
fontSize: 12, marginBottom: 12
}}>
: {error}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onClose}
disabled={saving}
style={{
background: 'transparent', color: '#666',
border: '1px solid #ccc', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
style={{
background: saving ? '#999' : '#0a4b80', color: '#fff',
border: 'none', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
{saving ? '검증 중...' : '저장'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';
export function RecallBanner(): React.ReactElement | null {
const candidate = useInbox((s) => s.recallCandidate);
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
const openRecall = useInbox((s) => s.openRecall);
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
const snoozeRecall = useInbox((s) => s.snoozeRecall);
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
const shownIdsRef = useRef<Set<string>>(new Set());
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
const [, setTick] = useState(0);
useEffect(() => {
if (snoozeUntilMs === null) return;
const remaining = snoozeUntilMs - Date.now();
if (remaining <= 0) return;
const t = setTimeout(() => setTick((n) => n + 1), remaining);
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
useEffect(() => {
if (!candidate) return;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
if (shownIdsRef.current.has(candidate.id)) return;
void inboxApi.emitRecallShown(candidate.id);
shownIdsRef.current.add(candidate.id);
}, [candidate, snoozeUntilMs]);
if (candidate === null) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
function onOpen() {
void openRecall(candidate!.id);
const el = document.getElementById(`note-${candidate!.id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return (
<div style={{
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>💭 <b> </b></span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
{title}
</span>
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays} </span>
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
onClick={onOpen}
style={{
background: '#4a7ec0', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void snoozeRecall()}
style={{
background: 'transparent', color: '#4a7ec0',
border: '1px solid #4a7ec0', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void dismissRecallNote(candidate.id)}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#888',
border: 'none', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
</div>
);
}
function computeAgeDays(refIso: string): number {
const refMs = new Date(refIso).getTime();
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
}

View File

@@ -6,17 +6,39 @@ export { selectFilteredNotes } from './selectFilteredNotes.js';
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
todayCount: number;
loading: boolean;
tagFilter: string | null;
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null;
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
recheckOllama: () => Promise<void>;
retryAllFailed: () => Promise<void>;
loadRecallCandidate: () => Promise<void>;
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>;
snoozeRecall: () => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -26,46 +48,181 @@ const emptyContinuity: WeeklyContinuity = {
export const useInbox = create<InboxState>((set, get) => ({
notes: [],
trashNotes: [],
trashCount: 0,
showTrash: false,
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
todayCount: 0,
loading: false,
tagFilter: null,
expiredCandidates: [],
expiredSnoozeUntilMs: null,
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount });
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
},
upsertNote(note) {
const i = get().notes.findIndex((n) => n.id === note.id);
if (i >= 0) {
const next = get().notes.slice();
next[i] = note;
set({ notes: next });
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
const showTrash = get().showTrash;
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
set({ notes: [note, ...get().notes] });
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({
notes: nextNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
}
},
removeNote(id) {
set({ notes: get().notes.filter((n) => n.id !== id) });
const cleanNotes = get().notes.filter((n) => n.id !== id);
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
const showTrash = get().showTrash;
set({
notes: cleanNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
},
setTagFilter(tag) {
set({ tagFilter: tag });
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
if (next) await get().loadTrash();
},
async loadTrash() {
const trashNotes = await inboxApi.listTrash({ limit: 200 });
set({ trashNotes, trashCount: trashNotes.length });
},
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
}
},
async permanentDeleteNote(id) {
const r = await inboxApi.permanentDeleteNote(id);
if (r.confirmed) get().removeNote(id);
},
async emptyTrash() {
const r = await inboxApi.emptyTrash();
if (r.confirmed) {
set({ trashNotes: [], trashCount: 0 });
}
},
async loadExpired() {
const expiredCandidates = await inboxApi.listExpired();
set({ expiredCandidates });
},
async trashExpiredBatch(ids: string[]) {
const r = await inboxApi.trashExpiredBatch(ids);
if (!r.confirmed) return;
const idSet = new Set(ids);
set({
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
notes: get().notes.filter((n) => !idSet.has(n.id)),
trashCount: get().trashCount + r.trashedCount
});
},
snoozeExpired() {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const now = Date.now();
const kstNow = now + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
},
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
},
async retryAllFailed() {
await inboxApi.retryAllFailed();
// 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출.
// refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer).
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
set({ failedCount: 0 });
},
async loadRecallCandidate() {
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async openRecall(id) {
await inboxApi.markRecallOpened(id);
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async dismissRecallNote(id) {
await inboxApi.dismissRecall(id);
const recallCandidate = await inboxApi.listRecallCandidate();
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
set({ recallCandidate, recallSnoozeUntilMs: null });
},
async snoozeRecall() {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const now = Date.now();
const kstNow = now + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
}
}
}));

2
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DEFAULT_OLLAMA_MODEL = 'gemma4:e4b';
export const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';

View File

@@ -33,6 +33,10 @@ export interface Note {
intentPromptedAt: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
// 신규 v3:
deletedAt: string | null;
lastRecalledAt: string | null;
recallDismissedAt: string | null;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
@@ -67,7 +71,27 @@ export interface InboxApi {
getPendingCount(): Promise<number>;
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
getTodayCount(): Promise<number>;
// 신규 v0.2.3 #4:
restoreNote(noteId: string): Promise<void>;
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>;
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
listTrash(opts: { limit: number }): Promise<Note[]>;
getTrashCount(): Promise<number>;
listExpired(): Promise<Note[]>;
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
onNoteUpdated(cb: (note: Note) => void): () => void;
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise<number>;
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
onOpenOllamaSettings(cb: () => void): () => void;
}
export interface InklingApi {

View File

@@ -3,8 +3,12 @@ import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
type EmittedEvent = { kind: string; payload: unknown };
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
return {
@@ -30,7 +34,7 @@ describe('AiWorker', () => {
it('processes a pending job and marks done', async () => {
const { id } = repo.create({ rawText: 'x' });
const updates: string[] = [];
const w = new AiWorker(repo, makeProvider(), {
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 0, 0],
onUpdate: (note) => updates.push(note.aiStatus)
});
@@ -45,7 +49,7 @@ describe('AiWorker', () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('boom'); })
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
@@ -57,7 +61,7 @@ describe('AiWorker', () => {
it('loadFromDb re-queues all pending', async () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(repo.findById(a)?.aiStatus).toBe('done');
@@ -76,7 +80,7 @@ describe('AiWorker', () => {
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
})
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
for (const id of ids) await w.enqueue(id);
await w.drain();
expect(max).toBe(1);
@@ -93,7 +97,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -115,7 +119,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -137,7 +141,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -159,7 +163,7 @@ describe('AiWorker', () => {
},
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
});
@@ -181,7 +185,7 @@ describe('AiWorker', () => {
},
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -193,3 +197,365 @@ describe('AiWorker', () => {
expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
});
});
describe('AiWorker telemetry emit', () => {
let db: Database.Database;
let repo: NoteRepository;
let events: Array<{ kind: string; payload: { noteId?: string; durationMs?: number; reason?: string; attempts?: number; tagId?: number; vocabSize?: number } }>;
const collectingTelemetry: AiTelemetryEmitter = {
emit: async (ev) => {
events.push(ev as typeof events[number]);
}
};
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
events = [];
});
it('emits ai_succeeded with durationMs/attempts on success', async () => {
const { id } = repo.create({ rawText: '수요일 회의 메모' });
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const succeeded = events.find((e) => e.kind === 'ai_succeeded');
expect(succeeded).toBeDefined();
expect(succeeded!.payload.noteId).toBe(id);
// attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
// 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
expect(succeeded!.payload.attempts).toBe(1);
expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0);
});
it('unreachable error — ai_failed NOT emitted (infinite retry, no markAiFailed)', async () => {
const { id } = repo.create({ rawText: '메모' });
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await new Promise((r) => setTimeout(r, 200));
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeUndefined();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
it('emits ai_failed with reason=schema on zod failure', async () => {
const { id } = repo.create({ rawText: '메모' });
const { ZodError } = await import('zod');
const provider = makeProvider({
generate: vi.fn(async () => { throw new ZodError([]); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed!.payload.reason).toBe('schema');
});
it('emits ai_failed with reason=other on unrecognized error', async () => {
const { id } = repo.create({ rawText: '메모' });
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('mystery'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed!.payload.reason).toBe('other');
});
});
describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
const { id } = repo.create({ rawText: 'x' });
// 먼저 trash — pending_jobs cleanup 됨
repo.trash(id, '2026-05-01T12:00:00.000Z');
// 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
const generate = vi.fn();
const provider = makeProvider({ generate: generate as any });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(generate).not.toHaveBeenCalled();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
});
describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
// 무한 retry — drain() 은 끝나지 않음. 짧게 대기 후 검증.
await new Promise((r) => setTimeout(r, 200));
expect(repo.findById(id)!.aiStatus).toBe('pending');
expect(provider.generate).toHaveBeenCalled();
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
expect(job.attempts).toBe(0);
});
it('timeout — unreachable 동일 (Q2=A)', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('Request timeout'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await new Promise((r) => setTimeout(r, 200));
expect(repo.findById(id)!.aiStatus).toBe('pending');
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('schema fail max 3 — markAiFailed + ai_failed emit (reason=schema)', async () => {
const { ZodError } = await import('zod');
const provider = makeProvider({
generate: vi.fn(async () => {
throw new ZodError([{ code: 'custom', message: 'bad', path: [] } as any]);
})
});
const events: any[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: async (e) => { events.push(e); } }
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('failed');
expect((provider.generate as any).mock.calls.length).toBe(3);
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed.payload.reason).toBe('schema');
});
it('other fail max 3 — markAiFailed + ai_failed emit (reason=other)', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('something weird'); })
});
const events: any[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: async (e) => { events.push(e); } }
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('failed');
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed.payload.reason).toBe('other');
});
it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => {
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]
});
expect((w as any).nextBackoffMs(0)).toBe(30_000);
expect((w as any).nextBackoffMs(2)).toBe(120_000);
expect((w as any).nextBackoffMs(5)).toBe(900_000);
expect((w as any).nextBackoffMs(10)).toBe(900_000); // cap
});
it('success 후 unreachableBackoffStep reset', async () => {
let callCount = 0;
const provider = makeProvider({
generate: vi.fn(async (): Promise<AiResponse> => {
callCount += 1;
if (callCount <= 2) throw new Error('ECONNREFUSED');
return { title: 't', summary: 's', tags: [], dueDate: null };
})
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('done');
expect(callCount).toBe(3);
expect((w as any).unreachableBackoffStep).toBe(0);
});
});
describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('fetches vocab and passes to provider.generate', async () => {
// Pre-seed 1 note with tag 'design' so vocab non-empty
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const generateMock = vi.fn(async () => ({
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
}));
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
backoffsMs: [0, 0, 0]
});
await w.enqueue(id);
await w.drain();
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
vocab: expect.arrayContaining(['design'])
}));
});
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
// Pre-seed: 'design' in vocab
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'newtag'], // 1 hit + 1 miss
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: {
emit: vi.fn(async (input) => { emits.push(input); })
}
});
await w.enqueue(id);
await w.drain();
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(hit).toHaveLength(1);
expect(miss).toHaveLength(1);
const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number };
const missPayload = miss[0]!.payload as { vocabSize: number };
expect(hitPayload.tagId).toBeGreaterThan(0);
expect(hitPayload.vocabSize).toBe(1);
expect(missPayload.vocabSize).toBe(1);
});
it('all tags miss when vocab is empty', async () => {
// No seed → vocab=[]
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(miss).toHaveLength(3);
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0);
});
it('emits one event per tag (3 tags → 3 events)', async () => {
// Pre-seed: all 3 in vocab
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
const hits = emits.filter((e) => e.kind === 'tag_vocab_hit');
expect(hits).toHaveLength(3);
});
it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => {
// Pre-seed: 'design' in vocab
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup)
expect(miss).toHaveLength(1); // 'meeting' 1 miss
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
@@ -51,10 +51,390 @@ describe('CaptureService', () => {
expect(celebrated).toHaveLength(0);
});
it('deleteNote removes db row + media dir', async () => {
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
const img = new Uint8Array([0, 1, 2, 3]).buffer;
const { noteId } = await svc.submit({ text: 't', images: [img] });
await svc.deleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
});
});
describe('CaptureService telemetry emit', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
events = [];
});
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
await svc.submit({ text: '안녕하세요', images: [] });
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('capture');
expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length);
expect(events[0]!.payload.hasMedia).toBe(false);
expect(typeof events[0]!.payload.noteId).toBe('string');
});
it('emits hasMedia=true when images present', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
await svc.submit({ text: '이미지 메모', images: [img] });
expect(events).toHaveLength(1);
expect(events[0]!.payload.hasMedia).toBe(true);
});
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {}
});
const result = await svc.submit({ text: 'no telem', images: [] });
expect(typeof result.noteId).toBe('string');
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
});
});
describe('CaptureService trash flow (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: any }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
store = new MediaStore(tmp);
events = [];
});
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0; // clear capture event
await svc.deleteNote(noteId);
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('trash');
expect(events[0]!.payload.noteId).toBe(noteId);
// media 디렉터리 보존 확인 (restore 시 필요)
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
});
it('restoreNote clears deleted_at and emits restore event', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [] });
events.length = 0;
await svc.deleteNote(noteId);
events.length = 0;
await svc.restoreNote(noteId);
expect(repo.findById(noteId)!.deletedAt).toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('restore');
});
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0;
await svc.permanentDeleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('permanent_delete');
});
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
await svc.submit({ text: 'c (active)', images: [] });
await svc.deleteNote(a);
await svc.deleteNote(b);
events.length = 0;
const r = await svc.emptyTrash();
expect(r.count).toBe(2);
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).toBeNull();
expect(existsSync(join(tmp, 'media', a))).toBe(false);
expect(existsSync(join(tmp, 'media', b))).toBe(false);
const empty = events.find((e) => e.kind === 'empty_trash')!;
expect(empty.payload.count).toBe(2);
});
it('emptyTrash returns count=0 when trash empty', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const r = await svc.emptyTrash();
expect(r.count).toBe(0);
});
});
describe('CaptureService.listExpired (dedup signature)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let svc: CaptureService;
function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void {
db.prepare(
`INSERT INTO notes
(id, raw_text, ai_status, due_date, created_at, updated_at)
VALUES (?, ?, 'done', ?, ?, ?)`
).run(id, id, dueDate, createdAt, createdAt);
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('emits expired_banner_shown on first call when candidates > 0', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
expect(r).toHaveLength(2);
expect(calls).toContainEqual(
expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } })
);
});
it('does NOT re-emit on second call with identical candidate set (dedup)', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
expect(showns).toHaveLength(1);
});
it('re-emits when candidate set changes (count or first-3-ids)', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
expect(showns).toHaveLength(2);
expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 });
});
it('does NOT emit when candidates is empty', async () => {
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
expect(r).toEqual([]);
expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]);
});
});
describe('CaptureService.trashExpiredBatch', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let svc: CaptureService;
function addExpired(id: string, dueDate: string): void {
db.prepare(
`INSERT INTO notes
(id, raw_text, ai_status, due_date, created_at, updated_at)
VALUES (?, ?, 'done', ?, ?, ?)`
).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z');
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => {
addExpired('n1', '2026-04-20');
addExpired('n2', '2026-04-22');
const r = await svc.trashExpiredBatch(['n1', 'n2']);
expect(r.trashedCount).toBe(2);
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([
expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } })
]);
expect(calls.filter((c) => c.kind === 'trash')).toEqual([]);
});
it('returns trashedCount=0 for empty array (no emit)', async () => {
const r = await svc.trashExpiredBatch([]);
expect(r.trashedCount).toBe(0);
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]);
});
});
describe('CaptureService.retryAllFailed', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let enqueued: string[];
let svc: CaptureService;
function makeFailed(rawText: string): string {
const { id } = repo.create({ rawText });
db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id);
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
return id;
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
enqueued = [];
svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = await svc.retryAllFailed();
expect(r.count).toBe(2);
expect(enqueued.sort()).toEqual([a, b].sort());
expect(calls).toContainEqual(
expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } })
);
});
it('retryAllFailed empty — count=0, no emit', async () => {
const r = await svc.retryAllFailed();
expect(r.count).toBe(0);
expect(enqueued).toEqual([]);
expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]);
});
});
describe('CaptureService recall methods (v0.2.3 #6)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let emits: Array<{ kind: string; payload: any }>;
let service: CaptureService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-'));
store = new MediaStore(tmp);
emits = [];
service = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { emits.push(ev as any); } }
});
});
it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
const id = repo.create({ rawText: 'old' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// No last_recalled_at → eligible immediately
const candidate = await service.listRecallCandidate();
expect(candidate?.id).toBe(id);
});
it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const before = repo.findById(id)!.lastRecalledAt;
expect(before).toBeNull();
await service.markRecallOpened(id);
expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
});
it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
expect(repo.findById(id)!.recallDismissedAt).toBeNull();
await service.dismissRecall(id);
expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
});
it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// Backdate created_at to 14 days ago
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
.run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
await service.emitRecallShown(id);
const shown = emits.find((e) => e.kind === 'recall_shown');
expect(shown).toBeDefined();
const payload = shown!.payload as { noteId: string; ageDays: number };
expect(payload.noteId).toBe(id);
expect(payload.ageDays).toBeGreaterThanOrEqual(13);
expect(payload.ageDays).toBeLessThanOrEqual(15);
});
});

View File

@@ -88,4 +88,18 @@ describe('ContinuityService', () => {
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(false);
});
it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => {
const db = dbWithDates([
'2026-04-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
// 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt
// 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함.
db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run();
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(1);
expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00
});
});

View File

@@ -138,4 +138,18 @@ describe('ExportService', () => {
expect(readme).toContain('RAG');
expect(readme).toContain('inkling_export_version');
});
it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => {
const a = repo.create({ rawText: 'active note' }).id;
const t = repo.create({ rawText: 'trashed note' }).id;
repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.trash(t, '2026-05-01T00:00:00.000Z');
const r = await svc.export(outDir, { includeMedia: false });
expect(r.noteCount).toBe(1);
// index.jsonl 도 trash 미포함
const indexPath = join(outDir, 'index.jsonl');
const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js';
import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
class FakeProvider implements InferenceProvider {
readonly name = 'fake';
results: HealthResult[] = [];
private idx = 0;
async healthCheck(): Promise<HealthResult> {
const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
this.idx += 1;
return r;
}
async generate(_input: GenerateInput): Promise<AiResponse> {
throw new Error('not used');
}
}
describe('HealthChecker — start/stop polling', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('start() runs runOnce immediately + every intervalMs', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBeGreaterThanOrEqual(3);
hc.stop();
});
it('start() is idempotent — second call does not duplicate timer', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
hc.start();
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBe(2);
hc.stop();
});
it('stop() clears timer (no further runOnce)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
const before = (provider as any).idx;
hc.stop();
await vi.advanceTimersByTimeAsync(5000);
expect((provider as any).idx).toBe(before);
});
});
describe('HealthChecker — delta transitions + telemetry', () => {
it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]);
});
it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
const events: HealthTelemetryEvent[] = [];
let nowCounter = 0;
const hc = new HealthChecker(new ProviderHolder(provider), {
onTelemetry: (e) => events.push(e),
now: () => { nowCounter += 1; return nowCounter * 1000; }
});
await hc.runOnce();
await hc.runOnce();
const recovered = events.find((e) => e.kind === 'ollama_recovered');
expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 });
});
it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => {
const provider = new FakeProvider();
provider.results = [
{ ok: false, reason: 'refused' },
{ ok: false, reason: 'timeout' }
];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toHaveLength(2);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
});
it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) });
await hc.runOnce({ manual: true });
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
});
});

View File

@@ -233,3 +233,81 @@ describe('ImportService', () => {
expect(dbNote!.media[0]!.bytes).toBe(7);
});
});
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
const r = repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('skipped');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: null
});
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('id-new insert: source deletedAt 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const r = repo.importNote({
id: 'fresh-id', rawText: 'fresh',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('inserted');
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'original' });
const r = repo.importNote({
id, rawText: 'different',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(id);
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});

View File

@@ -26,6 +26,25 @@ describe('LocalOllamaProvider', () => {
expect(r.title).toBe('회의');
});
it('generate passes vocab into prompt body', async () => {
let capturedBody: string = '';
mock.get('http://localhost:11434').intercept({
path: '/api/generate', method: 'POST'
}).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: ['design'] })
}) };
});
await new LocalOllamaProvider().generate({
text: 'x', todayKst: '2026-05-02', dueDateCandidates: [],
vocab: ['design', 'meeting']
});
const parsed = JSON.parse(capturedBody) as { prompt: string };
expect(parsed.prompt).toContain('design, meeting');
expect(parsed.prompt).toContain('Prefer reusing');
});
it('generate throws on non-JSON', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'not json'
@@ -70,4 +89,24 @@ describe('LocalOllamaProvider', () => {
expect(h.ok).toBe(false);
expect(h.reason).toMatch(/connect|refused|unreachable/i);
});
it('abort() cancels in-flight generate (rejects with AbortError)', async () => {
mock.get('http://localhost:11434').intercept({
path: '/api/generate', method: 'POST'
}).reply((async () => {
await new Promise<void>((r) => setTimeout(r, 5000)); // long-running
return { statusCode: 200, data: '{}' };
}) as never);
const provider = new LocalOllamaProvider({ timeoutMs: 30_000 });
const generatePromise = provider.generate({
text: 'x', todayKst: '2026-05-04', dueDateCandidates: []
});
setTimeout(() => provider.abort(), 50);
await expect(generatePromise).rejects.toThrow();
});
it('constructor uses provided model param (not just default)', () => {
const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
expect(provider.name).toBe('local-ollama/gemma4:26b');
});
});

View File

@@ -214,4 +214,585 @@ describe('NoteRepository', () => {
expect(typeof n).toBe('number');
expect(n).toBeGreaterThanOrEqual(0);
});
it('findRecallCandidate returns null for empty db', () => {
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate excludes notes recalled within 7 days', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// 5일 전 본 노트 → 제외
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
repo.markRecallOpened(id, fiveDaysAgo);
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate includes notes recalled 8+ days ago', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
repo.markRecallOpened(id, eightDaysAgo);
expect(repo.findRecallCandidate()?.id).toBe(id);
});
it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨
repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능
const candidate = repo.findRecallCandidate();
expect(candidate?.id).toBe(b);
});
it('findRecallCandidate excludes deleted/pending/imminent due', () => {
const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
// (a) deleted
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(a, new Date().toISOString());
// (b) pending (no AI)
repo.create({ rawText: 'b' });
// (c) due_date 어제
const c = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
expect(repo.findRecallCandidate()).toBeNull();
// (d) due_date today 는 OK (>=today 통과)
const d = repo.create({ rawText: 'd' }).id;
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
expect(repo.findRecallCandidate()?.id).toBe(d);
});
});
describe('NoteRepository.trash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('sets deleted_at and removes pending_jobs row atomically', () => {
const { id } = repo.create({ rawText: 'x' });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
it('updates updated_at to deletedAt timestamp', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('is no-op when note does not exist', () => {
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
});
});
describe('NoteRepository.restore', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('clears deleted_at on a trashed note', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
repo.restore(id);
const note = repo.findById(id)!;
expect(note.deletedAt).toBeNull();
});
it('updates updated_at', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const before = repo.findById(id)!.updatedAt;
repo.restore(id);
const after = repo.findById(id)!.updatedAt;
expect(after).not.toBe(before);
});
it('is no-op on already-active note', () => {
const { id } = repo.create({ rawText: 'x' });
expect(() => repo.restore(id)).not.toThrow();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
});
describe('NoteRepository.permanentDelete', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('removes notes row + cascades note_tags / pending_jobs', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.permanentDelete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
});
describe('NoteRepository.emptyTrash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hard-deletes all trashed notes and returns their ids', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
const r = repo.emptyTrash();
expect(r.noteIds.sort()).toEqual([a, c].sort());
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).not.toBeNull();
expect(repo.findById(c)).toBeNull();
});
it('returns empty array when trash is empty', () => {
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
});
});
describe('NoteRepository.listTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns trashed notes ordered by deleted_at DESC', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T02:00:00.000Z');
repo.trash(b, '2026-05-01T01:00:00.000Z');
const r = repo.listTrashed({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([c, b, a]);
});
it('excludes active notes', () => {
repo.create({ rawText: 'active' });
const r = repo.listTrashed({ limit: 50 });
expect(r).toEqual([]);
});
it('respects limit', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
}
const r = repo.listTrashed({ limit: 3 });
expect(r).toHaveLength(3);
});
});
describe('NoteRepository.countTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns 0 when no trash', () => {
repo.create({ rawText: 'active' });
expect(repo.countTrashed()).toBe(0);
});
it('counts only trashed notes', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b (active)' });
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
expect(repo.countTrashed()).toBe(2);
});
it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
// listTrashed limit cap is 200; countTrashed must reflect actual count.
for (let i = 0; i < 10; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
}
expect(repo.countTrashed()).toBe(10);
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
});
});
describe('Active queries exclude deleted notes', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('list() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.list({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([b]);
});
it('listAll() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.listAll();
expect(r.map((n) => n.rawText)).toEqual(['b']);
});
it('countToday() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, new Date().toISOString());
expect(repo.countToday(new Date())).toBe(1);
});
it('findById() returns trashed notes (does NOT filter)', () => {
const { id } = repo.create({ rawText: 'a' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
const note = repo.findById(id);
expect(note).not.toBeNull();
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
repo.create({ rawText: 'b' }); // ai_status=pending
expect(repo.getPendingCount()).toBe(2);
// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
repo.trash(a, '2026-05-01T00:00:00.000Z');
expect(repo.getPendingCount()).toBe(1);
});
});
describe('NoteRepository.findExpiredCandidates', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
function makeDone(opts: {
rawText: string;
dueDate: string | null;
edited?: boolean;
deletedAt?: string | null;
aiStatus?: 'pending' | 'done' | 'failed';
}): string {
const { id } = repo.create({ rawText: opts.rawText });
db.prepare(
`UPDATE notes
SET due_date = ?,
due_date_edited_by_user = ?,
ai_status = ?,
deleted_at = ?
WHERE id = ?`
).run(
opts.dueDate,
opts.edited ? 1 : 0,
opts.aiStatus ?? 'done',
opts.deletedAt ?? null,
id
);
return id;
}
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b);
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([b, a]);
});
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort());
});
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([a]);
});
it('excludes pending / failed notes (ai_status != done)', () => {
const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' });
makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([done]);
});
it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => {
const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: null });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([dated]);
});
it('excludes notes with due_date == today (boundary, not expired)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([past]);
});
});
describe('NoteRepository.trashBatch', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('atomically trashes all valid ids and returns trashedCount', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(3);
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c))
.toMatchObject({ c: 0 });
});
it('returns trashedCount=0 for empty array (no-op)', () => {
const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(0);
});
it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.trash(a, '2026-04-30T00:00:00.000Z');
const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(0);
expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z');
});
it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.trash(b, '2026-04-30T00:00:00.000Z');
const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(1);
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});
describe('NoteRepository — failed retry helpers', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
function makeFailed(rawText: string, deletedAt: string | null = null): string {
const { id } = repo.create({ rawText });
db.prepare(
`UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?`
).run(deletedAt, id);
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
return id;
}
it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => {
const a = makeFailed('a');
makeFailed('b', '2026-04-30T00:00:00Z'); // trashed
repo.create({ rawText: 'pending' }); // pending status
expect(repo.findFailedIds().sort()).toEqual([a].sort());
});
it('countFailed counts active failed notes only', () => {
makeFailed('a');
makeFailed('b');
makeFailed('c', '2026-04-30T00:00:00Z');
expect(repo.countFailed()).toBe(2);
});
it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
expect(r.ids.sort()).toEqual([a, b].sort());
expect(repo.findById(a)!.aiStatus).toBe('pending');
expect(repo.findById(b)!.aiStatus).toBe('pending');
expect(repo.findById(a)!.aiError).toBeNull();
const jobs = repo.getAllPendingJobs();
expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort());
for (const j of jobs) {
expect(j.attempts).toBe(0);
expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
}
});
it('retryAllFailed empty — { ids: [] }', () => {
expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] });
});
it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => {
// failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션.
// attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야.
const id = makeFailed('a');
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`)
.run(id, '2026-04-30T00:00:00.000Z');
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
expect(r.ids).toEqual([id]);
const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
expect(jobs).toHaveLength(1); // duplicate 안 됨
// OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로
expect(jobs[0]!.attempts).toBe(2);
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
});
it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => {
const { id } = repo.create({ rawText: 'x' });
repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');
// attempts 가 1 이 됨
repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable');
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
expect(job.attempts).toBe(1); // 변화 없음
expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
});
it('getTopUsedTags returns [] when no notes', () => {
expect(repo.getTopUsedTags()).toEqual([]);
});
it('getTopUsedTags orders by count desc, id asc tiebreaker', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' });
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
// counts: design=3, meeting=2, qa=1
expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']);
});
it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => {
const a = repo.create({ rawText: 'a' }).id;
// user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증
repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] });
expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout']));
expect(repo.getTopUsedTags()).not.toContain('회의');
expect(repo.getTopUsedTags()).not.toContain('Meeting');
expect(repo.getTopUsedTags()).not.toContain('two words');
});
it('getTopUsedTags counts AI + user sources together', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
// design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total
// → design must rank first (proves source merging, not AI-only count)
// Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note
// gets exactly the tags passed in the call.
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' });
repo.updateUserAiFields(b, { tags: ['design'] });
repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
const top = repo.getTopUsedTags();
expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only)
expect(top.indexOf('meeting')).toBeGreaterThan(0);
});
it('getTopUsedTags excludes tags from deleted notes', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' });
repo.trash(a, new Date().toISOString());
expect(repo.getTopUsedTags()).not.toContain('lonely');
});
it('getTopUsedTags respects LIMIT parameter', () => {
const ids: string[] = [];
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
ids.push(id);
repo.updateAiResult(id, {
title: 't', summary: 'a\nb\nc',
tags: [`tag-${i}`],
provider: 'p'
});
}
expect(repo.getTopUsedTags(3)).toHaveLength(3);
expect(repo.getTopUsedTags(10)).toHaveLength(5);
});
it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => {
// 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외
// 결과 length = 2 (limit=3 보다 작음)
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X
repo.updateUserAiFields(b, { tags: ['design'] });
repo.updateUserAiFields(c, { tags: ['meeting'] });
const top = repo.getTopUsedTags(3);
expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거
expect(top).not.toContain('회의');
expect(top).toEqual(expect.arrayContaining(['design', 'meeting']));
});
it('getTagIdByName returns id when present, null when absent', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });
const id = repo.getTagIdByName('hello');
expect(typeof id).toBe('number');
expect(id).toBeGreaterThan(0);
// case-insensitive
expect(repo.getTagIdByName('HELLO')).toBe(id);
// absent
expect(repo.getTagIdByName('nothere')).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
describe('ProviderHolder', () => {
it('replace() fires listener and get() returns new instance', () => {
const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' });
const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' });
const holder = new ProviderHolder(a);
const listener = vi.fn();
holder.onReplace(listener);
expect(holder.get()).toBe(a);
holder.replace(b);
expect(holder.get()).toBe(b);
expect(listener).toHaveBeenCalledWith(b);
});
it('multiple listeners all fire on replace()', () => {
const a = new LocalOllamaProvider({ model: 'm1' });
const b = new LocalOllamaProvider({ model: 'm2' });
const holder = new ProviderHolder(a);
const l1 = vi.fn();
const l2 = vi.fn();
holder.onReplace(l1);
holder.onReplace(l2);
holder.replace(b);
expect(l1).toHaveBeenCalledWith(b);
expect(l2).toHaveBeenCalledWith(b);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsService } from '@main/services/SettingsService.js';
describe('SettingsService', () => {
let dir: string;
let svc: SettingsService;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-settings-'));
svc = new SettingsService(dir);
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('load() returns empty object when file does not exist', async () => {
const s = await svc.load();
expect(s).toEqual({});
});
it('load() returns empty object on corrupted JSON (no throw)', async () => {
writeFileSync(join(dir, 'settings.json'), '{ this is not json');
const s = await svc.load();
expect(s).toEqual({});
});
it('load() caches result — second call does not re-read file', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
const before = await svc.load();
// 외부에서 파일 변경
writeFileSync(join(dir, 'settings.json'), JSON.stringify({ ollama: { endpoint: 'http://lan:11434', model: 'gemma4:26b' } }));
const after = await svc.load();
// 캐시 적용 — 파일 변경 무시
expect(after).toEqual(before);
});
it('setOllama() throws on non-URL endpoint', async () => {
await expect(
svc.setOllama({ endpoint: 'not-a-url', model: 'gemma4:e4b' })
).rejects.toThrow();
});
it('setOllama() persists to disk with valid JSON', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11435', model: 'gemma4:e4b' });
const raw = readFileSync(join(dir, 'settings.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed.ollama.endpoint).toBe('http://localhost:11435');
expect(parsed.ollama.model).toBe('gemma4:e4b');
});
it('setOllama() atomic write — tmp file does not remain', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
expect(existsSync(join(dir, 'settings.json'))).toBe(true);
});
});

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TelemetryService } from '@main/services/TelemetryService.js';
describe('TelemetryService.emit', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => {
// 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
const file = join(dir, 'events-2026-05-01.jsonl');
expect(existsSync(file)).toBe(true);
const content = readFileSync(file, 'utf8').trim();
const parsed = JSON.parse(content);
expect(parsed.kind).toBe('capture');
expect(parsed.payload.noteId).toBe('n1');
expect(typeof parsed.ts).toBe('string');
});
it('uses KST date even when UTC date differs (around midnight)', async () => {
// 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } });
expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true);
});
it('appends multiple events to same-day file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } });
const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]!).kind).toBe('capture');
expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded');
});
it('creates telemetry dir if absent', async () => {
const fresh = join(dir, 'nested', 'telem');
const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } });
expect(existsSync(fresh)).toBe(true);
});
it('rejects malformed event (privacy invariant) — does NOT write file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never
})).rejects.toThrow();
// No file should have been created
expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]);
});
it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => {
// Make the "dir" actually a file so mkdir({recursive:true}) reliably fails on every platform.
// (Earlier draft used /proc/0/... which on Windows resolves to C:\proc\0\... and
// mkdir({recursive:true}) silently *creates* it, leaking filesystem side-effects + the
// silent code path was never exercised.)
const blockingFile = join(dir, 'this-is-a-file-not-a-dir');
writeFileSync(blockingFile, '');
const svc = new TelemetryService(
blockingFile,
() => new Date('2026-05-01T12:00:00Z'),
14,
{ silent: true }
);
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
})).resolves.toBeUndefined();
});
it('emit DOES throw when fs write fails AND silent is not set (default)', async () => {
// Companion case — confirms silent is opt-in. Without silent, fs failure surfaces.
const blockingFile = join(dir, 'block-default');
writeFileSync(blockingFile, '');
const svc = new TelemetryService(blockingFile, () => new Date('2026-05-01T12:00:00Z'));
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
})).rejects.toThrow();
});
});
describe('TelemetryService.cleanupOldFiles', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('removes events-*.jsonl older than retentionDays', async () => {
// 시드: 오래된 파일 + 최근 파일
writeFileSync(join(dir, 'events-2026-04-01.jsonl'), '{}\n'); // 30일 전
writeFileSync(join(dir, 'events-2026-04-25.jsonl'), '{}\n'); // 6일 전 (retain)
writeFileSync(join(dir, 'events-2026-05-01.jsonl'), '{}\n'); // 오늘 (retain)
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-01.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-25.jsonl'))).toBe(true);
expect(existsSync(join(dir, 'events-2026-05-01.jsonl'))).toBe(true);
});
it('returns empty when no files match prefix', async () => {
writeFileSync(join(dir, 'unrelated.txt'), 'x');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
expect(existsSync(join(dir, 'unrelated.txt'))).toBe(true);
});
it('handles missing dir gracefully (no throw)', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
});
it('boundary: file exactly retentionDays old is retained', async () => {
// 2026-04-17 = 14일 전 (boundary, retain)
writeFileSync(join(dir, 'events-2026-04-17.jsonl'), '{}\n');
// 2026-04-16 = 15일 전 (delete)
writeFileSync(join(dir, 'events-2026-04-16.jsonl'), '{}\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-16.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true);
});
});
describe('TelemetryService.readAllRecent', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('reads events from all files within retentionDays', async () => {
writeFileSync(join(dir, 'events-2026-04-25.jsonl'),
JSON.stringify({ ts: '2026-04-25T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'b', rawTextLength: 2, hasMedia: false } }) + '\n' +
JSON.stringify({ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'b', durationMs: 100, attempts: 0 } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(3);
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
expect(events.map((e) =>
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
? null
: e.payload.noteId
)).toEqual(['a', 'b', 'b']);
});
it('skips malformed lines (silent — invariant)', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
'not-json\n' +
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n' +
'{}\n'); // valid JSON but invalid event
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(1);
const ev = events[0]!;
expect(ev.kind).toBe('capture');
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a');
});
it('returns [] when dir missing', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toEqual([]);
});
it('returns [] when dir empty', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
expect(await svc.readAllRecent()).toEqual([]);
});
});
describe('TelemetryService.exportTo', () => {
let dir: string;
let outDir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-telem-'));
outDir = mkdtempSync(join(tmpdir(), 'inkling-export-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(outDir, { recursive: true, force: true });
});
it('writes events.jsonl (concat) + stats.md to folder', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(1);
expect(existsSync(join(outDir, 'events.jsonl'))).toBe(true);
expect(existsSync(join(outDir, 'stats.md'))).toBe(true);
const events = readFileSync(join(outDir, 'events.jsonl'), 'utf8').trim().split('\n');
expect(events).toHaveLength(1);
const stats = readFileSync(join(outDir, 'stats.md'), 'utf8');
expect(stats).toContain('총 이벤트: 1');
});
it('handles empty input — writes 0-event stats', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(0);
expect(readFileSync(join(outDir, 'events.jsonl'), 'utf8')).toBe('');
expect(readFileSync(join(outDir, 'stats.md'), 'utf8')).toContain('총 이벤트: 0');
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js';
describe('todayInKstString', () => {
it('returns KST calendar date as YYYY-MM-DD', () => {
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST
expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
});
it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => {
expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
});
it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => {
expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
});
});
describe('nextKstMidnightMs', () => {
it('returns the next KST 00:00 epoch ms (UTC 12:00 → +12h to KST midnight)', () => {
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → 다음 KST 자정 = 2026-05-02 00:00 KST
// = 2026-05-01 15:00 UTC
const now = Date.parse('2026-05-01T12:00:00Z');
const next = nextKstMidnightMs(now);
expect(new Date(next).toISOString()).toBe('2026-05-01T15:00:00.000Z');
});
it('returns 24h-from-now-ish when called shortly after KST midnight', () => {
// 2026-05-01 15:01 UTC = 2026-05-02 00:01 KST → 다음 KST 자정 = 2026-05-03 00:00 KST
// = 2026-05-02 15:00 UTC (≈ 23h59m later)
const now = Date.parse('2026-05-01T15:01:00Z');
const next = nextKstMidnightMs(now);
expect(new Date(next).toISOString()).toBe('2026-05-02T15:00:00.000Z');
expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(next - now).toBeLessThan(24 * 60 * 60 * 1000);
});
});

View File

@@ -1,19 +1,11 @@
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations, latestVersion } from '@main/db/migrations/index.js';
import { runMigrations } from '@main/db/migrations/index.js';
describe('migrations m002 due_date', () => {
it('latestVersion returns 2', () => {
expect(latestVersion()).toBe(2);
});
it('runMigrations on fresh DB advances user_version to 2', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.pragma('user_version', { simple: true });
expect(row).toBe(2);
});
// v3 (m003 soft_delete) lands in v0.2.3 #4 — latest version + user_version
// assertions migrate to migrations.test.ts. Here we keep only the m002-specific
// assertion (due_date column existence) which is version-stable.
it('due_date column exists with NULL default', () => {
const db = new Database(':memory:');
runMigrations(db);

View File

@@ -29,3 +29,47 @@ describe('migrations', () => {
db.close();
});
});
describe('migration v3 — soft delete columns', () => {
it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
const db = new Database(':memory:');
runMigrations(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
expect(cols).toEqual(
expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
);
db.close();
});
it('creates idx_notes_deleted_at index', () => {
const db = new Database(':memory:');
runMigrations(db);
const indexes = db
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
.all() as Array<{ name: string }>;
expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
db.close();
});
it('user_version reaches 3', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(3);
db.close();
});
it('all 3 new columns default to NULL', () => {
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
).run();
const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
expect(row.deleted_at).toBeNull();
expect(row.last_recalled_at).toBeNull();
expect(row.recall_dismissed_at).toBeNull();
db.close();
});
});

31
tests/unit/prompt.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js';
describe('prompt', () => {
it('PROMPT_VERSION is 4', () => {
expect(PROMPT_VERSION).toBe(4);
});
it('buildPrompt with empty vocab omits vocabulary line entirely', () => {
const out = buildPrompt('hello', '2026-05-02', [], []);
expect(out).not.toContain('vocabulary');
expect(out).not.toContain('Prefer reusing');
});
it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => {
const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']);
expect(out).toContain('Existing vocabulary tags');
expect(out).toContain('design, meeting, qa');
expect(out).toContain('Prefer reusing');
});
it('vocab block appears after header and before JSON rules', () => {
const out = buildPrompt('hello', '2026-05-02', [], ['design']);
const headerIdx = out.indexOf("Today's date");
const vocabIdx = out.indexOf('Existing vocabulary');
const jsonRulesIdx = out.indexOf('Return a JSON object');
expect(headerIdx).toBeGreaterThan(-1);
expect(vocabIdx).toBeGreaterThan(headerIdx);
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockApi = {
listNotes: vi.fn(async () => []),
listTrash: vi.fn(async () => []),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {}),
listExpired: vi.fn(async () => []),
trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })),
ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })),
onOllamaStatus: vi.fn(() => () => {}),
retryAllFailed: vi.fn(async () => ({ count: 0 })),
getFailedCount: vi.fn(async () => 0)
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
describe('useInbox — AI retry (v0.2.3 #2)', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, failedCount: 5,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
expiredCandidates: [], expiredSnoozeUntilMs: null
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('retryAllFailed action — failedCount=0 reset 후 IPC 호출', async () => {
mockApi.retryAllFailed.mockResolvedValueOnce({ count: 5 });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().retryAllFailed();
expect(mockApi.retryAllFailed).toHaveBeenCalledTimes(1);
expect(useInbox.getState().failedCount).toBe(0);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {}),
listExpired: vi.fn(async () => [] as Note[]),
trashExpiredBatch: vi.fn(async (_ids: string[]) => ({ trashedCount: 0, confirmed: false }))
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
const noteStub = (id: string): Note => ({
id, rawText: 'x',
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
aiProvider: null, aiGeneratedAt: null,
titleEditedByUser: false, summaryEditedByUser: false,
userIntent: null, intentPromptedAt: null,
dueDate: '2026-04-20', dueDateEditedByUser: false,
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
tags: [], media: []
});
describe('useInbox — expired state (v0.2.3 #5)', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
expiredCandidates: [], expiredSnoozeUntilMs: null
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
afterEach(() => { vi.restoreAllMocks(); });
it('loadExpired sets expiredCandidates from inboxApi', async () => {
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().loadExpired();
const s = useInbox.getState();
expect(s.expiredCandidates).toHaveLength(1);
expect(s.expiredCandidates[0]!.id).toBe('n1');
});
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
expiredCandidates: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
notes: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
trashCount: 5
});
await useInbox.getState().trashExpiredBatch(['n1', 'n2']);
const s = useInbox.getState();
expect(s.expiredCandidates.map((n) => n.id)).toEqual(['n3']);
expect(s.notes.map((n) => n.id)).toEqual(['n3']);
expect(s.trashCount).toBe(7);
});
it('trashExpiredBatch does NOT mutate state when not confirmed', async () => {
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 0, confirmed: false });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
expiredCandidates: [noteStub('n1'), noteStub('n2')],
notes: [noteStub('n1'), noteStub('n2')],
trashCount: 5
});
await useInbox.getState().trashExpiredBatch(['n1']);
const s = useInbox.getState();
expect(s.expiredCandidates).toHaveLength(2);
expect(s.notes).toHaveLength(2);
expect(s.trashCount).toBe(5);
});
it('snoozeExpired sets expiredSnoozeUntilMs to next KST midnight', async () => {
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → next KST midnight = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC
const fixedNow = Date.parse('2026-05-01T12:00:00Z');
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().snoozeExpired();
expect(useInbox.getState().expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z'));
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockApi = {
listNotes: vi.fn(async () => []),
listTrash: vi.fn(async () => []),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {}),
listExpired: vi.fn(async () => []),
trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })),
ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })),
onOllamaStatus: vi.fn(() => () => {})
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
describe('useInbox — ollama (v0.2.3 #1)', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: false, reason: 'refused' },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
expiredCandidates: [], expiredSnoozeUntilMs: null
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('recheckOllama calls inboxApi.ollamaRecheck and updates ollamaStatus', async () => {
mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: true });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().recheckOllama();
expect(mockApi.ollamaRecheck).toHaveBeenCalledTimes(1);
expect(useInbox.getState().ollamaStatus).toEqual({ ok: true });
});
it('recheckOllama propagates failure status', async () => {
mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: false, reason: 'timeout' });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().recheckOllama();
expect(useInbox.getState().ollamaStatus).toEqual({ ok: false, reason: 'timeout' });
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
listRecallCandidate: vi.fn(),
markRecallOpened: vi.fn(),
dismissRecall: vi.fn(),
emitRecallShown: vi.fn(),
emitRecallSnoozed: vi.fn(),
listNotes: vi.fn(async () => []),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
getTrashCount: vi.fn(async () => 0),
listExpired: vi.fn(async () => []),
getFailedCount: vi.fn(async () => 0)
}
}));
import { useInbox } from '../../src/renderer/inbox/store.js';
import { inboxApi } from '../../src/renderer/inbox/api.js';
const inboxApiMock = inboxApi as unknown as {
listRecallCandidate: ReturnType<typeof vi.fn>;
markRecallOpened: ReturnType<typeof vi.fn>;
dismissRecall: ReturnType<typeof vi.fn>;
emitRecallShown: ReturnType<typeof vi.fn>;
emitRecallSnoozed: ReturnType<typeof vi.fn>;
};
const note = (id: string): Note => ({
id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc',
tags: [], media: [], aiStatus: 'done', aiProvider: null, aiGeneratedAt: null, aiError: null,
titleEditedByUser: false, summaryEditedByUser: false,
dueDate: null, dueDateEditedByUser: false,
userIntent: null, intentPromptedAt: null,
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
});
describe('store recall actions', () => {
beforeEach(() => {
vi.clearAllMocks();
useInbox.setState({
recallCandidate: null,
recallSnoozeUntilMs: null,
} as Parameters<typeof useInbox.setState>[0]);
});
it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => {
useInbox.setState({ recallCandidate: note('n1') } as Parameters<typeof useInbox.setState>[0]);
await useInbox.getState().snoozeRecall();
const ms = useInbox.getState().recallSnoozeUntilMs;
expect(ms).not.toBeNull();
expect(ms!).toBeGreaterThan(Date.now());
expect(inboxApiMock.emitRecallSnoozed).toHaveBeenCalledWith('n1');
});
it('openRecall calls API + fetches next candidate', async () => {
inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') });
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null);
await useInbox.getState().openRecall('n1');
expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1');
expect(inboxApiMock.listRecallCandidate).toHaveBeenCalled();
expect(useInbox.getState().recallCandidate).toBeNull();
});
it('dismissRecallNote calls API + fetches next candidate', async () => {
inboxApiMock.dismissRecall.mockResolvedValueOnce({ note: note('n1') });
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(note('n2'));
await useInbox.getState().dismissRecallNote('n1');
expect(inboxApiMock.dismissRecall).toHaveBeenCalledWith('n1');
expect(useInbox.getState().recallCandidate?.id).toBe('n2');
});
});

View File

@@ -18,6 +18,9 @@ function sample(id: string, tags: string[]): Note {
intentPromptedAt: null,
dueDate: null,
dueDateEditedByUser: false,
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
createdAt: '2026-04-26T00:00:00Z',
updatedAt: '2026-04-26T00:00:00Z',
tags: tags.map((name) => ({ name, source: 'ai' as const })),

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {})
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
id, rawText: 'x',
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
aiProvider: null, aiGeneratedAt: null,
titleEditedByUser: false, summaryEditedByUser: false,
userIntent: null, intentPromptedAt: null,
dueDate: null, dueDateEditedByUser: false,
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
tags: [], media: []
});
describe('useInbox — trash state (v0.2.3 #4)', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(true);
expect(useInbox.getState().trashNotes).toHaveLength(1);
expect(mockApi.listTrash).toHaveBeenCalled();
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(false);
});
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a'));
expect(useInbox.getState().notes).toHaveLength(1);
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('restoreNote calls api + moves note from trashNotes to notes (낙관적 갱신)', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().trashNotes).toHaveLength(1);
await useInbox.getState().restoreNote('a');
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
// main 은 restore 시 pushNoteUpdated 안 보냄 — store 자가 갱신 검증
expect(useInbox.getState().trashNotes).toHaveLength(0);
expect(useInbox.getState().notes).toHaveLength(1);
expect(useInbox.getState().notes[0]!.deletedAt).toBeNull();
});
it('upsertNote with showTrash=false preserves server trashCount (regression I1)', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
// server 가 trashCount=5 알려줬는데 trashNotes 는 미로드 (showTrash=false 기본)
useInbox.setState({ trashCount: 5, trashNotes: [] });
useInbox.getState().upsertNote(noteStub('active-1'));
expect(useInbox.getState().trashCount).toBe(5); // server 값 보존
expect(useInbox.getState().notes).toHaveLength(1);
});
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
await useInbox.getState().emptyTrash();
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
});

View File

@@ -0,0 +1,335 @@
import { describe, it, expect } from 'vitest';
import { validateEvent } from '@main/services/telemetryEvents.js';
describe('validateEvent — happy path', () => {
it('accepts capture event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 12, hasMedia: false }
});
expect(e.kind).toBe('capture');
});
it('accepts ai_succeeded event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1234, attempts: 0 }
});
expect(e.kind).toBe('ai_succeeded');
});
it('accepts ai_failed event with reason enum', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_failed',
payload: { noteId: 'n1', reason: 'unreachable', attempts: 3 }
});
expect(e.kind).toBe('ai_failed');
});
});
describe('validateEvent — privacy invariant', () => {
it('rejects payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, rawText: 'leak' }
})).toThrow();
});
it('rejects payload with title leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1, attempts: 0, title: 'leak' }
})).toThrow();
});
it('rejects payload with summary leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1, attempts: 0, summary: 'leak' }
})).toThrow();
});
it('rejects payload with userIntent leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, userIntent: 'leak' }
})).toThrow();
});
it('rejects payload with tag name leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, tagNames: ['일정'] }
})).toThrow();
});
it('rejects unknown reason in ai_failed', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_failed',
payload: { noteId: 'n1', reason: 'unicorn', attempts: 1 }
})).toThrow();
});
it('rejects unknown event kind', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'mystery',
payload: {}
})).toThrow();
});
});
describe('validateEvent — trash family (v0.2.3 #4)', () => {
it('accepts trash event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('trash');
});
it('accepts restore event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'restore',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('restore');
});
it('accepts permanent_delete event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'permanent_delete',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('permanent_delete');
});
it('accepts empty_trash event with count', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 7 }
});
expect(e.kind).toBe('empty_trash');
});
it('rejects trash payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1', rawText: 'leak' }
})).toThrow();
});
it('rejects empty_trash with negative count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: -1 }
})).toThrow();
});
it('rejects empty_trash with non-integer count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 1.5 }
})).toThrow();
});
});
describe('expired_banner_shown / expired_batch_trash events', () => {
it('parses valid expired_banner_shown', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_banner_shown',
payload: { candidateCount: 7 }
});
if (ev.kind !== 'expired_banner_shown') throw new Error('discriminant');
expect(ev.payload.candidateCount).toBe(7);
});
it('parses valid expired_batch_trash', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_batch_trash',
payload: { count: 3 }
});
if (ev.kind !== 'expired_batch_trash') throw new Error('discriminant');
expect(ev.payload.count).toBe(3);
});
it('rejects expired_banner_shown with extra payload field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_banner_shown',
payload: { candidateCount: 7, rawText: 'leak' }
})).toThrow();
});
it('rejects expired_batch_trash with negative count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_batch_trash',
payload: { count: -1 }
})).toThrow();
});
it('rejects expired_batch_trash with extra payload field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_batch_trash',
payload: { count: 3, rawText: 'leak' }
})).toThrow();
});
});
describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events', () => {
it('parses valid ollama_unreachable', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_unreachable',
payload: { reason: 'connection refused' }
});
if (ev.kind !== 'ollama_unreachable') throw new Error('discriminant');
expect(ev.payload.reason).toBe('connection refused');
});
it('parses valid ollama_recovered', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_recovered',
payload: { downtimeMs: 60000 }
});
if (ev.kind !== 'ollama_recovered') throw new Error('discriminant');
expect(ev.payload.downtimeMs).toBe(60000);
});
it('parses valid ollama_recheck_manual (empty payload)', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_recheck_manual',
payload: {}
});
expect(ev.kind).toBe('ollama_recheck_manual');
});
it('rejects ollama_unreachable with extra payload field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_unreachable',
payload: { reason: 'refused', rawText: 'leak' }
})).toThrow();
});
it('rejects ollama_recovered with negative downtimeMs', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_recovered',
payload: { downtimeMs: -1 }
})).toThrow();
});
it('rejects ollama_recheck_manual with non-empty payload (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ollama_recheck_manual',
payload: { foo: 'bar' }
})).toThrow();
});
});
describe('ai_retry_manual event', () => {
it('parses valid ai_retry_manual', () => {
const ev = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_retry_manual',
payload: { failedCount: 5 }
});
if (ev.kind !== 'ai_retry_manual') throw new Error('discriminant');
expect(ev.payload.failedCount).toBe(5);
});
it('rejects ai_retry_manual with failedCount=0 (≥1 invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_retry_manual',
payload: { failedCount: 0 }
})).toThrow();
});
it('rejects ai_retry_manual with extra payload field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_retry_manual',
payload: { failedCount: 5, rawText: 'leak' }
})).toThrow();
});
});
describe('validateEvent — tag vocab', () => {
it('accepts tag_vocab_hit event', () => {
const e = validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'tag_vocab_hit',
payload: { tagId: 42, vocabSize: 17 }
});
expect(e.kind).toBe('tag_vocab_hit');
});
it('accepts tag_vocab_miss event without tagId', () => {
const e = validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'tag_vocab_miss',
payload: { vocabSize: 17 }
});
expect(e.kind).toBe('tag_vocab_miss');
});
it('rejects tag_vocab_hit with extra field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'tag_vocab_hit',
payload: { tagId: 42, vocabSize: 17, tagName: 'leak' }
})).toThrow();
});
});
describe('validateEvent — recall', () => {
it('accepts recall_shown event', () => {
const e = validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14 }
});
expect(e.kind).toBe('recall_shown');
});
it('rejects recall_shown with extra field (privacy)', () => {
expect(() => validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
})).toThrow();
});
it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
expect(e.kind).toBe(kind);
}
});
});

View File

@@ -0,0 +1,213 @@
import { describe, it, expect } from 'vitest';
import { aggregateStats } from '@main/services/telemetryStats.js';
import type { TelemetryEvent } from '@main/services/telemetryEvents.js';
const e = (ts: string, kind: TelemetryEvent['kind'], payload: TelemetryEvent['payload']): TelemetryEvent =>
({ ts, kind, payload } as TelemetryEvent);
describe('aggregateStats', () => {
it('produces empty stats for empty input', () => {
const r = aggregateStats([], new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(0);
expect(r.md).toContain('총 이벤트: 0');
});
it('counts events per KST day per kind', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T12:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 5, hasMedia: false }),
e('2026-05-01T12:01:00Z', 'capture', { noteId: 'n2', rawTextLength: 3, hasMedia: true }),
e('2026-05-01T12:02:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
e('2026-05-02T00:00:00Z', 'ai_failed', { noteId: 'n2', reason: 'unreachable', attempts: 3 })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(4);
expect(r.md).toContain('| 2026-05-01 | 2 | 1 | 0 |');
expect(r.md).toContain('| 2026-05-02 | 0 | 0 | 1 |');
});
it('computes AI 성공률', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:03Z', 'ai_failed', { noteId: 'n4', reason: 'other', attempts: 1 })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('AI 성공률: 75.0%');
expect(r.md).toContain('3/4');
});
it('AI 성공률 N/A when no AI events', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('AI 성공률: N/A');
});
it('computes 평균 ai_succeeded durationMs', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 2000, attempts: 0 }),
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 3000, attempts: 0 })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('평균 ai_succeeded durationMs: 2000');
});
it('buckets near-midnight UTC events on the correct KST day (regression: not naive UTC)', () => {
// 2026-05-01T15:30:00Z → 2026-05-02 00:30 KST → KST day 2026-05-02
// Naive UTC slice(0,10) would put this on 2026-05-01 — this test catches that regression.
const events: TelemetryEvent[] = [
e('2026-05-01T15:30:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.md).toContain('| 2026-05-02 | 1 | 0 | 0 |');
expect(r.md).not.toContain('| 2026-05-01 |');
});
});
describe('aggregateStats — trash family (v0.2.3 #4)', () => {
it('counts trash/restore/permanent_delete/empty_trash per day', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'n1' }),
e('2026-05-01T01:00:00Z', 'trash', { noteId: 'n2' }),
e('2026-05-01T02:00:00Z', 'restore', { noteId: 'n1' }),
e('2026-05-01T03:00:00Z', 'permanent_delete', { noteId: 'n3' }),
e('2026-05-01T04:00:00Z', 'empty_trash', { count: 5 })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(5);
expect(r.md).toContain('| 2026-05-01 | 0 | 0 | 0 | 2 | 1 | 1 | 1 |');
});
it('computes restore/trash ratio', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'a' }),
e('2026-05-01T00:00:01Z', 'trash', { noteId: 'b' }),
e('2026-05-01T00:00:02Z', 'trash', { noteId: 'c' }),
e('2026-05-01T00:00:03Z', 'trash', { noteId: 'd' }),
e('2026-05-01T00:00:04Z', 'restore', { noteId: 'a' })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: 25.0% (1/4)');
});
it('휴지통 회수율 N/A when no trash events', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: N/A');
});
});
describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => {
it('counts both kinds per day and computes 만료 trash ratio', () => {
const events = [
{ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 5 } },
{ ts: '2026-05-01T01:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 3 } },
{ ts: '2026-05-01T02:00:00.000Z', kind: 'expired_batch_trash' as const, payload: { count: 4 } }
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('expired_banner_shown');
expect(r.md).toContain('expired_batch_trash');
// 4 / (5 + 3) = 50.0%
expect(r.md).toMatch(/만료 trash ratio.*50\.0%/);
});
it('shows N/A when 만료 배너 노출 0건', () => {
const events = [
{ ts: '2026-05-01T00:00:00.000Z', kind: 'capture' as const, payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toMatch(/만료 trash ratio.*N\/A/);
});
});
describe('aggregateStats — ollama_* events', () => {
it('counts 3 kinds per day and computes downtime average', () => {
const events = [
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } },
{ ts: '2026-05-01T01:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 60000 } },
{ ts: '2026-05-01T02:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'timeout' } },
{ ts: '2026-05-01T03:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 120000 } },
{ ts: '2026-05-01T04:00:00.000Z', kind: 'ollama_recheck_manual' as const, payload: {} as Record<string, never> }
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('ollama_unreachable');
expect(r.md).toContain('ollama_recovered');
expect(r.md).toContain('ollama_recheck_manual');
// (60000 + 120000) / 2 = 90000
expect(r.md).toMatch(/평균 downtimeMs.*90000/);
expect(r.md).toMatch(/수동 recheck.*1/);
});
it('shows N/A for downtime when no recovered events', () => {
const events = [
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } }
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toMatch(/평균 downtimeMs.*N\/A/);
});
});
describe('aggregateStats — ai_retry_manual', () => {
it('counts events and sums failedCount', () => {
const events = [
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 3 } },
{ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 7 } }
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('ai_retry_manual');
// 2회 / 누적 10건
expect(r.md).toMatch(/AI 수동 재시도.*2회.*10건/);
});
});
describe('aggregateStats — tag_vocab hit/miss', () => {
it('aggregates tag_vocab hit/miss with success rate', () => {
const events: TelemetryEvent[] = [
e('2026-05-02T00:00:00Z', 'tag_vocab_hit', { tagId: 1, vocabSize: 10 }),
e('2026-05-02T00:00:01Z', 'tag_vocab_hit', { tagId: 2, vocabSize: 10 }),
e('2026-05-02T00:00:02Z', 'tag_vocab_hit', { tagId: 3, vocabSize: 10 }),
e('2026-05-02T00:00:03Z', 'tag_vocab_hit', { tagId: 4, vocabSize: 10 }),
e('2026-05-02T00:00:04Z', 'tag_vocab_hit', { tagId: 5, vocabSize: 10 }),
e('2026-05-02T00:00:05Z', 'tag_vocab_miss', { vocabSize: 10 }),
e('2026-05-02T00:00:06Z', 'tag_vocab_miss', { vocabSize: 10 }),
e('2026-05-02T00:00:07Z', 'tag_vocab_miss', { vocabSize: 10 })
];
const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
expect(r.md).toContain('태그 vocab: hit/miss = 5/3');
expect(r.md).toContain('적중률 62.5%');
});
it('태그 vocab summary shows 데이터 없음 when no events', () => {
const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
expect(r.md).toContain('태그 vocab');
expect(r.md).toContain('데이터 없음');
});
it('aggregates recall events with open rate + average ageDays', () => {
const events: TelemetryEvent[] = [
e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }),
e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }),
e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }),
e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }),
e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }),
e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }),
e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }),
e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' })
];
const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1');
expect(r.md).toContain('열림율 50.0%');
expect(r.md).toContain('회상 평균 ageDays: 25'); // (10+20+30+40)/4
});
it('회상 summary shows 데이터 없음 when no recall events', () => {
const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
expect(r.md).toContain('회상 추천');
expect(r.md).toContain('데이터 없음');
});
});