 # Inkling — Vertical Slice v0.4 설계 문서

**작성일:** 2026-04-24 (v0.1) · **개정:** 2026-04-25 (v0.2, Strategy 통합) · 2026-04-25 (v0.3, gemma4:26b + LAN endpoint) · 2026-04-25 (v0.4, gemma4:e4b 표준 티어로 다운사이즈)
**저자:** 김태현 (dlsrks0734@gmail.com)
**대상 기획서:** `inkling.md` v1.4
**참조 문서:** `docs/superpowers/strategy/strategy.md` (심리학 기반 습관화 전략)
**문서 성격:** Phase 1 MVP의 첫 서브프로젝트 설계 (vertical slice)

---

## 0. 개요

이 문서는 `inkling.md` 기획서의 Phase 1 MVP(§8.1) 중 **종단 end-to-end 경로** 한 줄만 최소 구현하기 위한 설계다. 목적은 세 가지 핵심 가설을 가장 얇은 경로로 검증하는 것이다.

1. "전역 단축키 → 입력 → 저장"이 실제로 3초 내 마찰 없이 이루어지는가. (기획서 §3.2-3)
2. 로컬 Ollama(Gemma 4 9B)가 제목·요약·태그를 실용 가능한 품질로 생성하는가. (기획서 §4.2, §6.5)
3. Strategy 문서의 핵심 행동 루프(Capture → Clarify → Capitalize)와 회복 친화 스트릭이 실제 dogfood에서 "메모를 던지고 싶은 충동"을 만드는가.

Slice는 dogfood만을 목적으로 하며, 패키징·서명·배포·캘린더 트리거·검색·내보내기는 전부 후속 spec의 범위다.

### 0.1 제품 원칙 (Strategy §12)

이 원칙들은 모든 UX 결정·카피·기능 우선순위의 상위 기준이다. 충돌 시 원칙이 이긴다.

1. **메모의 기준은 완성도가 아니라 존재 여부다.**
2. **사용자의 첫 책임은 캡처, 정리는 시스템의 책임이다.**
3. **AI는 사고를 대체하지 않고 사고를 다시 꺼내게 한다.**
4. **스트릭은 죄책감이 아니라 회복을 위한 장치다.**
5. **개인 메모는 팀 지식의 씨앗이다.**

원칙 1·2·3은 본 slice에 직접 반영된다. 원칙 4는 Weekly Continuity와 회복 친화 카피로 반영. 원칙 5는 Confluence 내보내기 spec(후속)에서 본격 다룸.

---

## 1. 범위

### 1.1 In-scope

- **플랫폼:** Windows + macOS Electron 빌드. Windows를 dogfood 우선으로 하고 macOS는 빌드 통과 및 기본 동작 수준을 유지.
- **Quick Capture:** 전역 단축키 `Ctrl/Cmd+Shift+J`, 단일 입력창. 텍스트 + 클립보드 붙여넣기 이미지 입력.
- **저장:** 로컬 SQLite 단일 프로필(`default`). 미디어는 프로필 디렉터리 `media/` 하위 파일로 저장.
- **AI 파이프라인:** 비동기 처리. `LocalOllamaProvider` 1개 구현체, endpoint는 `INKLING_OLLAMA_ENDPOINT` 환경변수 또는 생성자 인자로 주입(미지정 시 `http://localhost:11434` 폴백). `gemma4:e4b` (8.0B Q4_K_M, 효율 변형) 표준 티어 고정 — 공식 9B 체크포인트 부재 시 가장 근접한 대체. 출력은 제목 + 3줄 요약 + 태그 최대 3개.
- **Inbox:** 날짜 내림차순 리스트. 카드에 제목·요약·태그·원문·미디어 썸네일. AI 필드 인라인 편집 및 노트 삭제 지원. 원문은 읽기 전용.
- **즉시 보상 토스트 (Strategy §4.1):** Quick Capture 저장 직후 OS 네이티브 알림으로 회전 카피 1개 표시 ("이 생각은 이제 Inkling이 들고 있습니다." 등 4종).
- **AI 제안형 라벨 (Strategy §7-원칙4):** AI가 생성한 제목·태그 옆에 작은 "AI" 회색 라벨. 사용자가 편집한 필드에서는 라벨이 사라져 "내 메모" 정체성 식별 가능.
- **"의미 한 줄" 배너 (Strategy §2.2, §7-원칙2):** AI가 `done` 전환 시 카드 상단에 1회 노출. 회전 4종 중 1개 ("내일의 내가 알아야 할 것은?" 등). 입력 시 카드에 💡 배지로 영구 노출, 건너뛰기 시 해당 카드에서는 다시 안 띄움. `notes.user_intent`·`notes.intent_prompted_at` 컬럼 사용.
- **Weekly Continuity 스트릭 (Strategy §5):** Daily streak 폐기. KST 월~일 주 단위, 목표 7건/주(요일 무관, 같은 날 누적 OK). Inbox 상단 배지: 미달성 "이번 주 N/7", 달성 "이번 주 7/7 ✓ · 연속 K주 완성". 마지막 기록 후 7일 이상 갭 후 첫 기록 시 24시간 동안 "🌱 흐름을 다시 이어갑니다" 토스트.
- **회복 친화 카피 정책 (Strategy §5, §12-원칙4):** "실패"·"끊김"·"연속 실패" 단어 금지. 0건 상태도 "이번 주 한 줄이면 시작입니다". 모든 사용자 대면 문구는 §5의 카피 카탈로그 따름.

### 1.2 Out-of-scope (후속 spec에서 다룸)

기존 항목:
- 음성 입력 / STT
- 관련 메모 링크 / 프로젝트 자동 분류 / 벡터 검색
- 푸시·스케줄러·캘린더 어댑터·모닝/이브닝 프롬프트
- 다중 프로필 / 비밀번호 잠금 / 마스킹 레이어
- 별도 `LanOllamaProvider` 클래스 / 외부 API Provider / BYOK 키 관리 (LAN Ollama는 `LocalOllamaProvider` endpoint 주입 방식으로 slice 범위 내)
- Confluence 내보내기 / 프로필 zip 내보내기·재가져오기
- 주간 회고 / 뱃지 / 온보딩 위자드
- 코드 서명·공증·자동 업데이트·배포 파이프라인
- Android 보조 앱

Strategy 문서에서 추가로 명시 이관 (Strategy 절 번호와 함께):
- **실행 의도(if-then) 온보딩** (§3) — 별도 온보딩 위자드 spec
- **상황별 최소 템플릿** (회의/트러블슈팅/요청/의사결정/인수인계, §6) — QuickCapture v2 spec
- **66일 단계별 습관 프로그램** (§9) — 알림 빈도·프롬프트 변화 spec
- **심리학 기반 KPI 측정 + 월 1회 정성 설문** (§10) — 분석 인프라 spec
- **A/B 테스트 인프라** (§11) — 실험 플랫폼 spec
- **Confluence 공유 후보 추천 + 실패→주의 카피 변환** (§8) — Confluence export spec
- **Triggered Capture** (회의 종료·퇴근 직전, §3) — 캘린더 어댑터 spec

### 1.3 종료 조건

- Windows에서 저자 본인이 하루 5건 이상 실제 dogfood를 2주 연속 수행.
- AI 결과 체감 품질 검토: 한국어 제목·요약이 10건 중 7건 이상 사용자가 받아들일 수 있는 수준.
- 앱 재시작 후 데이터 무결성 유지, 수동 사용 기준 크래시 0회.

---

## 2. 아키텍처

### 2.1 프로세스 구조 (Electron 표준 main/renderer)

```
┌─ Renderer (React + TS) ──────────────────┐
│  - QuickCaptureWindow (frameless popup)  │
│  - InboxWindow (main)                    │
│  - 상태 관리: Zustand                    │
└──────────────┬───────────────────────────┘
               │ IPC (typed, contextBridge)
┌──────────────▼───────────────────────────┐
│ Main process (Node)                      │
│  ┌──────────────────┐                    │
│  │ HotkeyService    │ globalShortcut 등록│
│  ├──────────────────┤                    │
│  │ CaptureService   │ 입력 수신·저장 오케│
│  ├──────────────────┤                    │
│  │ NoteRepository   │ SQLite CRUD        │
│  │                  │  (better-sqlite3)  │
│  ├──────────────────┤                    │
│  │ MediaStore       │ 이미지 → 파일 저장 │
│  ├──────────────────┤                    │
│  │ AiWorker         │ 비동기 큐, 재시도  │
│  │  └─ InferenceProvider ◄── LocalOllama │
│  ├──────────────────┤                    │
│  │ StreakService    │ SQL 집계           │
│  └──────────────────┘                    │
└──────────────────────────────────────────┘
               │ HTTP
┌──────────────▼───────────────────────────┐
│ Ollama ({endpoint}, gemma4:e4b)          │
└──────────────────────────────────────────┘
```

### 2.2 모듈 책임과 경계

- **HotkeyService** — `Ctrl/Cmd+Shift+J` 등록, 중복·충돌 감지, 이벤트를 `QuickCaptureWindow.show()`로 연결. OS별 분기는 이 모듈 안에 격리한다.
- **CaptureService** — QuickCapture에서 들어온 페이로드(`{text, images[]}`)를 받아 단일 트랜잭션으로 `NoteRepository.create` + `MediaStore.save`를 실행하고, 이어서 `AiWorker.enqueue(noteId)`를 호출한 뒤 `NotificationService.celebrate(noteId)`를 호출한다. 저장 성공 시점에 창 닫힘 응답을 반환한다.
- **NoteRepository** — `notes`·`note_tags`·`tags` 테이블 접근을 전담. `user_intent`, `intent_prompted_at`, `title_edited_by_user`, `summary_edited_by_user` 컬럼 관리도 여기 포함. SQL 문자열이 이 모듈 밖으로 새지 않는다. `better-sqlite3`(동기 드라이버, 단일 프로세스에 적합)를 사용한다.
- **MediaStore** — 클립보드 PNG 바이트를 `{profileDir}/media/{noteId}/{uuid}.png` 형태로 파일 저장하고, 상대 경로만 DB에 기록한다.
- **AiWorker** — in-memory FIFO 큐 + SQLite `pending_jobs` 테이블(앱 재시작 시 재적재용). 동시 처리 1개. Ollama 호출 실패/타임아웃을 지수 백오프로 3회 재시도 후 노트를 `failed`로 전이. `pending` 상태의 작업만 재시작 시 자동 재적재되며, 이미 `failed`로 전이된 노트는 slice 범위에서는 수동·자동 어느 쪽으로도 다시 처리되지 않는다.
- **InferenceProvider 인터페이스** — §4 참조. 본 slice의 유일한 구현체는 `LocalOllamaProvider`.
- **StreakService** — Weekly Continuity 계산 전담. `notes.created_at`을 KST 기준으로 그룹화해 이번 주 건수, 연속 완성 주(consecutive complete weeks), 회복 토스트 노출 여부(`showRecoveryToast`)를 산출. 순수 읽기 작업.
- **NotificationService** — OS 네이티브 알림(`new Notification(...)`) 발사 전담. 회전 카피 4종 중 1개 선택 (noteId 해시 mod 4). 권한 거부 시 무음 폴백. Strategy §4.1 즉시 보상 책임.
- **IntentService** — `setIntent(noteId, text)` / `dismissIntent(noteId)` 두 메서드만. `notes.user_intent`와 `notes.intent_prompted_at` 갱신 책임을 격리. UI(IntentBanner)에서 IPC를 통해 호출. 회전 프롬프트 4종 중 noteId 해시 mod 4로 결정론적 선택.

### 2.3 IPC 계약 (typed preload)

```ts
captureApi: {
  submit(payload: {text: string; images: ArrayBuffer[]}): Promise<{noteId: string}>;
  hide(): void;
}
inboxApi: {
  listNotes(opts: {limit: number; cursor?: string}): Promise<Note[]>;
  updateAiFields(
    noteId: string,
    fields: {title?: string; summary?: string; tags?: string[]}
  ): Promise<void>;
  deleteNote(noteId: string): Promise<void>;
  getContinuity(): Promise<WeeklyContinuity>;
  getPendingCount(): Promise<number>;
  getOllamaStatus(): Promise<{ok: boolean; reason?: string}>;
  setIntent(noteId: string, text: string): Promise<void>;
  dismissIntent(noteId: string): Promise<void>;
  onNoteUpdated(cb: (note: Note) => void): () => void;
}

// Continuity / intent types
interface WeeklyContinuity {
  weekStart: string;                    // ISO date, KST 월요일
  weekCount: number;                    // 이번 주 메모 건수
  weekTarget: number;                   // 7
  consecutiveCompleteWeeks: number;     // 7건 채운 주 연속 횟수
  showRecoveryToast: boolean;           // 갭≥7일 후 첫 기록인지
}
```

`onNoteUpdated`는 AiWorker가 노트 상태를 전이할 때마다 푸시한다. 낙관적 업데이트는 하지 않으며, pending 카드는 "Inkling이 정리하는 중…" 상태로 두었다가 완료 이벤트 수신 시 부분 리렌더로 교체한다. `done` 전환된 카드 중 `intent_prompted_at IS NULL`인 것은 IntentBanner를 1회 노출하고, 사용자가 setIntent 또는 dismissIntent 호출 시 `intent_prompted_at`이 기록되어 다시 노출되지 않는다.

IntentBanner의 회전 4종 프롬프트는 renderer 측에 상수 배열로 보유하고 noteId 해시 mod 4로 결정론적으로 선택한다. IPC 라운드트립이 필요 없는 순수 클라이언트 계산이므로 contract에 노출하지 않는다.

### 2.4 데이터 디렉터리

- Windows: `%APPDATA%\Inkling\profiles\default\`
- macOS: `~/Library/Application Support/Inkling/profiles/default/`

Electron의 `app.getPath('userData')`를 기반으로 경로를 구성한다. 각 프로필 디렉터리는 `inkling.sqlite` 단일 DB 파일과 `media/` 하위 디렉터리를 포함한다.

---

## 3. 데이터 모델

### 3.1 SQLite 스키마 (v1)

```sql
-- 메모 본문
CREATE TABLE notes (
    id                       TEXT PRIMARY KEY,           -- UUID v7
    raw_text                 TEXT NOT NULL,              -- 원본. 불변 (기획서 §4.2)
    ai_title                 TEXT,                       -- AI 생성, 사용자 편집 가능
    ai_summary               TEXT,                       -- AI 생성, 사용자 편집 가능
    ai_status                TEXT NOT NULL
                             CHECK (ai_status IN ('pending','done','failed')),
    ai_error                 TEXT,                       -- 실패 사유 (디버그용)
    ai_provider              TEXT,                       -- 'local-ollama/gemma4:e4b'
    ai_generated_at          TEXT,                       -- ISO8601
    title_edited_by_user     INTEGER NOT NULL DEFAULT 0  -- 0/1, AI 라벨 표시 토글
                             CHECK (title_edited_by_user IN (0,1)),
    summary_edited_by_user   INTEGER NOT NULL DEFAULT 0
                             CHECK (summary_edited_by_user IN (0,1)),
    user_intent              TEXT,                       -- "의미 한 줄" (Strategy §2.2)
    intent_prompted_at       TEXT,                       -- 배너 노출/처리된 시점 (NULL=아직 미노출)
    created_at               TEXT NOT NULL,              -- ISO8601 UTC
    updated_at               TEXT NOT NULL
);
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
CREATE INDEX idx_notes_ai_status  ON notes(ai_status);

-- 태그 (정규화)
CREATE TABLE tags (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    name  TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE note_tags (
    note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    tag_id  INTEGER NOT NULL REFERENCES tags(id),
    source  TEXT NOT NULL CHECK (source IN ('ai','user')),
    PRIMARY KEY (note_id, tag_id)
);

-- 미디어 (slice에선 이미지만)
CREATE TABLE media (
    id          TEXT PRIMARY KEY,
    note_id     TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    kind        TEXT NOT NULL CHECK (kind IN ('image')),
    rel_path    TEXT NOT NULL,                  -- media/{note_id}/{uuid}.png
    mime        TEXT NOT NULL,
    bytes       INTEGER NOT NULL,
    created_at  TEXT NOT NULL
);
CREATE INDEX idx_media_note_id ON media(note_id);

-- AI 작업 큐 (재기동 시 재적재)
CREATE TABLE pending_jobs (
    note_id      TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
    attempts     INTEGER NOT NULL DEFAULT 0,
    next_run_at  TEXT NOT NULL,
    last_error   TEXT
);

-- 스키마 버전
CREATE TABLE schema_migrations (
    version    INTEGER PRIMARY KEY,
    applied_at TEXT NOT NULL
);
```

### 3.2 파일시스템 레이아웃

```
{userData}/Inkling/profiles/default/
├── inkling.sqlite
├── inkling.sqlite-wal        # WAL 모드 활성
├── inkling.sqlite-shm
└── media/
    └── {note_id}/
        └── {uuid}.png
```

### 3.3 핵심 불변 조건

- `raw_text`는 저장 이후 수정 쿼리가 존재하지 않는다. `NoteRepository`에 원문 업데이트 메서드를 만들지 않는 것으로 이 불변을 보장한다.
- `ai_status='pending'` 행은 반드시 `pending_jobs`에 대응 엔트리가 존재한다. `CaptureService.create`는 두 insert를 동일 트랜잭션 안에서 수행한다.
- 노트 삭제는 DB 트랜잭션 수행 후 미디어 디렉터리 제거 순서로 진행한다. 미디어 삭제 실패로 생긴 고아 파일은 기동 시 GC가 청소한다.
- 태그는 `source='ai'`와 `source='user'`를 구분해 저장한다. 사용자가 AI 태그를 제거해도 `tags` 테이블에서 지우지 않고 `note_tags` 행만 삭제한다(태그 이름 재사용 가능).
- `title_edited_by_user`/`summary_edited_by_user`는 사용자가 해당 필드를 한 번이라도 편집하면 `1`로 전이되며, 이후 절대 `0`으로 되돌아가지 않는다. AI가 같은 필드를 재생성하지 않으므로 (slice에는 재처리 기능 없음) 안전하다.
- `intent_prompted_at`은 `setIntent` 또는 `dismissIntent` 호출 시 1회 기록되며, 이후 갱신되지 않는다. NULL 여부가 IntentBanner 노출 게이트.

### 3.4 마이그레이션

단방향 버전 번호를 사용한다. Slice 출시 시점은 v1. 앱 기동 시 `PRAGMA user_version`을 읽어 필요한 순차 마이그레이션을 실행한다. 다운그레이드는 지원하지 않는다.

### 3.5 쿼리 핫스팟

- Inbox 리스트: `SELECT ... FROM notes ORDER BY created_at DESC LIMIT 50 OFFSET ?` + 태그·미디어를 별도 fetch(N+1 허용, 페이지당 50행 기준 실용 문제 없음).
- Weekly Continuity: `SELECT created_at FROM notes ORDER BY created_at ASC` 결과를 KST 월~일 주 단위로 그룹화. 이번 주 카운트와 연속 완성 주(consecutive complete weeks) 계산. 회복 토스트는 `MAX(created_at)` 한 행 추가 조회.

---

## 4. AI 파이프라인

### 4.1 Provider 계약

```ts
interface InferenceProvider {
  readonly name: string;            // 'local-ollama/gemma4:e4b'
  generate(input: GenerateInput): Promise<GenerateResult>;
  healthCheck(): Promise<HealthResult>;
}

interface GenerateInput {
  text: string;                     // 원문. slice에선 이미지 미포함.
}

interface GenerateResult {
  title: string;                    // 한국어, ≤ 60자
  summary: string;                  // 한국어, 3줄, 각 ≤ 120자, "\n" 구분
  tags: string[];                   // 0~3개, 영문 kebab-case
}

interface HealthResult {
  ok: boolean;
  model?: string;
  reason?: string;
}
```

### 4.2 LocalOllamaProvider 구현

- 엔드포인트: `POST {endpoint}/api/generate`. `endpoint`는 생성자 인자(`{endpoint: string}`)로 주입하며, 미지정 시 `http://localhost:11434`로 폴백. dogfood에서 LAN Ollama를 쓰기 위해 Task 30 main 엔트리에서 `process.env.INKLING_OLLAMA_ENDPOINT`를 전달.
- 요청 본문: `{model: "gemma4:e4b", prompt: <§4.3>, format: "json", stream: false, options: {temperature: 0.2, num_predict: 512}}`
- 타임아웃: **120초** (AbortController).
- 응답 파싱: `response` 필드를 JSON.parse한 뒤 zod 스키마로 검증. 실패 시 상위에 throw(AiWorker가 재시도를 결정).

### 4.3 프롬프트

```
You organize raw personal notes into structured metadata.

Input note (raw text, may be fragmented, any language):
---
{raw_text}
---

Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only,
  e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.

Rules:
- title and summary MUST be written in Korean regardless of input language.
- tags MUST be English kebab-case (for consistency across notes; easier to search/group).
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.
```

### 4.4 응답 검증 규칙

- `title`: 한국어 문자 1개 이상 포함(정규식 `/[가-힣]/`). 미포함이면 프롬프트 위반으로 간주하고 재시도 대상으로 삼는다.
- `summary`: 줄 수가 정확히 3줄이 아니면 빈 줄을 보정하거나 과잉 줄을 합쳐 3줄로 정규화한다. 0줄이면 실패 처리.
- `tags`: 각 태그가 `^[a-z0-9]+(-[a-z0-9]+)*$`를 만족하는지 검사. 위반 태그는 필터링하고, 남은 태그만 저장. 전체 실패는 아니다.

### 4.5 AiWorker 동작

- **시작 시점:** 앱 기동 직후 `pending_jobs` 전체를 큐에 적재.
- **동시성:** 1건(로컬 Ollama 자원 경합 회피).
- **재시도 백오프:** 즉시 / 30초 / 120초. 3회 실패 시 `notes.ai_status='failed'`로 전이하고 `pending_jobs`에서 삭제.
- **성공 트랜잭션:**
  1. `UPDATE notes SET ai_title, ai_summary, ai_status='done', ai_provider, ai_generated_at=NOW(), updated_at=NOW() WHERE id=?`
  2. `DELETE FROM note_tags WHERE note_id=? AND source='ai'` 후 새 태그 `INSERT` (멱등).
  3. `DELETE FROM pending_jobs WHERE note_id=?`
- **완료 이벤트:** IPC `onNoteUpdated`로 갱신된 노트 푸시.

### 4.6 헬스 체크 (앱 기동 시 1회)

- `GET /api/tags` 호출로 `gemma4:e4b`가 설치되어 있는지 확인.
- 모델 미설치 → Inbox 상단 배너: "`ollama pull gemma4:e4b` 실행 후 앱을 재시작하세요."
- 엔드포인트 무응답 → 배너: "Ollama가 실행되지 않았습니다. `ollama serve`를 실행해주세요." Capture는 계속 허용되며 노트는 `pending` 상태로 누적된다.
- 자동 pull은 slice에서 제공하지 않는다.

### 4.7 배제 항목 (후속 spec에서 다룸)

- 이미지 멀티모달 입력 (Gemma 4는 텍스트 전용으로 가정; 비전 모델 도입은 별도 spec).
- 프롬프트 A/B, 사용자 피드백 기반 튜닝.
- 외부 API Provider, LAN Ollama Provider.
- 마스킹 레이어 (로컬 전용이므로 slice에서는 비활성; 기획서 §6.5 마스킹 참조).

---

## 5. UX 플로우

### 5.1 Quick Capture

```
[Any app/screen]
   │ Ctrl/Cmd+Shift+J
   ▼
[QuickCaptureWindow popup]
   - frameless, always-on-top, 640x280
   - 화면 중앙, 포커스 자동 획득
   - 단일 textarea + 썸네일 스트립 (이미지 있을 때만)
   - placeholder: "지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
   - 하단 힌트: "Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기"
   │
   ├─ 사용자 타이핑 / Ctrl+V (이미지)
   │    └─ 클립보드 이미지 감지 → 썸네일 추가 (메모리 버퍼)
   │
   ├─ Ctrl+Enter → submit
   │    - CaptureService.submit({text, images})
   │    - 트랜잭션: notes insert + media 저장 + pending_jobs insert
   │    - 성공 시 창 즉시 닫힘 (AI 완료 대기 없음)
   │    - 닫힘 직후 NotificationService.celebrate(noteId) 발사 (OS 네이티브 알림)
   │    - 실패 시 창 내 빨간 토스트, 내용 유지
   │
   └─ Esc → 취소 (5자 이상이면 "이 한 줄을 흘려보낼까요?" 1-step 확인)
```

**타이밍 목표**

- 단축키 → 창 표시: p95 < 100ms (숨겨진 창을 `show/hide` 토글, 매번 생성하지 않음).
- Submit → 창 닫힘: p95 < 300ms (DB 트랜잭션만 동기 대기, AI·알림은 비동기).

**저장 직후 즉시 보상 토스트 (Strategy §4.1)**

- 발사 시점: `CaptureService.submit` 트랜잭션 성공 직후.
- 채널: OS 네이티브 알림 (`new Notification(title, {body, silent})`). Inbox in-app 토스트 아님 — 사용자가 작업 중인 다른 앱 위에 즉시 떠야 보상 즉시성을 살림.
- 회전 카피 4종 (noteId 해시 mod 4로 결정론적 선택):
  1. "이 생각은 이제 Inkling이 들고 있습니다."
  2. "머릿속에서 꺼내 두었습니다."
  3. "방금 한 줄 잡아뒀습니다."
  4. "기록 완료. 이제 잊어도 됩니다."
- 권한: 첫 submit 시 OS 권한 다이얼로그. 거부 시 무음 폴백 (캡처는 정상 동작).
- 알림 본문에는 `raw_text`나 `ai_title`을 포함하지 않는다. 오직 회전 카피만 표시 (PII 보호와 일관성).

### 5.2 Inbox

```
[InboxWindow] — 트레이 아이콘 클릭으로 열림, 상시 실행
┌──────────────────────────────────────────────────┐
│ Inkling                이번 주 4/7 · 연속 2주 완성 │  ← ContinuityBadge
├──────────────────────────────────────────────────┤
│ [🌱 흐름을 다시 이어갑니다]                       │  ← 회복 토스트 (24h, 갭≥7일 후만)
│ [🟡 Inkling이 정리하는 중: 3건]                  │  ← pending count (0이면 숨김)
│ [Ollama 상태 경고가 있으면 여기 배너]            │
├──────────────────────────────────────────────────┤
│ ┌ 2026-04-24 18:32 ────────────────────────────┐ │
│ │ 회의 A프로젝트 이슈 정리   [AI]              │ │  ← 제목 + AI 라벨 (편집 안 됨)
│ │ 첫 줄 요약                                    │ │
│ │ 둘째 줄 요약                                  │ │
│ │ 셋째 줄 요약                          [AI]    │ │
│ │ [api-timeout AI] [meeting AI]                │ │  ← 태그 + AI 표시
│ │ 💡 다시 만나면 trace 먼저 확인               │ │  ← user_intent (입력된 경우)
│ │ [썸네일] 🖼 🖼                                │ │
│ │ ▸ 원문 보기 (토글, 기본 접힘)                │ │
│ │                                  [🗑 삭제]    │ │
│ └──────────────────────────────────────────────┘ │
│ ┌ 2026-04-24 18:10 ────────────────────────────┐ │
│ │ ┌ IntentBanner (1회 노출) ─────────────────┐ │ │  ← intent_prompted_at IS NULL
│ │ │ 💭 내일의 내가 알아야 할 것 한 줄?       │ │ │
│ │ │ [한 줄 입력...] [건너뛰기]               │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ 트러블슈팅 timeout                            │ │
│ │ ...                                           │ │
│ └──────────────────────────────────────────────┘ │
│ ┌ 2026-04-24 17:10 ────────────────────────────┐ │
│ │ Inkling이 정리하는 중…                        │ │  ← ai_status='pending'
│ │ (원문 자동 펼침)                              │ │
│ └──────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────┤
│ [더 보기]  (50개씩 페이지네이션)                 │
└──────────────────────────────────────────────────┘
```

**상태별 카드 렌더링**

- `pending` — 제목 자리에 "Inkling이 정리하는 중…" 스피너. 요약·태그 숨김, 원문 자동 펼침. IntentBanner 미노출.
- `done` — 제목·요약·태그 표시, 원문 접힘. AI 라벨은 사용자가 편집하지 않은 필드에만 노출. `intent_prompted_at IS NULL`이면 카드 상단에 IntentBanner 1회 노출.
- `failed` — 제목 자리에 "정리 보류 — 원문은 안전합니다" + 사유 hover tooltip. 원문 자동 펼침. 재시도 버튼은 slice 외. IntentBanner 미노출.

**AI 제안형 라벨 (Strategy §7-원칙4)**

- 제목 옆 작은 회색 "AI" 배지: `title_edited_by_user=0`일 때만 표시.
- 요약 카드 우측 하단 "AI" 배지: `summary_edited_by_user=0`일 때만 표시.
- 태그 옆 "AI" 첨자: `note_tags.source='ai'`인 태그만. 사용자 편집/추가 태그는 라벨 없음.
- 라벨은 클릭 불가 (장식). hover tooltip: "AI 제안 — 클릭하여 편집".

**IntentBanner (Strategy §2.2)**

- 노출 조건: `note.aiStatus === 'done' && note.intentPromptedAt === null`.
- 프롬프트 회전 4종 (noteId 해시 mod 4로 결정론적):
  1. "내일의 내가 이 메모에서 꼭 알아야 할 것은?"
  2. "이 메모가 중요한 이유를 한 줄로?"
  3. "이 문제를 다시 만나면 무엇을 먼저 확인할까요?"
  4. "동료에게 공유한다면 제목을 뭐라고?"
- 입력 → `setIntent(noteId, text)` IPC. 성공 시 배너 사라지고 카드에 "💡 {intent}" 영구 배지로 노출.
- 건너뛰기 → `dismissIntent(noteId)`. 배너 사라지고 다시 안 보임.
- 두 경로 모두 `intent_prompted_at`을 현재 시각으로 기록.
- 입력 시 한 줄 ≤ 200자 강제 (오버는 잘림).
- 💡 배지 클릭 시 인라인 편집 가능 (EditableField 재사용).

**편집 동작**

- 제목·요약 필드는 클릭 시 `contentEditable`로 전환, blur 시점에 저장(`updateAiFields` IPC). 저장 시 해당 필드의 `*_edited_by_user`가 1로 전이.
- 태그 클릭 시 제거(source 무관). 태그 추가 UI는 slice 외.
- 저장 실패 시 이전 값 복구 + 빨간 테두리 1회 플래시.

**Continuity 배지 (Strategy §5)**

- 0건: "이번 주 한 줄이면 시작입니다"
- 1~6건: "이번 주 N/7"
- 7건 이상: "이번 주 7/7 ✓ · 연속 K주 완성" (K=consecutiveCompleteWeeks)
- 모든 카피에서 "실패", "끊김", "연속 실패" 단어 금지 (회복 친화 카피 정책).

**회복 토스트**

- 조건: 마지막 메모로부터 ≥7일 갭 후 첫 기록 시.
- 노출: Inbox 상단 in-app 배너 (Continuity 배지와 별도). "🌱 흐름을 다시 이어갑니다".
- 표시 기간: 24시간 또는 사용자가 닫기까지. 닫기 후 같은 회복 사건으로 재노출 안 됨 (`localStorage`에 dismiss 시점 저장).

**삭제**

- 🗑 클릭 → 네이티브 confirm 다이얼로그 ("이 기억을 버릴까요? 되돌릴 수 없습니다."). 확인 시 `deleteNote` 호출 후 카드 제거 및 미디어 파일 삭제.

**실시간 업데이트**

- `onNoteUpdated` 수신 시 해당 카드만 부분 리렌더. 스크롤 위치 유지.

### 5.3 첫 실행

Slice는 온보딩 위자드를 제공하지 않는다.

1. 최초 기동 시 `{userData}/Inkling/profiles/default/`가 자동 생성된다.
2. Inbox 창이 열리고 빈 상태 메시지("머릿속에 떠다니는 한 줄을 적어보세요. `Ctrl+Shift+J`")가 노출된다.
3. Ollama 헬스 체크가 실행되고 결과에 따라 배너가 표시된다.

### 5.4 카피 카탈로그

본 slice의 모든 사용자 대면 문구는 다음 카탈로그를 따른다. 추가 문구가 필요하면 동일 톤(회복 친화·인지 부담 감소·정체성 강화)을 유지한다.

| 위치 | 문구 |
|------|------|
| QuickCapture placeholder | 지금 머릿속에 있는 것 한 줄. 정리는 나중입니다. |
| QuickCapture hint | Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기 |
| QuickCapture 제출 실패 | 저장에 실패했습니다. 다시 시도해주세요. |
| QuickCapture 취소 confirm (5자+) | 이 한 줄을 흘려보낼까요? |
| Inbox 빈 상태 | 머릿속에 떠다니는 한 줄을 적어보세요. `Ctrl+Shift+J` |
| Inbox 처리 중 카드 | Inkling이 정리하는 중… |
| Inbox AI 실패 카드 | 정리 보류 — 원문은 안전합니다 |
| 트레이 메뉴: Quick Capture | 한 줄 적기 |
| 트레이 메뉴: Inbox 열기 | 보관한 메모 보기 |
| 트레이 메뉴: Quit | 종료 |
| 삭제 confirm | 이 기억을 버릴까요? 되돌릴 수 없습니다. |
| Continuity 0건 | 이번 주 한 줄이면 시작입니다 |
| Continuity 1~6건 | 이번 주 N/7 |
| Continuity 7건+ | 이번 주 7/7 ✓ · 연속 K주 완성 |
| 회복 토스트 | 🌱 흐름을 다시 이어갑니다 |
| 즉시 보상 토스트 (회전 4종) | (§5.1 참조) |
| IntentBanner 프롬프트 (회전 4종) | (§5.2 참조) |
| Ollama 미실행 배너 | Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요. |
| Ollama 모델 미설치 배너 | `ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요. |
| 단축키 등록 실패 배너 | 단축키 `Ctrl+Shift+J`를 다른 앱이 사용 중입니다. 충돌 앱을 닫거나 FAQ를 확인하세요. |

### 5.5 앱 종료·재시작

- 창 닫기(X)는 트레이로 숨기는 동작이며 앱은 계속 실행된다. 실제 종료는 트레이 메뉴의 "종료" 항목으로 수행한다.
- 재시작 시 `pending_jobs`를 큐에 재적재하여 AiWorker 처리가 이어진다.
- 회복 토스트 dismiss 상태는 renderer `localStorage`에 저장되어 재시작 후에도 유지된다.

---

## 6. 오류 처리·관찰성·테스트

### 6.1 실패 모드 카탈로그

| # | 지점 | 실패 | 사용자 영향 | 복구 전략 |
|---|------|------|-------------|----------|
| 1 | 단축키 등록 | 다른 앱이 `Ctrl+Shift+J` 점유 | Capture 진입 불가 | 기동 로그 기록 + Inbox 배너 표시. 설정 UI는 slice 외이므로 FAQ로 대체 안내. |
| 2 | Quick Capture submit | DB write 실패(디스크 full, lock) | 메모 소실 위험 | 창 내 빨간 토스트 + 내용 유지. 재시도는 사용자가 Ctrl+Enter 재입력. |
| 3 | 미디어 저장 | 파일 쓰기 실패 | 이미지 누락 | 미디어를 먼저 쓰고 성공 후에만 노트 insert. 미디어 실패 시 노트 저장 자체를 중단. |
| 4 | Ollama 미실행/미설치 | `/api/tags` 실패 | 노트는 저장되지만 pending 누적 | Inbox 배너 + 큐가 지수 백오프로 계속 시도. |
| 5 | Ollama 타임아웃 (>120s) | AbortError | 3회 실패 시 노트 `failed` | `ai_error` 기록. 수동 재처리는 slice 외. |
| 6 | Ollama 응답 스키마 위반 | zod parse 실패 | 3회 실패 시 `failed` | 위반 응답 최대 500자를 `last_error`에 저장. |
| 7 | 앱 비정상 종료 | WAL 미체크포인트 | 재기동 시 자동 복구 | `PRAGMA journal_mode=WAL` + SQLite 자체 복구. |
| 8 | 미디어 고아 파일 | DB에는 기록 없으나 파일 잔존 | 디스크 낭비 | 앱 기동 시 GC가 `media/{note_id}/` 중 미등록 디렉터리 제거. |
| 9 | 마이그레이션 실패 | 스키마 충돌 | 앱 기동 불가 | DB 백업 복사본 자동 생성 후 기동 중단, 에러 다이얼로그 표시. |
| 10 | OS 알림 권한 거부 | 즉시 보상 토스트 미발사 | 보상 신호 없음 | 캡처는 정상 동작. 로그에 1회 기록 후 무음 폴백. 재요청 안 함. |
| 11 | IntentBanner 동시 setIntent | 두 탭/창에서 동시 호출 | (slice는 단일 창이라 미발생) | `intent_prompted_at` 컬럼이 NOT NULL이면 후속 setIntent는 user_intent만 갱신, 첫 시각 보존. |

### 6.2 로깅·진단

- 로그 경로: `{userData}/Inkling/logs/main-YYYY-MM-DD.log`. 일별 rotate, 7일 보관.
- 로그 레벨: `info` / `warn` / `error`. `debug`는 환경 변수 `INKLING_DEBUG=1`일 때만 활성.
- PII 보호: 로그에 `raw_text`·`ai_title`·`ai_summary` 본문을 기록하지 않는다. `noteId`, 본문 길이, 해시 prefix(첫 8자)만 기록한다.
- 진단 명령: 트레이 메뉴에 "Export logs" 항목. 최근 7일 로그를 zip으로 묶어 사용자가 선택한 폴더에 저장.

### 6.3 테스트 전략

**단위 테스트 (Vitest)**

- `NoteRepository`: 메모리 SQLite(`:memory:`)로 CRUD·트랜잭션 원자성·원문 불변 계약 검증.
- `MediaStore`: tmp 디렉터리 기반, 경로 충돌 및 바이너리 무결성.
- `AiWorker`: `InferenceProvider` 목으로 주입. 성공·타임아웃·파싱 실패·재시도 간격 시나리오.
- `LocalOllamaProvider`: HTTP 레이어는 `undici` mock agent로 고정 응답 주입.
- 응답 검증기(zod + 한국어 정규식): 영문 제목 반려, kebab-case 위반 태그 필터링.

**통합 테스트 (Vitest, Electron main 없이)**

- 실제 SQLite 파일 + 실제 `LocalOllamaProvider` + 로컬에서 실행 중인 Ollama.
- CI에서는 스킵(로컬 커맨드 `npm run test:integration`으로 수행).
- 골든 케이스 10종(한국어 회의 메모, 영문 스택트레이스, 혼합, 단편, 긴 로그 등): 제목·요약 한국어 여부, 태그 kebab-case 형식 검증.

**E2E 테스트 (Playwright + Electron)**

- Windows · macOS 각각 1회의 smoke run.
- 시나리오: (a) 단축키 → 텍스트 입력 → 저장 → Inbox 노출(pending), (b) AI 완료 대기 → done 상태로 전환 확인, (c) 제목 인라인 편집 후 저장, (d) 삭제 → 카드 제거, (e) 앱 재시작 후 데이터 유지.

**수동 체크리스트 (릴리스 전)**

- 클립보드 이미지 붙여넣기를 양 OS에서 수동 확인.
- Ollama 중단 상태에서 10건 저장 후 재기동 → 큐 재적재 및 처리 확인.
- 디스크 full 재현(tmpfs quota 등)으로 Failure #2 경로 확인.

### 6.4 성능 목표

| 항목 | p95 목표 |
|------|----------|
| 단축키 → Quick Capture 창 표시 | < 100ms |
| Submit → 창 닫힘 | < 300ms |
| AI 완료 (gemma4:e4b Q4_K_M, LAN, 입력 ≤ 500자) | **< 30s 잠정** (서버 GPU·LAN 지연 실측 후 §8에서 확정; 초과해도 failed 아님) |
| Inbox 50건 렌더 | < 200ms |

---

## 7. 기술 스택과 버전 정책

### 7.1 버전 정책

- **Node.js:** 최신 LTS 고정. 구현 시작 시점 기준 **v24.x (Krypton, Latest LTS)**.
- **라이브러리·프레임워크:** 각 의존성의 최신 안정(stable) 메이저를 기본값으로 사용. 의도적 구버전 고정이 필요한 경우(보안 이슈, 알려진 regression 등) 이 문서 하단 §7.3 이슈 로그에 사유 기록.
- **pre-release·RC 금지:** alpha/beta/rc 태그 의존성은 허용하지 않는다. 단, Electron 내부 Chromium/Node 조합에 한해서는 Electron이 번들한 버전을 그대로 사용한다(별도 pin 불가).
- **버전 고정 방법:** `package.json`은 `^` 프리픽스 없이 정확한 버전으로 pin(`"electron": "41.3.0"` 형태). 업그레이드는 PR 단위 명시적 작업으로만 수행.
- **Node 버전 동기화:** 저장소 루트에 `.nvmrc`(예: `24.15.0`) 파일을 둔다. CI·로컬 개발자 모두 `nvm use`로 동일 버전 사용.

### 7.2 초기 pin 후보 (구현 시작 시점에 최신값 재확인)

| 역할 | 패키지 | 메이저 | 비고 |
|------|--------|--------|------|
| 런타임 | Node.js | 24 LTS | `.nvmrc`로 minor까지 고정 |
| 데스크톱 셸 | `electron` | 41 | Chromium + Node 번들 |
| UI | `react`, `react-dom` | 19 | Concurrent features 사용 자유 |
| 언어 | `typescript` | 6 | `strict: true` |
| 상태 관리 | `zustand` | 5 | |
| DB | `better-sqlite3` | 12 | 동기 드라이버 |
| 스키마 검증 | `zod` | 4 | AI 응답 파싱 |
| 단위/통합 테스트 | `vitest` | 4 | |
| E2E | `@playwright/test` | 1.59 | Electron support 포함 |
| HTTP mock (테스트) | `undici` | 8 | |
| 패키징 | `electron-builder` 또는 `electron-forge` | 미정 | §8 오픈 이슈 |

> 실제 `npm install` 시점에 `npm view <pkg> version`으로 최신값을 다시 확인하여 `package.json`에 기록한다. 위 표의 메이저 번호는 2026-04-24 기준 참고값이며, 구현 착수일에 더 높은 stable 메이저가 있으면 그것을 채택한다.

### 7.3 업그레이드 관리

- 의존성 업그레이드는 **메이저 단위로 일괄**이 아닌 **패키지 단위 독립 PR**로 수행한다.
- Electron 메이저 업그레이드는 Chromium·Node 동반 상승을 수반하므로 별도 smoke 체크리스트 재수행을 강제한다.
- `npm outdated`를 월 1회 리뷰하며, 보안 경고(`npm audit`)는 발견 즉시 처리.

### 7.4 개발 환경 전제

- Windows 개발 머신에 **Volta**(권장) 또는 **nvm-windows**로 Node 24 LTS 설치. macOS/Linux는 **nvm** 사용.
- **AI Provider 엔드포인트:** `INKLING_OLLAMA_ENDPOINT` 환경변수로 Ollama 서버 URL 지정 (예: `http://192.168.0.47:11434`). 미지정 시 `http://localhost:11434` 폴백. LAN 서버 사용 시 User 환경변수에 등록 후 앱 실행.
- 저장소 최초 체크아웃 흐름:
  1. `volta install node@24.15.0` 또는 `nvm install` → `.nvmrc` 기반 설치·활성화.
  2. `npm ci` → 락파일에 고정된 정확한 트리 복원.
  3. 구현 착수 시점의 실제 버전이 §7.2 표와 어긋나면 이 문서를 PR로 갱신한다.

---

## 8. 오픈 이슈 / 차기 spec으로 이관

기존 항목:
- **단축키 충돌 시 변경 UI**: 설정 화면이 slice에 없으므로 FAQ/환경 변수로 임시 대응. 후속 spec에서 정식 설정 UI로 해결.
- **AI 결과 수동 재처리 버튼**: dogfood 중 프롬프트 튜닝 편의가 필요하면 개발자 전용 debug 메뉴로 먼저 도입 검토.
- **Gemma 4 모델 가용성 (확인 완료 2026-04-25)**: 공식 `gemma4:9b` 체크포인트 부재 확인. LAN 서버(`192.168.0.47:11434`)에 설치된 `gemma4:e4b` (8.0B Q4_K_M, 효율 변형)로 표준 티어 운용 — 9B 표준 티어에 가장 근접. e variant는 동일 파라미터 클래스의 효율 변형으로, 한국어 출력 품질·kebab-case 태그 형식 준수가 dogfood 1주차 검증 대상. 품질 부족 확인 시 `gemma4:26b`(고품질, 25.8B Q4_K_M)로 상향, 속도 우선 시 `gemma4:e2b`(경량, 5.1B)로 하향. 기획서(`inkling.md`) §6.5의 "표준=9b" 권장 문구는 9B 공식 체크포인트 출시 여부 재확인 후 별도 조정.
- **§6.4 AI 완료 p95 실측 (신규)**: 26B Q4_K_M + LAN 조합의 실측 레이턴시 확보 후 p95 목표 확정. 잠정 < 60s. dogfood 첫 주 중 측정 후 본 문서에 기록.
- **Electron 패키징 옵션 결정**: `electron-builder` vs `electron-forge`. Slice 바이너리는 dev 빌드로 충분하므로 후속 배포 spec에서 결정.
- **Node 24 LTS → 이후 LTS 전환**: Node 24 Maintenance 진입 시점(로드맵상 2027년대)에 다음 짝수 메이저로 이관. 시점 도래 시 별도 spec.

Strategy v0.2 추가 이관 (구현·실험 시점이 slice 이후로 미뤄진 항목):
- **실행 의도(if-then) 온보딩 위자드** (Strategy §3) — 첫 실행 3분 위자드, 2개 트리거 선택, Capture → Clarify → Capitalize 행동 모델 노출.
- **상황별 최소 템플릿** (Strategy §6) — 회의/트러블슈팅/요청/의사결정/인수인계 5종, 2~4칸 입력 카드. QuickCapture v2.
- **66일 단계별 습관 프로그램** (Strategy §9) — Week 0/1-2/3-4/5-8/9+ 단계별 알림 빈도·프롬프트 자동 변화. 사용자별 진행 상태 저장 필요.
- **심리학 기반 KPI 측정** (Strategy §10) — Time to First Note, MVN Rate, Triggered Capture Rate, Reuse Rate, Confluence Candidate Acceptance + 월 1회 정성 설문 (인지 오프로딩, 자기효능감, 정체성 변화).
- **A/B 테스트 인프라** (Strategy §11) — 4종 실험 (빈창 vs 템플릿 / 스트릭형 vs 가치형 피드백 / 의미 질문 유무 / 매일 vs 주 7회).
- **Confluence 공유 후보 추천** (Strategy §8) — AI가 문제·해결·근거·재발 방지 패턴을 가진 메모를 후보로 분류. 실패 단어 → "주의할 점" 카피 변환.
- **Triggered Capture** (Strategy §3, 회의형/디버깅형/요청 관리형/퇴근 회고형/학습형) — 캘린더 어댑터 + 시간 트리거 필요.
- **Streak Freeze 자동 보호** (Strategy §5) — 휴가·주말·공휴일 자동 감지. 캘린더 권한 필요.
- **알림 강도 사용자 조절 + 빈도 자동 감소** (Strategy §9 Week 9+) — 사용자 행동 분석 기반 알림 개인화.

---

*본 문서는 dogfood 결과와 후속 spec 작성 과정에서 조정될 수 있다.*
