From 35f900b65dca8f465508e26aef61747b4d889783 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 01:46:20 +0000 Subject: [PATCH] Rewrite implementation plan to v0.2 with Strategy integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 new tasks and updates existing ones to cover: - ContinuityService (was StreakService) for Weekly Continuity (7 notes/week, KST Mon-Sun, recovery detection >=7 day gap). - NotificationService for OS native post-submit reward toast (4 rotating copies, deterministic by noteId hash). - IntentService + IntentBanner component for "의미 한 줄" prompt after AI done (4 rotating prompts). - RecoveryToast component with localStorage dismissal. - AI proposal labels (gray "AI" badges on un-edited fields), tag source subscript, edited-by-user flags guarding AI overwrites. - Updated copy throughout (recovery-friendly, no failure language). - New schema columns: user_intent, intent_prompted_at, title_edited_by_user, summary_edited_by_user. - Updated IPC: setIntent/dismissIntent/getContinuity. - Tray copy ("기억 구출하기" / "구출한 메모 보기"). - Updated dogfood checklist with all v0.2 features. Plan now has 33 tasks (was 28). All tasks remain bite-sized with TDD discipline (red → green → commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-24-inkling-vertical-slice.md | 2726 +++++++++-------- 1 file changed, 1419 insertions(+), 1307 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md b/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md index 583fe93..c972a0b 100644 --- a/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md +++ b/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md @@ -1,14 +1,17 @@ -# Inkling Vertical Slice Implementation Plan +# Inkling Vertical Slice Implementation Plan (v0.2) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Implement the Inkling Vertical Slice v0.1 — a Windows/macOS Electron desktop app that captures text + pasted images via a global hotkey, persists them to SQLite, asynchronously generates Korean title/summary/tags via a local Ollama (`gemma4:9b`) provider, and displays results in an Inbox with inline editing and a simple streak badge. +**Plan version:** v0.2 (Strategy integration). Updated 2026-04-25. -**Architecture:** Electron main/renderer split with typed IPC. Main process hosts services (HotkeyService, CaptureService, NoteRepository, MediaStore, AiWorker, StreakService) around a single `better-sqlite3` database per profile. Renderer is React + Zustand with two windows (Inbox, QuickCapture). AI calls go through an `InferenceProvider` interface whose only slice implementation is `LocalOllamaProvider` (HTTP JSON to `localhost:11434`). +**Goal:** Implement Inkling Vertical Slice v0.2 — a Windows/macOS Electron desktop app that captures text + pasted images via a global hotkey, persists them to SQLite, asynchronously generates Korean title/summary/tags via a local Ollama (`gemma4:9b`) provider, fires an OS native "post-submit reward" toast, displays results in an Inbox with AI-proposal labels, an inline "intent banner" (Strategy §2.2) after AI completes, a Weekly Continuity streak (7 notes/week, recovery-friendly copy), and a recovery toast on returning after a 7+ day gap. + +**Architecture:** Electron main/renderer split with typed IPC. Main process hosts services (HotkeyService, CaptureService, NoteRepository, MediaStore, AiWorker, ContinuityService, NotificationService, IntentService) around a single `better-sqlite3` database per profile. Renderer is React + Zustand with two windows (Inbox, QuickCapture). AI calls go through an `InferenceProvider` interface whose only slice implementation is `LocalOllamaProvider` (HTTP JSON to `localhost:11434`). **Tech Stack:** Node.js 24.15.0 LTS · Electron 41 · electron-vite 2 · React 19 · TypeScript 6 · Zustand 5 · better-sqlite3 12 · zod 4 · undici 8 (HTTP mocking) · Vitest 4 · @playwright/test 1.59 · electron-log 5. -**Spec reference:** `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` +**Spec reference:** `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (v0.2) +**Strategy reference:** `docs/superpowers/strategy/strategy.md` --- @@ -22,34 +25,37 @@ inkling/ ├── tsconfig.node.json # Task 1 ├── electron.vite.config.ts # Task 1 ├── vitest.config.ts # Task 1 -├── playwright.config.ts # Task 28 +├── playwright.config.ts # Task 32 ├── .gitignore # Task 1 ├── src/ │ ├── shared/ -│ │ └── types.ts # Task 3 shared IPC/domain types +│ │ ├── types.ts # Task 3 shared IPC/domain types +│ │ └── intentPrompts.ts # Task 27 rotating prompt strings │ ├── preload/ │ │ └── index.ts # Task 3 contextBridge │ ├── main/ -│ │ ├── index.ts # Task 2 entry, app lifecycle +│ │ ├── index.ts # Task 2 + Task 30 final wiring │ │ ├── logger.ts # Task 4 │ │ ├── paths.ts # Task 5 │ │ ├── windows/ │ │ │ ├── inboxWindow.ts # Task 2 -│ │ │ └── quickCaptureWindow.ts # Task 17 +│ │ │ └── quickCaptureWindow.ts # Task 18 │ │ ├── db/ -│ │ │ ├── index.ts # Task 6 connection factory +│ │ │ ├── index.ts # Task 6 │ │ │ └── migrations/ -│ │ │ ├── index.ts # Task 6 runner +│ │ │ ├── index.ts # Task 6 │ │ │ └── m001_initial.ts # Task 6 │ │ ├── repository/ │ │ │ └── NoteRepository.ts # Task 7 │ │ ├── services/ │ │ │ ├── MediaStore.ts # Task 8 -│ │ │ ├── StreakService.ts # Task 9 +│ │ │ ├── ContinuityService.ts # Task 9 │ │ │ ├── CaptureService.ts # Task 14 -│ │ │ ├── HotkeyService.ts # Task 16 -│ │ │ ├── HealthChecker.ts # Task 25 -│ │ │ └── MediaGc.ts # Task 27 +│ │ │ ├── NotificationService.ts # Task 15 +│ │ │ ├── HotkeyService.ts # Task 17 +│ │ │ ├── IntentService.ts # Task 20 +│ │ │ ├── HealthChecker.ts # Task 29 +│ │ │ └── MediaGc.ts # Task 31 │ │ ├── ai/ │ │ │ ├── schema.ts # Task 10 │ │ │ ├── InferenceProvider.ts # Task 11 @@ -57,40 +63,46 @@ inkling/ │ │ │ ├── prompt.ts # Task 10 │ │ │ └── AiWorker.ts # Task 13 │ │ ├── ipc/ -│ │ │ ├── captureApi.ts # Task 15 -│ │ │ └── inboxApi.ts # Task 19 -│ │ └── tray.ts # Task 26 +│ │ │ ├── captureApi.ts # Task 16 +│ │ │ └── inboxApi.ts # Task 21 +│ │ └── tray.ts # Task 30 │ └── renderer/ │ ├── inbox/ -│ │ ├── index.html # Task 20 -│ │ ├── main.tsx # Task 20 -│ │ ├── App.tsx # Task 20 -│ │ ├── store.ts # Task 20 -│ │ ├── api.ts # Task 20 preload api wrapper +│ │ ├── index.html # Task 22 +│ │ ├── main.tsx # Task 22 +│ │ ├── App.tsx # Task 22 +│ │ ├── store.ts # Task 22 +│ │ ├── api.ts # Task 22 +│ │ ├── recoveryToast.ts # Task 22 dismiss persistence helper │ │ └── components/ -│ │ ├── NoteCard.tsx # Task 21 -│ │ ├── EditableField.tsx # Task 22 -│ │ ├── StreakBadge.tsx # Task 24 +│ │ ├── NoteCard.tsx # Task 25 +│ │ ├── EditableField.tsx # Task 26 +│ │ ├── IntentBanner.tsx # Task 27 +│ │ ├── RecoveryToast.tsx # Task 28 +│ │ ├── ContinuityBadge.tsx # Task 23 │ │ ├── PendingBanner.tsx # Task 24 -│ │ └── OllamaBanner.tsx # Task 25 +│ │ └── OllamaBanner.tsx # Task 29 │ └── quickcapture/ -│ ├── index.html # Task 18 -│ ├── main.tsx # Task 18 -│ ├── App.tsx # Task 18 -│ └── api.ts # Task 18 +│ ├── index.html # Task 19 +│ ├── main.tsx # Task 19 +│ ├── App.tsx # Task 19 +│ └── api.ts # Task 19 └── tests/ ├── unit/ + │ ├── migrations.test.ts # Task 6 │ ├── NoteRepository.test.ts # Task 7 │ ├── MediaStore.test.ts # Task 8 - │ ├── StreakService.test.ts # Task 9 + │ ├── ContinuityService.test.ts # Task 9 │ ├── ai-schema.test.ts # Task 10 │ ├── LocalOllamaProvider.test.ts # Task 12 │ ├── AiWorker.test.ts # Task 13 - │ └── CaptureService.test.ts # Task 14 + │ ├── CaptureService.test.ts # Task 14 + │ ├── NotificationService.test.ts # Task 15 + │ └── IntentService.test.ts # Task 20 ├── integration/ - │ └── ollama-golden.test.ts # Task 12 (opt-in) + │ └── ollama-golden.test.ts # Task 12 └── e2e/ - └── smoke.spec.ts # Task 28 + └── smoke.spec.ts # Task 32 ``` --- @@ -98,21 +110,14 @@ inkling/ ## Task 1: Bootstrap project **Files:** -- Create: `package.json` -- Create: `tsconfig.json` -- Create: `tsconfig.node.json` -- Create: `electron.vite.config.ts` -- Create: `vitest.config.ts` -- Create: `.gitignore` +- Create: `package.json`, `tsconfig.json`, `tsconfig.node.json`, `electron.vite.config.ts`, `vitest.config.ts`, `.gitignore` - [ ] **Step 1: Initialize `package.json`** -Write `package.json`: - ```json { "name": "inkling", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "main": "out/main/index.js", @@ -129,9 +134,7 @@ Write `package.json`: } ``` -- [ ] **Step 2: Install runtime and dev dependencies** - -Run: +- [ ] **Step 2: Install dependencies** ```bash npm install --save-exact \ @@ -157,9 +160,9 @@ npm install --save-exact --save-dev \ @playwright/test@1.59.1 ``` -If any version fails (not yet published), run `npm view version` and use the latest instead, then update §7.2 of the spec. +If a version is not yet published, run `npm view version` and use the latest stable; update spec §7.2 in the same PR. -- [ ] **Step 3: Create `tsconfig.json`** +- [ ] **Step 3: `tsconfig.json`** ```json { @@ -188,7 +191,7 @@ If any version fails (not yet published), run `npm view version` and use t } ``` -- [ ] **Step 4: Create `tsconfig.node.json`** +- [ ] **Step 4: `tsconfig.node.json`** ```json { @@ -204,7 +207,7 @@ If any version fails (not yet published), run `npm view version` and use t } ``` -- [ ] **Step 5: Create `electron.vite.config.ts`** +- [ ] **Step 5: `electron.vite.config.ts`** ```ts import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; @@ -233,15 +236,11 @@ export default defineConfig({ input: { index: resolve(__dirname, 'src/preload/index.ts') } } }, - resolve: { - alias: { '@shared': resolve(__dirname, 'src/shared') } - } + resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } } }, renderer: { plugins: [react()], - resolve: { - alias: { '@shared': resolve(__dirname, 'src/shared') } - }, + resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } }, build: { rollupOptions: { input: { @@ -254,7 +253,7 @@ export default defineConfig({ }); ``` -- [ ] **Step 6: Create `vitest.config.ts`** +- [ ] **Step 6: `vitest.config.ts`** ```ts import { defineConfig } from 'vitest/config'; @@ -266,9 +265,7 @@ export default defineConfig({ globals: false, include: ['tests/unit/**/*.test.ts'], exclude: ['tests/integration/**', 'tests/e2e/**'], - coverage: { - reporter: ['text', 'html'] - } + coverage: { reporter: ['text', 'html'] } }, resolve: { alias: { @@ -279,7 +276,7 @@ export default defineConfig({ }); ``` -- [ ] **Step 7: Create `.gitignore`** +- [ ] **Step 7: `.gitignore`** ``` node_modules/ @@ -293,35 +290,32 @@ playwright-report/ test-results/ ``` -- [ ] **Step 8: Verify toolchain** +- [ ] **Step 8: Verify** -Run: ```bash npx tsc --version npx vitest --version npx electron --version ``` -Expected: versions matching `package.json` (TypeScript 6.x, Vitest 4.x, Electron 41.x). + +Expected: TypeScript 6.x, Vitest 4.x, Electron 41.x. - [ ] **Step 9: Commit** ```bash git add package.json package-lock.json tsconfig.json tsconfig.node.json electron.vite.config.ts vitest.config.ts .gitignore -git commit -m "chore: bootstrap inkling project with electron-vite, vitest, react, sqlite, zod" +git commit -m "chore: bootstrap inkling project (v0.2 deps)" ``` --- ## Task 2: Electron main entry + Inbox window shell -**Files:** -- Create: `src/main/index.ts` -- Create: `src/main/windows/inboxWindow.ts` -- Create: `src/renderer/inbox/index.html` +**Files:** Create `src/main/index.ts`, `src/main/windows/inboxWindow.ts`, `src/renderer/inbox/index.html` -- [ ] **Step 1: Create placeholder Inbox HTML** +- [ ] **Step 1: Inbox HTML placeholder** -Write `src/renderer/inbox/index.html`: +`src/renderer/inbox/index.html`: ```html @@ -333,26 +327,14 @@ Write `src/renderer/inbox/index.html`:
- + ``` -(`main.tsx` will be created in Task 20; electron-vite dev server tolerates the missing module by showing a build error, which is fine for this step. To make Task 2 runnable without Task 20, temporarily insert a minimal placeholder script tag — see Step 2.) +(Task 22 restores the real module script.) -- [ ] **Step 2: Replace the script tag with an inline placeholder until Task 20** - -Change the script line in `index.html` to: - -```html - -``` - -Task 20 will restore the module script. - -- [ ] **Step 3: Implement `inboxWindow.ts`** - -Write `src/main/windows/inboxWindow.ts`: +- [ ] **Step 2: `inboxWindow.ts`** ```ts import { BrowserWindow, app } from 'electron'; @@ -360,7 +342,6 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; let inboxWindow: BrowserWindow | null = null; - const __dirname = fileURLToPath(new URL('.', import.meta.url)); export function getInboxWindow(): BrowserWindow | null { @@ -399,33 +380,23 @@ export function createInboxWindow(): BrowserWindow { } }); - inboxWindow.once('ready-to-show', () => { - inboxWindow?.show(); - }); - + inboxWindow.once('ready-to-show', () => inboxWindow?.show()); return inboxWindow; } ``` -- [ ] **Step 4: Declare the custom `isQuitting` flag** - -Create `src/shared/types.ts` minimal first version: +- [ ] **Step 3: `src/shared/types.ts` minimum (Task 3 expands it)** ```ts declare global { namespace Electron { - interface App { - isQuitting?: boolean; - } + interface App { isQuitting?: boolean; } } } - export {}; ``` -- [ ] **Step 5: Implement `main/index.ts`** - -Write `src/main/index.ts`: +- [ ] **Step 4: `src/main/index.ts`** ```ts import { app, BrowserWindow } from 'electron'; @@ -434,64 +405,37 @@ import { createInboxWindow } from './windows/inboxWindow.js'; app.whenReady().then(() => { createInboxWindow(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createInboxWindow(); - } + if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); }); }); -app.on('before-quit', () => { - app.isQuitting = true; -}); - -app.on('window-all-closed', () => { - // Keep the app running in the tray on non-macOS too (tray added in Task 26). - if (process.platform !== 'darwin') { - // Intentionally do NOT quit here. - } -}); +app.on('before-quit', () => { app.isQuitting = true; }); ``` -- [ ] **Step 6: Run dev and verify window opens** +- [ ] **Step 5: Verify dev start** -Run: `npm run dev` +`npm run dev` → window shows placeholder; close X hides; Ctrl+C exits. -Expected: Electron launches, an Inbox window opens showing "Inkling Inbox (renderer pending)". Close the window; the process stays alive (no quit). - -Stop with `Ctrl+C`. - -- [ ] **Step 7: Commit** +- [ ] **Step 6: Commit** ```bash git add src/main/index.ts src/main/windows/inboxWindow.ts src/renderer/inbox/index.html src/shared/types.ts -git commit -m "feat(main): add Electron entry and Inbox window shell" +git commit -m "feat(main): add Electron entry + Inbox window shell" ``` --- -## Task 3: Preload typed bridge skeleton +## Task 3: Preload typed bridge with v0.2 types -**Files:** -- Create: `src/preload/index.ts` -- Modify: `src/shared/types.ts` +**Files:** Create `src/preload/index.ts`, modify `src/shared/types.ts` -- [ ] **Step 1: Extend shared types with domain + IPC interfaces** - -Replace `src/shared/types.ts` with: +- [ ] **Step 1: Replace `src/shared/types.ts`** ```ts declare global { - namespace Electron { - interface App { - isQuitting?: boolean; - } - } - - interface Window { - inkling: InklingApi; - } + namespace Electron { interface App { isQuitting?: boolean; } } + interface Window { inkling: InklingApi; } } export interface NoteMedia { @@ -518,15 +462,23 @@ export interface Note { aiError: string | null; aiProvider: string | null; aiGeneratedAt: string | null; + titleEditedByUser: boolean; + summaryEditedByUser: boolean; + userIntent: string | null; + intentPromptedAt: string | null; createdAt: string; updatedAt: string; tags: NoteTag[]; media: NoteMedia[]; } -export interface StreakInfo { - current: number; - longest: number; +export interface WeeklyContinuity { + weekStart: string; // ISO date (KST 월요일) + weekCount: number; + weekTarget: number; // 7 + consecutiveCompleteWeeks: number; + showRecoveryToast: boolean; + lastNoteAt: string | null; } export interface CaptureApi { @@ -541,7 +493,9 @@ export interface InboxApi { fields: { title?: string; summary?: string; tags?: string[] } ): Promise; deleteNote(noteId: string): Promise; - getStreak(): Promise; + setIntent(noteId: string, text: string): Promise; + dismissIntent(noteId: string): Promise; + getContinuity(): Promise; getPendingCount(): Promise; getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>; onNoteUpdated(cb: (note: Note) => void): () => void; @@ -555,9 +509,7 @@ export interface InklingApi { export {}; ``` -- [ ] **Step 2: Implement the preload bridge** - -Write `src/preload/index.ts`: +- [ ] **Step 2: `src/preload/index.ts`** ```ts import { contextBridge, ipcRenderer } from 'electron'; @@ -573,7 +525,9 @@ const api: InklingApi = { updateAiFields: (noteId, fields) => ipcRenderer.invoke('inbox:updateAi', { noteId, fields }), deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId), - getStreak: () => ipcRenderer.invoke('inbox:streak'), + setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }), + dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId), + getContinuity: () => ipcRenderer.invoke('inbox:continuity'), getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'), getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'), onNoteUpdated: (cb) => { @@ -587,29 +541,21 @@ const api: InklingApi = { contextBridge.exposeInMainWorld('inkling', api); ``` -- [ ] **Step 3: Run typecheck** - -Run: `npm run typecheck` - -Expected: exit 0. - -- [ ] **Step 4: Commit** +- [ ] **Step 3: Typecheck and commit** ```bash +npm run typecheck git add src/preload/index.ts src/shared/types.ts -git commit -m "feat(preload): expose typed inkling API bridge (capture + inbox)" +git commit -m "feat(preload): expose typed InklingApi (v0.2: intent + continuity)" ``` --- ## Task 4: Logger with PII protection -**Files:** -- Create: `src/main/logger.ts` +**Files:** Create `src/main/logger.ts`, modify `src/main/index.ts` -- [ ] **Step 1: Implement the logger** - -Write `src/main/logger.ts`: +- [ ] **Step 1: `logger.ts`** ```ts import log from 'electron-log/main'; @@ -642,44 +588,35 @@ export const logger = { }; ``` -- [ ] **Step 2: Wire logger init in main entry** - -Modify `src/main/index.ts`, insert at the top of `app.whenReady().then(...)` body: +- [ ] **Step 2: Wire in `src/main/index.ts`** +Add to imports: ```ts import { initLogger, logger } from './logger.js'; ``` -and inside `whenReady`: - +Insert at top of `whenReady` body: ```ts -initLogger(); -logger.info('app.start', { platform: process.platform, version: app.getVersion() }); + initLogger(); + logger.info('app.start', { platform: process.platform, version: app.getVersion() }); ``` -- [ ] **Step 3: Run dev and verify log file appears** +- [ ] **Step 3: Verify and commit** -Run: `npm run dev`. After the window shows, quit the app (`Ctrl+C`). - -Check: `ls %APPDATA%\Inkling\logs\` on Windows or `ls ~/Library/Application\ Support/Inkling/logs/` on macOS shows `main-YYYY-MM-DD.log` with an `app.start` entry. - -- [ ] **Step 4: Commit** +`npm run dev` → check `{userData}/Inkling/logs/main-YYYY-MM-DD.log` has `app.start` entry. ```bash git add src/main/logger.ts src/main/index.ts -git commit -m "feat(logger): add electron-log with daily rotation and PII-safe helpers" +git commit -m "feat(logger): add electron-log with PII-safe helpers" ``` --- ## Task 5: Paths utility -**Files:** -- Create: `src/main/paths.ts` +**Files:** Create `src/main/paths.ts` -- [ ] **Step 1: Implement paths resolver** - -Write `src/main/paths.ts`: +- [ ] **Step 1: Implement** ```ts import { app } from 'electron'; @@ -694,13 +631,13 @@ export interface ProfilePaths { export function resolveProfilePaths(profile = 'default'): ProfilePaths { const root = join(app.getPath('userData'), 'Inkling', 'profiles', profile); - const profilePaths: ProfilePaths = { + const p: ProfilePaths = { profileDir: root, dbFile: join(root, 'inkling.sqlite'), mediaDir: join(root, 'media') }; - mkdirSync(profilePaths.mediaDir, { recursive: true }); - return profilePaths; + mkdirSync(p.mediaDir, { recursive: true }); + return p; } ``` @@ -708,21 +645,16 @@ export function resolveProfilePaths(profile = 'default'): ProfilePaths { ```bash git add src/main/paths.ts -git commit -m "feat(paths): resolve per-profile data directory layout" +git commit -m "feat(paths): per-profile directory resolver" ``` --- -## Task 6: DB initialization + migration framework +## Task 6: DB initialization + v1 migration with intent + edited columns -**Files:** -- Create: `src/main/db/index.ts` -- Create: `src/main/db/migrations/index.ts` -- Create: `src/main/db/migrations/m001_initial.ts` +**Files:** Create `src/main/db/index.ts`, `src/main/db/migrations/index.ts`, `src/main/db/migrations/m001_initial.ts`, `tests/unit/migrations.test.ts` -- [ ] **Step 1: Write the initial migration** - -Write `src/main/db/migrations/m001_initial.ts`: +- [ ] **Step 1: `m001_initial.ts`** ```ts import type Database from 'better-sqlite3'; @@ -732,17 +664,23 @@ export const version = 1; export function up(db: Database.Database): void { db.exec(` CREATE TABLE notes ( - id TEXT PRIMARY KEY, - raw_text TEXT NOT NULL, - ai_title TEXT, - ai_summary TEXT, - ai_status TEXT NOT NULL - CHECK (ai_status IN ('pending','done','failed')), - ai_error TEXT, - ai_provider TEXT, - ai_generated_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0 + 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, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + 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); @@ -751,7 +689,6 @@ export function up(db: Database.Database): void { 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), @@ -780,9 +717,7 @@ export function up(db: Database.Database): void { } ``` -- [ ] **Step 2: Write the migration runner** - -Write `src/main/db/migrations/index.ts`: +- [ ] **Step 2: `migrations/index.ts`** ```ts import type Database from 'better-sqlite3'; @@ -791,8 +726,8 @@ import * as m001 from './m001_initial.js'; const migrations = [m001]; export function runMigrations(db: Database.Database): void { - const currentRow = db.prepare('PRAGMA user_version').get() as { user_version: number }; - const current = currentRow.user_version ?? 0; + const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; + const current = row.user_version ?? 0; for (const m of migrations) { if (m.version > current) { const tx = db.transaction(() => { @@ -805,9 +740,7 @@ export function runMigrations(db: Database.Database): void { } ``` -- [ ] **Step 3: Write the db connection factory** - -Write `src/main/db/index.ts`: +- [ ] **Step 3: `db/index.ts`** ```ts import Database from 'better-sqlite3'; @@ -822,9 +755,7 @@ export function openDb(dbFile: string): Database.Database { } ``` -- [ ] **Step 4: Write a smoke test for the migration runner** - -Write `tests/unit/migrations.test.ts`: +- [ ] **Step 4: `tests/unit/migrations.test.ts`** ```ts import { describe, it, expect } from 'vitest'; @@ -832,17 +763,20 @@ import Database from 'better-sqlite3'; import { runMigrations } from '@main/db/migrations/index.js'; describe('migrations', () => { - it('creates schema at version 1 from fresh db', () => { + it('creates schema at version 1 with intent + edited columns', () => { const db = new Database(':memory:'); runMigrations(db); const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; expect(ver).toBe(1); - const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() - .map((r: any) => r.name); - expect(tables).toEqual( - expect.arrayContaining(['notes', 'note_tags', 'tags', 'media', 'pending_jobs']) + const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name); + expect(cols).toEqual( + expect.arrayContaining([ + 'id', 'raw_text', 'ai_title', 'ai_summary', 'ai_status', 'ai_error', + 'ai_provider', 'ai_generated_at', + 'title_edited_by_user', 'summary_edited_by_user', + 'user_intent', 'intent_prompted_at', + 'created_at', 'updated_at' + ]) ); db.close(); }); @@ -851,37 +785,27 @@ describe('migrations', () => { const db = new Database(':memory:'); runMigrations(db); runMigrations(db); - const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; - expect(ver).toBe(1); + expect((db.prepare('PRAGMA user_version').get() as any).user_version).toBe(1); db.close(); }); }); ``` -- [ ] **Step 5: Run the test** - -Run: `npx vitest run tests/unit/migrations.test.ts` - -Expected: 2 tests pass. - -- [ ] **Step 6: Commit** +- [ ] **Step 5: Run + commit** ```bash +npx vitest run tests/unit/migrations.test.ts git add src/main/db/ tests/unit/migrations.test.ts -git commit -m "feat(db): add sqlite migration framework with v1 initial schema" +git commit -m "feat(db): v1 schema with user_intent + edited flags" ``` --- -## Task 7: NoteRepository +## Task 7: NoteRepository with intent + edited flags -**Files:** -- Create: `src/main/repository/NoteRepository.ts` -- Create: `tests/unit/NoteRepository.test.ts` +**Files:** Create `src/main/repository/NoteRepository.ts`, `tests/unit/NoteRepository.test.ts` -- [ ] **Step 1: Write the failing test for create + findById** - -Write `tests/unit/NoteRepository.test.ts`: +- [ ] **Step 1: Failing test for create + edited flag default 0** ```ts import { describe, it, expect, beforeEach } from 'vitest'; @@ -898,41 +822,35 @@ function freshDb() { describe('NoteRepository', () => { let db: Database.Database; let repo: NoteRepository; + beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); }); - beforeEach(() => { - db = freshDb(); - repo = new NoteRepository(db); - }); - - it('creates a note with pending ai_status and enqueues a pending_job atomically', () => { - const { id } = repo.create({ rawText: '회의 메모 test' }); - const row = db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any; - expect(row.raw_text).toBe('회의 메모 test'); - expect(row.ai_status).toBe('pending'); + it('create stores raw_text, defaults edited flags to 0, intent fields NULL, enqueues pending job', () => { + const { id } = repo.create({ rawText: '회의 메모' }); + const note = repo.findById(id)!; + expect(note.rawText).toBe('회의 메모'); + expect(note.titleEditedByUser).toBe(false); + expect(note.summaryEditedByUser).toBe(false); + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).toBeNull(); + expect(note.aiStatus).toBe('pending'); const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); expect(job).toBeDefined(); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/NoteRepository.test.ts` +`npx vitest run tests/unit/NoteRepository.test.ts` → FAIL. -Expected: FAIL with "Cannot find module '@main/repository/NoteRepository.js'". - -- [ ] **Step 3: Implement `NoteRepository` (create + findById)** - -Write `src/main/repository/NoteRepository.ts`: +- [ ] **Step 3: Implement repository** ```ts import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; -export interface CreateNoteInput { - rawText: string; -} +export interface CreateNoteInput { rawText: string; } export interface NewMediaRow { noteId: string; @@ -950,16 +868,12 @@ export class NoteRepository { const now = new Date().toISOString(); const tx = this.db.transaction(() => { this.db - .prepare( - `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) - VALUES (?, ?, 'pending', ?, ?)` - ) + .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?)`) .run(id, input.rawText, now, now); this.db - .prepare( - `INSERT INTO pending_jobs (note_id, attempts, next_run_at) - VALUES (?, 0, ?)` - ) + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) .run(id, now); }); tx(); @@ -991,9 +905,7 @@ 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 LIMIT ?` - ) + .prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`) .all(opts.cursor, limit) as any[]) : (this.db .prepare(`SELECT * FROM notes ORDER BY created_at DESC LIMIT ?`) @@ -1007,20 +919,21 @@ export class NoteRepository { ): void { const now = new Date().toISOString(); const tx = this.db.transaction(() => { + // Only overwrite ai_title/summary if user hasn't edited them this.db .prepare( `UPDATE notes - SET ai_title=?, ai_summary=?, ai_status='done', - ai_provider=?, ai_generated_at=?, ai_error=NULL, updated_at=? - WHERE id=?` + SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, + ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + ai_status = 'done', + ai_provider = ?, + ai_generated_at = ?, + ai_error = NULL, + updated_at = ? + WHERE id = ?` ) .run(result.title, result.summary, result.provider, now, now, id); - this.db - .prepare( - `DELETE FROM note_tags - WHERE note_id=? AND source='ai'` - ) - .run(id); + this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id); const getOrInsertTag = this.db.prepare( `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` ); @@ -1040,9 +953,7 @@ export class NoteRepository { const now = new Date().toISOString(); const tx = this.db.transaction(() => { this.db - .prepare( - `UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?` - ) + .prepare(`UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?`) .run(error.slice(0, 500), now, id); this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); }); @@ -1059,10 +970,12 @@ export class NoteRepository { const params: unknown[] = []; if (fields.title !== undefined) { updates.push('ai_title=?'); + updates.push('title_edited_by_user=1'); params.push(fields.title); } if (fields.summary !== undefined) { updates.push('ai_summary=?'); + updates.push('summary_edited_by_user=1'); params.push(fields.summary); } if (updates.length > 0) { @@ -1088,6 +1001,31 @@ export class NoteRepository { tx(); } + setIntent(id: string, text: string): void { + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE notes + SET user_intent = ?, + intent_prompted_at = COALESCE(intent_prompted_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(text.slice(0, 200), now, now, id); + } + + dismissIntent(id: string): void { + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE notes + SET intent_prompted_at = COALESCE(intent_prompted_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(now, now, id); + } + delete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } @@ -1114,10 +1052,10 @@ export class NoteRepository { this.db .prepare( `UPDATE pending_jobs - SET attempts = attempts + 1, - next_run_at = ?, - last_error = ? - WHERE note_id = ?` + SET attempts = attempts + 1, + next_run_at = ?, + last_error = ? + WHERE note_id = ?` ) .run(nextRunAt, lastError.slice(0, 500), noteId); } @@ -1144,6 +1082,10 @@ export class NoteRepository { aiError: row.ai_error, aiProvider: row.ai_provider, aiGeneratedAt: row.ai_generated_at, + titleEditedByUser: row.title_edited_by_user === 1, + summaryEditedByUser: row.summary_edited_by_user === 1, + userIntent: row.user_intent, + intentPromptedAt: row.intent_prompted_at, createdAt: row.created_at, updatedAt: row.updated_at, tags: tags as NoteTag[], @@ -1153,65 +1095,82 @@ export class NoteRepository { } ``` -- [ ] **Step 4: Run first test to verify pass** +- [ ] **Step 4: Verify first test passes** -Run: `npx vitest run tests/unit/NoteRepository.test.ts` +`npx vitest run tests/unit/NoteRepository.test.ts` → first test passes. -Expected: first test passes. - -- [ ] **Step 5: Add remaining tests** - -Append to `tests/unit/NoteRepository.test.ts`: +- [ ] **Step 5: Append remaining tests** ```ts - it('findById returns note with empty tags and media arrays', () => { - const { id } = repo.create({ rawText: 'hello' }); - const note = repo.findById(id); - expect(note?.tags).toEqual([]); - expect(note?.media).toEqual([]); + it('updateAiResult does not overwrite user-edited title/summary', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p' }); + repo.updateUserAiFields(id, { title: '내 제목', summary: '내 요약\n둘\n셋' }); + repo.updateAiResult(id, { title: 'AI 제목 2', summary: 'x\ny\nz', tags: [], provider: 'p' }); + const note = repo.findById(id)!; + expect(note.aiTitle).toBe('내 제목'); + expect(note.aiSummary).toBe('내 요약\n둘\n셋'); + expect(note.titleEditedByUser).toBe(true); + expect(note.summaryEditedByUser).toBe(true); }); - it('updateAiResult marks done, stores fields, replaces ai tags, and removes pending job', () => { + it('updateAiResult marks done, replaces ai tags, removes pending job', () => { const { id } = repo.create({ rawText: '원문' }); repo.updateAiResult(id, { - title: '제목', - summary: '1줄\n2줄\n3줄', - tags: ['api-timeout', 'meeting'], - provider: 'local-ollama/gemma4:9b' + title: '제목', summary: '1줄\n2줄\n3줄', + tags: ['api-timeout', 'meeting'], provider: 'local-ollama/gemma4:9b' }); const note = repo.findById(id)!; expect(note.aiStatus).toBe('done'); - expect(note.aiTitle).toBe('제목'); expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']); expect(note.tags.every((t) => t.source === 'ai')).toBe(true); - const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); - expect(job).toBeUndefined(); + expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined(); }); - it('markAiFailed truncates error to 500 chars and removes pending job', () => { + it('markAiFailed truncates and clears pending job', () => { const { id } = repo.create({ rawText: 'x' }); - const longErr = 'E'.repeat(600); - repo.markAiFailed(id, longErr); + repo.markAiFailed(id, 'E'.repeat(600)); const note = repo.findById(id)!; expect(note.aiStatus).toBe('failed'); expect(note.aiError?.length).toBe(500); }); - it('updateUserAiFields replaces user-sourced tags and updates fields', () => { + it('updateUserAiFields replaces user-sourced tags', () => { const { id } = repo.create({ rawText: 'x' }); - repo.updateAiResult(id, { - title: 'ai', - summary: 'a\nb\nc', - tags: ['ai-tag'], - provider: 'p' - }); - repo.updateUserAiFields(id, { title: '편집됨', tags: ['user-tag'] }); + repo.updateAiResult(id, { title: 'ai', summary: 'a\nb\nc', tags: ['ai-tag'], provider: 'p' }); + repo.updateUserAiFields(id, { tags: ['user-tag'] }); const note = repo.findById(id)!; - expect(note.aiTitle).toBe('편집됨'); expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]); }); - it('delete cascades note_tags, media, and pending_jobs', () => { + it('setIntent stores user_intent, sets intent_prompted_at first time, preserves on subsequent', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setIntent(id, '내일의 나에게'); + const a = repo.findById(id)!; + expect(a.userIntent).toBe('내일의 나에게'); + expect(a.intentPromptedAt).not.toBeNull(); + const firstStamp = a.intentPromptedAt!; + repo.setIntent(id, '수정'); + const b = repo.findById(id)!; + expect(b.userIntent).toBe('수정'); + expect(b.intentPromptedAt).toBe(firstStamp); + }); + + it('dismissIntent stamps intent_prompted_at without setting user_intent', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.dismissIntent(id); + const note = repo.findById(id)!; + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).not.toBeNull(); + }); + + it('setIntent truncates to 200 chars', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setIntent(id, 'X'.repeat(300)); + expect(repo.findById(id)!.userIntent?.length).toBe(200); + }); + + it('delete cascades note_tags, media, pending_jobs', () => { const { id } = repo.create({ rawText: 'x' }); repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]); repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' }); @@ -1224,8 +1183,7 @@ Append to `tests/unit/NoteRepository.test.ts`: it('list returns notes in descending created_at', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; - const listed = repo.list({ limit: 10 }); - expect(listed.map((n) => n.id)).toEqual([b, a]); + expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]); }); it('getPendingCount counts pending notes', () => { @@ -1236,7 +1194,7 @@ Append to `tests/unit/NoteRepository.test.ts`: expect(repo.getPendingCount()).toBe(1); }); - it('incrementJobAttempt bumps attempts and records last_error', () => { + it('incrementJobAttempt bumps attempts and stores last_error', () => { const { id } = repo.create({ rawText: 'x' }); repo.incrementJobAttempt(id, new Date().toISOString(), 'boom'); const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any; @@ -1245,34 +1203,25 @@ Append to `tests/unit/NoteRepository.test.ts`: }); ``` -- [ ] **Step 6: Run all tests** - -Run: `npx vitest run tests/unit/NoteRepository.test.ts` - -Expected: all tests pass. - -- [ ] **Step 7: Commit** +- [ ] **Step 6: Run + commit** ```bash +npx vitest run tests/unit/NoteRepository.test.ts git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts -git commit -m "feat(repo): add NoteRepository with create/list/update/delete and job queue ops" +git commit -m "feat(repo): NoteRepository with intent, edited flags, AI overwrite guard" ``` --- ## Task 8: MediaStore -**Files:** -- Create: `src/main/services/MediaStore.ts` -- Create: `tests/unit/MediaStore.test.ts` +**Files:** Create `src/main/services/MediaStore.ts`, `tests/unit/MediaStore.test.ts` -- [ ] **Step 1: Write the failing tests** - -Write `tests/unit/MediaStore.test.ts`: +- [ ] **Step 1: Failing tests** ```ts import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { MediaStore } from '@main/services/MediaStore.js'; @@ -1286,16 +1235,13 @@ describe('MediaStore', () => { store = new MediaStore(tmp); }); - it('saves a png and returns a relative path under media/{noteId}/', async () => { + it('saves a png under media/{noteId}/', async () => { const bytes = Buffer.from('\x89PNG\r\n\x1a\n' + 'A'.repeat(100)); const saved = await store.saveImage('note-123', bytes, 'image/png'); expect(saved.relPath.startsWith('media/note-123/')).toBe(true); expect(saved.relPath.endsWith('.png')).toBe(true); - expect(saved.mime).toBe('image/png'); expect(saved.bytes).toBe(bytes.length); - const absolute = join(tmp, saved.relPath); - expect(existsSync(absolute)).toBe(true); - expect(readFileSync(absolute).equals(bytes)).toBe(true); + expect(readFileSync(join(tmp, saved.relPath)).equals(bytes)).toBe(true); }); it('saves multiple images with unique filenames', async () => { @@ -1305,32 +1251,25 @@ describe('MediaStore', () => { expect(a.relPath).not.toBe(b.relPath); }); - it('deleteNoteDirectory removes the note directory', async () => { - const bytes = Buffer.from('abc'); - await store.saveImage('note-x', bytes, 'image/png'); - expect(existsSync(join(tmp, 'media/note-x'))).toBe(true); + it('deleteNoteDirectory removes the note dir', async () => { + await store.saveImage('note-x', Buffer.from('abc'), 'image/png'); await store.deleteNoteDirectory('note-x'); expect(existsSync(join(tmp, 'media/note-x'))).toBe(false); }); - it('listNoteDirs returns directory names under media/', async () => { + it('listNoteDirs returns dir names', async () => { await store.saveImage('alpha', Buffer.from('a'), 'image/png'); await store.saveImage('beta', Buffer.from('b'), 'image/png'); - const dirs = await store.listNoteDirs(); - expect(dirs.sort()).toEqual(['alpha', 'beta']); + expect((await store.listNoteDirs()).sort()).toEqual(['alpha', 'beta']); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/MediaStore.test.ts` +`npx vitest run tests/unit/MediaStore.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `MediaStore`** - -Write `src/main/services/MediaStore.ts`: +- [ ] **Step 3: Implement** ```ts import { mkdir, writeFile, rm, readdir } from 'node:fs/promises'; @@ -1351,13 +1290,8 @@ export class MediaStore { await mkdir(dir, { recursive: true }); const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'bin'; const filename = `${uuidv4()}.${ext}`; - const absolute = join(dir, filename); - await writeFile(absolute, bytes); - return { - relPath: `media/${noteId}/${filename}`, - mime, - bytes: bytes.length - }; + await writeFile(join(dir, filename), bytes); + return { relPath: `media/${noteId}/${filename}`, mime, bytes: bytes.length }; } async deleteNoteDirectory(noteId: string): Promise { @@ -1365,9 +1299,9 @@ export class MediaStore { } async listNoteDirs(): Promise { - const mediaRoot = join(this.profileDir, 'media'); + const root = join(this.profileDir, 'media'); try { - const entries = await readdir(mediaRoot, { withFileTypes: true }); + const entries = await readdir(root, { withFileTypes: true }); return entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; @@ -1377,36 +1311,27 @@ export class MediaStore { } ``` -- [ ] **Step 4: Run tests** - -Run: `npx vitest run tests/unit/MediaStore.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Commit** +- [ ] **Step 4: Run + commit** ```bash +npx vitest run tests/unit/MediaStore.test.ts git add src/main/services/MediaStore.ts tests/unit/MediaStore.test.ts -git commit -m "feat(media): add MediaStore for image persistence and cleanup" +git commit -m "feat(media): MediaStore for image persistence and cleanup" ``` --- -## Task 9: StreakService +## Task 9: ContinuityService (Weekly Continuity) -**Files:** -- Create: `src/main/services/StreakService.ts` -- Create: `tests/unit/StreakService.test.ts` +**Files:** Create `src/main/services/ContinuityService.ts`, `tests/unit/ContinuityService.test.ts` -- [ ] **Step 1: Write the failing tests** - -Write `tests/unit/StreakService.test.ts`: +- [ ] **Step 1: Failing tests** ```ts import { describe, it, expect } from 'vitest'; import Database from 'better-sqlite3'; import { runMigrations } from '@main/db/migrations/index.js'; -import { StreakService } from '@main/services/StreakService.js'; +import { ContinuityService } from '@main/services/ContinuityService.js'; function dbWithDates(isoDates: string[]): Database.Database { const db = new Database(':memory:'); @@ -1415,147 +1340,212 @@ function dbWithDates(isoDates: string[]): Database.Database { `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, ?, 'pending', ?, ?)` ); - for (const [i, d] of isoDates.entries()) { - insert.run(`n${i}`, 'x', d, d); - } + for (const [i, d] of isoDates.entries()) insert.run(`n${i}`, 'x', d, d); return db; } -describe('StreakService', () => { - it('returns zero streak for empty db', () => { +describe('ContinuityService', () => { + it('empty db returns zero counts and no recovery toast', () => { const db = new Database(':memory:'); runMigrations(db); - const svc = new StreakService(db, () => new Date('2026-04-24T10:00:00Z')); - expect(svc.get()).toEqual({ current: 0, longest: 0 }); + const svc = new ContinuityService(db, () => new Date('2026-04-25T10:00:00+09:00')); + const r = svc.get(); + expect(r.weekCount).toBe(0); + expect(r.weekTarget).toBe(7); + expect(r.consecutiveCompleteWeeks).toBe(0); + expect(r.showRecoveryToast).toBe(false); + expect(r.lastNoteAt).toBeNull(); }); - it('counts consecutive days ending today', () => { + it('counts notes in current KST week (월~일)', () => { + // KST 2026-04-20 (월) ~ 2026-04-26 (일) const db = dbWithDates([ - '2026-04-22T09:00:00Z', - '2026-04-23T10:00:00Z', - '2026-04-24T11:00:00Z' + '2026-04-20T01:00:00+09:00', // 월 + '2026-04-22T03:00:00+09:00', // 수 + '2026-04-25T22:00:00+09:00' // 토 (조사 시점 직전) ]); - const svc = new StreakService(db, () => new Date('2026-04-24T23:00:00Z')); - expect(svc.get()).toEqual({ current: 3, longest: 3 }); + const svc = new ContinuityService(db, () => new Date('2026-04-25T23:00:00+09:00')); + const r = svc.get(); + expect(r.weekStart).toBe('2026-04-20'); + expect(r.weekCount).toBe(3); }); - it('resets current if today has no note', () => { - const db = dbWithDates(['2026-04-22T09:00:00Z', '2026-04-23T10:00:00Z']); - const svc = new StreakService(db, () => new Date('2026-04-25T09:00:00Z')); - expect(svc.get().current).toBe(0); - expect(svc.get().longest).toBe(2); + function isoKst(year: number, month: number, day: number, hour = 10): string { + const mm = String(month).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + const hh = String(hour).padStart(2, '0'); + return `${year}-${mm}-${dd}T${hh}:00:00+09:00`; + } + + it('consecutiveCompleteWeeks counts weeks with >=7 notes ending immediately before current week', () => { + const dates: string[] = []; + // 2주 전 (2026-04-06 월 ~ 04-12 일): 7건 + for (let d = 6; d <= 12; d++) dates.push(isoKst(2026, 4, d)); + // 1주 전 (2026-04-13 월 ~ 04-19 일): 7건 + for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d)); + // 이번 주 (2026-04-20 월 ~ 04-26 일): 2건 (미완성) + dates.push(isoKst(2026, 4, 20)); + dates.push(isoKst(2026, 4, 21)); + const db = dbWithDates(dates); + const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00')); + const r = svc.get(); + expect(r.consecutiveCompleteWeeks).toBe(2); + expect(r.weekCount).toBe(2); }); - it('computes longest across gaps', () => { + it('consecutiveCompleteWeeks includes current week if it already hit 7', () => { + const dates: string[] = []; + for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d)); + for (let d = 20; d <= 26; d++) dates.push(isoKst(2026, 4, d)); + const db = dbWithDates(dates); + const svc = new ContinuityService(db, () => new Date('2026-04-26T23:00:00+09:00')); + const r = svc.get(); + expect(r.weekCount).toBe(7); + expect(r.consecutiveCompleteWeeks).toBe(2); + }); + + it('showRecoveryToast=true when last note is >=7 days ago AND a fresh note exists today', () => { + const dates = [ + '2026-04-15T10:00:00+09:00', // 10일 전 + '2026-04-25T11:00:00+09:00' // 오늘 (recovery moment) + ]; + const db = dbWithDates(dates); + const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00')); + expect(svc.get().showRecoveryToast).toBe(true); + }); + + it('showRecoveryToast=false when there are notes within last 7 days', () => { const db = dbWithDates([ - '2026-04-01T00:00:00Z', - '2026-04-02T00:00:00Z', - '2026-04-03T00:00:00Z', - '2026-04-10T00:00:00Z' + '2026-04-22T10:00:00+09:00', + '2026-04-25T11:00:00+09:00' ]); - const svc = new StreakService(db, () => new Date('2026-04-10T00:00:00Z')); - expect(svc.get()).toEqual({ current: 1, longest: 3 }); + const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00')); + expect(svc.get().showRecoveryToast).toBe(false); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/StreakService.test.ts` +`npx vitest run tests/unit/ContinuityService.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `StreakService`** - -Write `src/main/services/StreakService.ts`: +- [ ] **Step 3: Implement** ```ts import type Database from 'better-sqlite3'; -import type { StreakInfo } from '@shared/types'; +import type { WeeklyContinuity } from '@shared/types'; -function toDateKey(d: Date): string { +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_TARGET = 7; +const RECOVERY_GAP_DAYS = 7; + +function toKstDateKey(d: Date): string { + const k = new Date(d.getTime() + KST_OFFSET_MS); + return k.toISOString().slice(0, 10); +} + +function kstMondayOf(d: Date): string { + const k = new Date(d.getTime() + KST_OFFSET_MS); + const dayIdx = (k.getUTCDay() + 6) % 7; // Mon=0..Sun=6 + k.setUTCDate(k.getUTCDate() - dayIdx); + return k.toISOString().slice(0, 10); +} + +function addDaysIso(iso: string, days: number): string { + const d = new Date(iso + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } -function addDays(key: string, delta: number): string { - const d = new Date(key + 'T00:00:00Z'); - d.setUTCDate(d.getUTCDate() + delta); - return toDateKey(d); -} - -export class StreakService { +export class ContinuityService { constructor( private db: Database.Database, private now: () => Date = () => new Date() ) {} - get(): StreakInfo { + get(): WeeklyContinuity { const rows = this.db - .prepare( - `SELECT DISTINCT strftime('%Y-%m-%d', created_at) AS d FROM notes ORDER BY d ASC` - ) - .all() as Array<{ d: string }>; - const days = rows.map((r) => r.d); - if (days.length === 0) return { current: 0, longest: 0 }; - - let longest = 1; - let run = 1; - for (let i = 1; i < days.length; i++) { - const prev = days[i - 1]!; - const cur = days[i]!; - run = addDays(prev, 1) === cur ? run + 1 : 1; - if (run > longest) longest = run; + .prepare(`SELECT created_at FROM notes 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) { + return { + weekStart: kstMondayOf(this.now()), + weekCount: 0, + weekTarget: WEEK_TARGET, + consecutiveCompleteWeeks: 0, + showRecoveryToast: false, + lastNoteAt: null + }; } - const today = toDateKey(this.now()); - const set = new Set(days); - let current = 0; - if (set.has(today)) { - current = 1; - let cursor = today; - while (set.has(addDays(cursor, -1))) { - current += 1; - cursor = addDays(cursor, -1); - } + // Group by KST week (Mon-Sun) + const byWeek = new Map(); + for (const d of dates) { + const wk = kstMondayOf(d); + byWeek.set(wk, (byWeek.get(wk) ?? 0) + 1); } - return { current, longest }; + const currentWeek = kstMondayOf(this.now()); + const weekCount = byWeek.get(currentWeek) ?? 0; + + // consecutiveCompleteWeeks: walk backward from current week (or previous if current incomplete) + let consecutive = 0; + let cursor = weekCount >= WEEK_TARGET ? currentWeek : addDaysIso(currentWeek, -7); + while ((byWeek.get(cursor) ?? 0) >= WEEK_TARGET) { + consecutive += 1; + cursor = addDaysIso(cursor, -7); + } + + // Recovery toast: latest note today (KST) AND second-latest is >=7 days before that + const last = dates[dates.length - 1]!; + const todayKst = toKstDateKey(this.now()); + const lastIsToday = toKstDateKey(last) === todayKst; + let showRecoveryToast = false; + if (lastIsToday && dates.length >= 2) { + const prev = dates[dates.length - 2]!; + const gapMs = last.getTime() - prev.getTime(); + if (gapMs >= RECOVERY_GAP_DAYS * ONE_DAY_MS) showRecoveryToast = true; + } else if (lastIsToday && dates.length === 1) { + // First note ever: not a "recovery", it's a start + showRecoveryToast = false; + } + + return { + weekStart: currentWeek, + weekCount, + weekTarget: WEEK_TARGET, + consecutiveCompleteWeeks: consecutive, + showRecoveryToast, + lastNoteAt: last.toISOString() + }; } } ``` -- [ ] **Step 4: Run tests** - -Run: `npx vitest run tests/unit/StreakService.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Commit** +- [ ] **Step 4: Run + commit** ```bash -git add src/main/services/StreakService.ts tests/unit/StreakService.test.ts -git commit -m "feat(streak): compute current and longest streak from distinct note days" +npx vitest run tests/unit/ContinuityService.test.ts +git add src/main/services/ContinuityService.ts tests/unit/ContinuityService.test.ts +git commit -m "feat(continuity): WeeklyContinuity service (7 notes/week, recovery detection)" ``` --- ## Task 10: AI schema + prompt template -**Files:** -- Create: `src/main/ai/schema.ts` -- Create: `src/main/ai/prompt.ts` -- Create: `tests/unit/ai-schema.test.ts` +**Files:** Create `src/main/ai/schema.ts`, `src/main/ai/prompt.ts`, `tests/unit/ai-schema.test.ts` -- [ ] **Step 1: Write failing tests for schema validator** - -Write `tests/unit/ai-schema.test.ts`: +- [ ] **Step 1: Failing tests** ```ts import { describe, it, expect } from 'vitest'; import { parseAiResponse } from '@main/ai/schema.js'; describe('parseAiResponse', () => { - it('accepts valid korean title, 3-line summary, kebab tags', () => { + it('accepts valid Korean title, 3-line summary, kebab tags', () => { const r = parseAiResponse({ title: '회의 요약', summary: '첫 줄\n둘째 줄\n셋째 줄', @@ -1566,37 +1556,29 @@ describe('parseAiResponse', () => { expect(r.tags).toEqual(['api-timeout', 'meeting']); }); - it('rejects when title has no korean characters', () => { + it('rejects title without Korean', () => { expect(() => - parseAiResponse({ - title: 'English only', - summary: 'a\nb\nc', - tags: [] - }) + parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] }) ).toThrow(/korean/i); }); - it('normalizes summary to exactly 3 lines: pads when fewer', () => { + it('pads short summary to 3 lines', () => { const r = parseAiResponse({ title: '제목', summary: '한 줄', tags: [] }); expect(r.summary.split('\n')).toHaveLength(3); }); - it('normalizes summary to exactly 3 lines: trims when more', () => { + it('compresses long summary to 3 lines', () => { const r = parseAiResponse({ - title: '제목', - summary: 'a\nb\nc\nd\ne', - tags: [] + title: '제목', summary: 'a\nb\nc\nd\ne', tags: [] }); const lines = r.summary.split('\n'); expect(lines).toHaveLength(3); - expect(lines[0]).toBe('a'); expect(lines[2]).toBe('c d e'); }); - it('filters invalid tags but keeps valid ones', () => { + it('filters invalid tags', () => { const r = parseAiResponse({ - title: '제목', - summary: 'a\nb\nc', + title: '제목', summary: 'a\nb\nc', tags: ['good-tag', 'BadCase', 'has space', 'ok2', ''] }); expect(r.tags).toEqual(['good-tag', 'ok2']); @@ -1604,28 +1586,23 @@ describe('parseAiResponse', () => { it('caps tags to 3', () => { const r = parseAiResponse({ - title: '제목', - summary: 'a\nb\nc', + title: '제목', summary: 'a\nb\nc', tags: ['a', 'b', 'c', 'd', 'e'] }); expect(r.tags).toHaveLength(3); }); - it('rejects when input is not an object', () => { + it('rejects non-object input', () => { expect(() => parseAiResponse('nope')).toThrow(); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/ai-schema.test.ts` +`npx vitest run tests/unit/ai-schema.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `ai/schema.ts`** - -Write `src/main/ai/schema.ts`: +- [ ] **Step 3: Implement schema** ```ts import { z } from 'zod'; @@ -1646,10 +1623,7 @@ export interface AiResponse { } function normalizeSummary(raw: string): string { - const lines = raw - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l.length > 0); + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); if (lines.length === 0) throw new Error('summary is empty'); if (lines.length === 3) return lines.join('\n'); if (lines.length < 3) { @@ -1666,22 +1640,17 @@ export function parseAiResponse(raw: unknown): AiResponse { if (!KOREAN_REGEX.test(parsed.title)) { throw new Error('title must contain Korean characters'); } - const title = parsed.title.slice(0, 60); - const summary = normalizeSummary(parsed.summary); - const tags = parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3); - return { title, summary, tags }; + return { + title: parsed.title.slice(0, 60), + summary: normalizeSummary(parsed.summary), + tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3) + }; } ``` -- [ ] **Step 4: Run schema tests** +- [ ] **Step 4: Implement prompt** -Run: `npx vitest run tests/unit/ai-schema.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Add the prompt template** - -Write `src/main/ai/prompt.ts`: +`src/main/ai/prompt.ts`: ```ts export const PROMPT_VERSION = 1; @@ -1708,36 +1677,27 @@ Rules: } ``` -- [ ] **Step 6: Commit** +- [ ] **Step 5: Run + commit** ```bash +npx vitest run tests/unit/ai-schema.test.ts git add src/main/ai/schema.ts src/main/ai/prompt.ts tests/unit/ai-schema.test.ts -git commit -m "feat(ai): add zod schema validator and korean-first prompt template" +git commit -m "feat(ai): zod schema validator + Korean-first prompt" ``` --- ## Task 11: InferenceProvider interface -**Files:** -- Create: `src/main/ai/InferenceProvider.ts` +**Files:** Create `src/main/ai/InferenceProvider.ts` -- [ ] **Step 1: Implement the interface** - -Write `src/main/ai/InferenceProvider.ts`: +- [ ] **Step 1: Implement** ```ts import type { AiResponse } from './schema.js'; -export interface GenerateInput { - text: string; -} - -export interface HealthResult { - ok: boolean; - model?: string; - reason?: string; -} +export interface GenerateInput { text: string; } +export interface HealthResult { ok: boolean; model?: string; reason?: string; } export interface InferenceProvider { readonly name: string; @@ -1750,21 +1710,16 @@ export interface InferenceProvider { ```bash git add src/main/ai/InferenceProvider.ts -git commit -m "feat(ai): declare InferenceProvider interface" +git commit -m "feat(ai): InferenceProvider interface" ``` --- -## Task 12: LocalOllamaProvider with undici mock +## Task 12: LocalOllamaProvider -**Files:** -- Create: `src/main/ai/LocalOllamaProvider.ts` -- Create: `tests/unit/LocalOllamaProvider.test.ts` -- Create: `tests/integration/ollama-golden.test.ts` +**Files:** Create `src/main/ai/LocalOllamaProvider.ts`, `tests/unit/LocalOllamaProvider.test.ts`, `tests/integration/ollama-golden.test.ts` -- [ ] **Step 1: Write failing unit tests using undici MockAgent** - -Write `tests/unit/LocalOllamaProvider.test.ts`: +- [ ] **Step 1: Failing unit tests** ```ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -1773,101 +1728,77 @@ import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; describe('LocalOllamaProvider', () => { let mock: MockAgent; - let originalDispatcher: ReturnType; + let original: ReturnType; beforeEach(() => { - originalDispatcher = getGlobalDispatcher(); + original = getGlobalDispatcher(); mock = new MockAgent(); mock.disableNetConnect(); setGlobalDispatcher(mock); }); afterEach(async () => { - setGlobalDispatcher(originalDispatcher); + setGlobalDispatcher(original); await mock.close(); }); - it('generate: parses Ollama JSON response and returns AiResponse', async () => { - const pool = mock.get('http://localhost:11434'); - pool.intercept({ path: '/api/generate', method: 'POST' }).reply(200, { - response: JSON.stringify({ - title: '회의 요약', - summary: '첫 줄\n둘째 줄\n셋째 줄', - tags: ['api-timeout'] - }) + it('generate parses Ollama JSON', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] }) }); - const provider = new LocalOllamaProvider(); - const result = await provider.generate({ text: 'input text' }); - expect(result.title).toBe('회의 요약'); - expect(result.tags).toEqual(['api-timeout']); + const r = await new LocalOllamaProvider().generate({ text: 'x' }); + expect(r.title).toBe('회의'); }); - it('generate: throws when response is not JSON', async () => { - const pool = mock.get('http://localhost:11434'); - pool.intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + it('generate throws on non-JSON', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'not json' }); - const provider = new LocalOllamaProvider(); - await expect(provider.generate({ text: 'x' })).rejects.toThrow(/json/i); + await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i); }); - it('generate: aborts on timeout', async () => { - const pool = mock.get('http://localhost:11434'); - pool - .intercept({ path: '/api/generate', method: 'POST' }) - .reply(() => { - return new Promise((resolve) => - setTimeout(() => resolve({ statusCode: 200, data: '{}' }), 500) - ); - }); - const provider = new LocalOllamaProvider({ timeoutMs: 50 }); - await expect(provider.generate({ text: 'x' })).rejects.toThrow(); + it('generate aborts on timeout', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(() => + new Promise((resolve) => setTimeout(() => resolve({ statusCode: 200, data: '{}' }), 500)) + ); + await expect( + new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' }) + ).rejects.toThrow(); }, 2000); - it('healthCheck: ok=true when gemma4:9b is in tags list', async () => { - const pool = mock.get('http://localhost:11434'); - pool.intercept({ path: '/api/tags', method: 'GET' }).reply(200, { - models: [{ name: 'gemma4:9b' }, { name: 'other:latest' }] + it('healthCheck ok=true when model present', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + models: [{ name: 'gemma4:9b' }] }); - const provider = new LocalOllamaProvider(); - const h = await provider.healthCheck(); + const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(true); expect(h.model).toBe('gemma4:9b'); }); - it('healthCheck: ok=false when model missing', async () => { - const pool = mock.get('http://localhost:11434'); - pool.intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + it('healthCheck ok=false when missing', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { models: [{ name: 'other:latest' }] }); - const provider = new LocalOllamaProvider(); - const h = await provider.healthCheck(); + const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toMatch(/gemma4:9b/); }); - it('healthCheck: ok=false when endpoint unreachable', async () => { - const pool = mock.get('http://localhost:11434'); - pool - .intercept({ path: '/api/tags', method: 'GET' }) + it('healthCheck ok=false on connection error', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('ECONNREFUSED')); - const provider = new LocalOllamaProvider(); - const h = await provider.healthCheck(); + const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toMatch(/connect|refused|unreachable/i); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/LocalOllamaProvider.test.ts` +`npx vitest run tests/unit/LocalOllamaProvider.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `LocalOllamaProvider`** - -Write `src/main/ai/LocalOllamaProvider.ts`: +- [ ] **Step 3: Implement** ```ts import { request } from 'undici'; @@ -1922,11 +1853,8 @@ export class LocalOllamaProvider implements InferenceProvider { const body = (await res.body.json()) as { response?: string }; if (!body.response) throw new Error('missing response field'); let parsed: unknown; - try { - parsed = JSON.parse(body.response); - } catch (err) { - throw new Error(`invalid json in response: ${String(err)}`); - } + try { parsed = JSON.parse(body.response); } + catch (err) { throw new Error(`invalid json in response: ${String(err)}`); } return parseAiResponse(parsed); } finally { clearTimeout(timer); @@ -1936,14 +1864,11 @@ export class LocalOllamaProvider implements InferenceProvider { async healthCheck(): Promise { try { const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); - if (res.statusCode !== 200) { - return { ok: false, reason: `tags http ${res.statusCode}` }; - } + if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` }; const body = (await res.body.json()) as { models?: Array<{ name: string }> }; const found = body.models?.some((m) => m.name === this.model); - return found - ? { ok: true, model: this.model } - : { ok: false, reason: `${this.model} not installed` }; + return found ? { ok: true, model: this.model } + : { ok: false, reason: `${this.model} not installed` }; } catch (err) { return { ok: false, reason: `unreachable: ${(err as Error).message}` }; } @@ -1951,15 +1876,9 @@ export class LocalOllamaProvider implements InferenceProvider { } ``` -- [ ] **Step 4: Run unit tests** +- [ ] **Step 4: Integration golden test (opt-in)** -Run: `npx vitest run tests/unit/LocalOllamaProvider.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Add opt-in integration golden test** - -Write `tests/integration/ollama-golden.test.ts`: +`tests/integration/ollama-golden.test.ts`: ```ts import { describe, it, expect, beforeAll } from 'vitest'; @@ -1976,40 +1895,35 @@ describe.skipIf(skip)('LocalOllamaProvider integration', () => { }); const cases = [ - '회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음. 원인 아직 미상.', + '회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음.', 'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.', '오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.' ]; - it.each(cases)('generates Korean title + 3-line summary for case: %s', async (input) => { + it.each(cases)('Korean title + 3 lines for: %s', async (input) => { const r = await provider.generate({ text: input }); expect(/[가-힣]/.test(r.title)).toBe(true); expect(r.summary.split('\n')).toHaveLength(3); - for (const t of r.tags) { - expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); - } + for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); }, 180_000); }); ``` -- [ ] **Step 6: Commit** +- [ ] **Step 5: Run + commit** ```bash +npx vitest run tests/unit/LocalOllamaProvider.test.ts git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts tests/integration/ollama-golden.test.ts -git commit -m "feat(ai): add LocalOllamaProvider with 120s timeout, health check, and integration test harness" +git commit -m "feat(ai): LocalOllamaProvider with 120s timeout + integration harness" ``` --- ## Task 13: AiWorker -**Files:** -- Create: `src/main/ai/AiWorker.ts` -- Create: `tests/unit/AiWorker.test.ts` +**Files:** Create `src/main/ai/AiWorker.ts`, `tests/unit/AiWorker.test.ts` -- [ ] **Step 1: Write failing tests** - -Write `tests/unit/AiWorker.test.ts`: +- [ ] **Step 1: Failing tests** ```ts import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -2024,9 +1938,7 @@ function makeProvider(overrides: Partial = {}): InferenceProv return { name: 'mock', generate: vi.fn(async (): Promise => ({ - title: '제목', - summary: 'a\nb\nc', - tags: ['tag'] + title: '제목', summary: 'a\nb\nc', tags: ['tag'] })), healthCheck: vi.fn(async () => ({ ok: true })), ...overrides @@ -2043,16 +1955,15 @@ describe('AiWorker', () => { repo = new NoteRepository(db); }); - it('processes a pending job and marks note done', async () => { + it('processes a pending job and marks done', async () => { const { id } = repo.create({ rawText: 'x' }); - const provider = makeProvider(); const updates: string[] = []; - const worker = new AiWorker(repo, provider, { + const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0], onUpdate: (note) => updates.push(note.aiStatus) }); - await worker.enqueue(id); - await worker.drain(); + await w.enqueue(id); + await w.drain(); expect(repo.findById(id)?.aiStatus).toBe('done'); expect(updates).toContain('done'); }); @@ -2060,60 +1971,52 @@ describe('AiWorker', () => { it('retries 3 times then marks failed', async () => { const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ - generate: vi.fn(async () => { - throw new Error('boom'); - }) + generate: vi.fn(async () => { throw new Error('boom'); }) }); - const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); - await worker.enqueue(id); - await worker.drain(); + const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + await w.enqueue(id); + await w.drain(); const note = repo.findById(id)!; expect(note.aiStatus).toBe('failed'); expect(note.aiError).toContain('boom'); expect(provider.generate).toHaveBeenCalledTimes(3); }); - it('loadFromDb re-queues all pending jobs on startup', async () => { - const { id: a } = repo.create({ rawText: 'a' }); - const { id: b } = repo.create({ rawText: 'b' }); - const provider = makeProvider(); - const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); - await worker.loadFromDb(); - await worker.drain(); + 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] }); + await w.loadFromDb(); + await w.drain(); expect(repo.findById(a)?.aiStatus).toBe('done'); expect(repo.findById(b)?.aiStatus).toBe('done'); }); - it('processes jobs sequentially (concurrency 1)', async () => { + it('processes sequentially (concurrency 1)', async () => { const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id]; let running = 0; - let maxConcurrent = 0; + let max = 0; const provider = makeProvider({ generate: vi.fn(async () => { - running++; - maxConcurrent = Math.max(maxConcurrent, running); + running++; max = Math.max(max, running); await new Promise((r) => setTimeout(r, 10)); running--; return { title: '제목', summary: 'a\nb\nc', tags: [] }; }) }); - const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); - for (const id of ids) await worker.enqueue(id); - await worker.drain(); - expect(maxConcurrent).toBe(1); + const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + for (const id of ids) await w.enqueue(id); + await w.drain(); + expect(max).toBe(1); }); }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/AiWorker.test.ts` +`npx vitest run tests/unit/AiWorker.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `AiWorker`** - -Write `src/main/ai/AiWorker.ts`: +- [ ] **Step 3: Implement** ```ts import type { NoteRepository } from '../repository/NoteRepository.js'; @@ -2130,10 +2033,7 @@ export interface AiWorkerOptions { }; } -interface Job { - noteId: string; - attempts: number; -} +interface Job { noteId: string; attempts: number; } export class AiWorker { private queue: Job[] = []; @@ -2150,11 +2050,7 @@ export class AiWorker { ) { this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; this.onUpdate = opts.onUpdate; - this.logger = opts.logger ?? { - info: () => {}, - warn: () => {}, - error: () => {} - }; + this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} }; } async enqueue(noteId: string): Promise { @@ -2163,8 +2059,7 @@ export class AiWorker { } async loadFromDb(): Promise { - const jobs = this.repo.getAllPendingJobs(); - for (const j of jobs) { + for (const j of this.repo.getAllPendingJobs()) { this.queue.push({ noteId: j.noteId, attempts: j.attempts }); } this.kick(); @@ -2180,10 +2075,7 @@ export class AiWorker { private kick(): void { if (this.running) return; - if (this.queue.length === 0) { - this.resolveDrainers(); - return; - } + if (this.queue.length === 0) { this.resolveDrainers(); return; } this.running = true; void this.loop(); } @@ -2206,23 +2098,21 @@ export class AiWorker { } private async processJob(job: Job): Promise { - const maxAttempts = this.backoffsMs.length; - for (let attempt = job.attempts; attempt < maxAttempts; attempt++) { + const max = this.backoffsMs.length; + for (let attempt = job.attempts; attempt < max; attempt++) { try { const note = this.repo.findById(job.noteId); if (!note || note.aiStatus !== 'pending') return; const res = await this.provider.generate({ text: note.rawText }); this.repo.updateAiResult(job.noteId, { - title: res.title, - summary: res.summary, - tags: res.tags, + title: res.title, summary: res.summary, tags: res.tags, provider: this.provider.name }); this.logger.info('ai.done', { noteId: job.noteId, attempt }); - this.emitUpdate(job.noteId); + this.emit(job.noteId); return; } catch (err) { - const isLast = attempt === maxAttempts - 1; + const isLast = attempt === max - 1; const msg = (err as Error).message; this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg }); const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString(); @@ -2230,7 +2120,7 @@ export class AiWorker { if (isLast) { this.repo.markAiFailed(job.noteId, msg); this.logger.error('ai.failed', { noteId: job.noteId, err: msg }); - this.emitUpdate(job.noteId); + this.emit(job.noteId); return; } await this.sleep(this.backoffsMs[attempt + 1] ?? 0); @@ -2238,7 +2128,7 @@ export class AiWorker { } } - private emitUpdate(noteId: string): void { + private emit(noteId: string): void { if (!this.onUpdate) return; const note = this.repo.findById(noteId); if (note) this.onUpdate(note); @@ -2251,30 +2141,21 @@ export class AiWorker { } ``` -- [ ] **Step 4: Run tests** - -Run: `npx vitest run tests/unit/AiWorker.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Commit** +- [ ] **Step 4: Run + commit** ```bash +npx vitest run tests/unit/AiWorker.test.ts git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts -git commit -m "feat(ai): add AiWorker with sequential queue, 3-attempt backoff, and on-update emitter" +git commit -m "feat(ai): AiWorker with sequential queue, 3-attempt backoff" ``` --- -## Task 14: CaptureService +## Task 14: CaptureService (with notification trigger) -**Files:** -- Create: `src/main/services/CaptureService.ts` -- Create: `tests/unit/CaptureService.test.ts` +**Files:** Create `src/main/services/CaptureService.ts`, `tests/unit/CaptureService.test.ts` -- [ ] **Step 1: Write failing tests** - -Write `tests/unit/CaptureService.test.ts`: +- [ ] **Step 1: Failing tests** ```ts import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -2293,6 +2174,7 @@ describe('CaptureService', () => { let store: MediaStore; let tmp: string; let enqueued: string[]; + let celebrated: string[]; let svc: CaptureService; beforeEach(() => { @@ -2302,20 +2184,21 @@ describe('CaptureService', () => { tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); store = new MediaStore(tmp); enqueued = []; + celebrated = []; svc = new CaptureService(repo, store, { - enqueue: async (id) => { - enqueued.push(id); - } + enqueue: async (id) => { enqueued.push(id); }, + celebrate: (id) => { celebrated.push(id); } }); }); - it('persists text-only note and enqueues ai job', async () => { + it('persists text-only and triggers enqueue + celebrate', async () => { const { noteId } = await svc.submit({ text: '안녕', images: [] }); expect(repo.findById(noteId)?.rawText).toBe('안녕'); expect(enqueued).toEqual([noteId]); + expect(celebrated).toEqual([noteId]); }); - it('saves images under media/{noteId}/ and records in db', async () => { + it('saves images under media/{noteId}/', async () => { const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer; const { noteId } = await svc.submit({ text: 'x', images: [img] }); const note = repo.findById(noteId)!; @@ -2323,11 +2206,12 @@ describe('CaptureService', () => { expect(note.media[0].relPath.startsWith(`media/${noteId}/`)).toBe(true); }); - it('rejects empty submit (no text, no images)', async () => { + it('rejects empty submit', async () => { await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i); + expect(celebrated).toHaveLength(0); }); - it('deleteNote removes db row and media directory', async () => { + it('deleteNote removes db row + media dir', async () => { const img = new Uint8Array([0, 1, 2, 3]).buffer; const { noteId } = await svc.submit({ text: 't', images: [img] }); await svc.deleteNote(noteId); @@ -2336,15 +2220,11 @@ describe('CaptureService', () => { }); ``` -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Verify failure** -Run: `npx vitest run tests/unit/CaptureService.test.ts` +`npx vitest run tests/unit/CaptureService.test.ts` → FAIL. -Expected: FAIL. - -- [ ] **Step 3: Implement `CaptureService`** - -Write `src/main/services/CaptureService.ts`: +- [ ] **Step 3: Implement** ```ts import type { NoteRepository } from '../repository/NoteRepository.js'; @@ -2352,6 +2232,7 @@ import type { MediaStore } from './MediaStore.js'; export interface CaptureDeps { enqueue: (noteId: string) => Promise; + celebrate: (noteId: string) => void; } export interface SubmitInput { @@ -2388,6 +2269,7 @@ export class CaptureService { this.repo.insertMedia(rows); } await this.deps.enqueue(id); + this.deps.celebrate(id); return { noteId: id }; } @@ -2398,29 +2280,123 @@ export class CaptureService { } ``` -- [ ] **Step 4: Run tests** - -Run: `npx vitest run tests/unit/CaptureService.test.ts` - -Expected: all pass. - -- [ ] **Step 5: Commit** +- [ ] **Step 4: Run + commit** ```bash +npx vitest run tests/unit/CaptureService.test.ts git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts -git commit -m "feat(capture): add CaptureService orchestrating note + media persistence and ai enqueue" +git commit -m "feat(capture): CaptureService with enqueue + celebrate hooks" ``` --- -## Task 15: IPC captureApi handlers +## Task 15: NotificationService (post-submit reward toast) -**Files:** -- Create: `src/main/ipc/captureApi.ts` +**Files:** Create `src/main/services/NotificationService.ts`, `tests/unit/NotificationService.test.ts` -- [ ] **Step 1: Implement handlers** +- [ ] **Step 1: Failing tests** -Write `src/main/ipc/captureApi.ts`: +```ts +import { describe, it, expect, vi } from 'vitest'; +import { NotificationService, REWARD_COPIES } from '@main/services/NotificationService.js'; + +describe('NotificationService', () => { + it('rotates copy deterministically by noteId hash', () => { + const fired: string[] = []; + const svc = new NotificationService({ + isSupported: () => true, + send: (body) => { fired.push(body); } + }); + const id1 = '00000000-0000-7000-8000-000000000001'; + svc.celebrate(id1); + svc.celebrate(id1); + expect(fired).toHaveLength(2); + expect(fired[0]).toBe(fired[1]); + expect(REWARD_COPIES).toContain(fired[0]); + }); + + it('different ids select different (eventually all 4)', () => { + const fired: string[] = []; + const svc = new NotificationService({ + isSupported: () => true, + send: (body) => { fired.push(body); } + }); + for (let i = 0; i < 32; i++) { + svc.celebrate(`id-${i}`); + } + const distinct = new Set(fired); + expect(distinct.size).toBe(REWARD_COPIES.length); + }); + + it('skips silently when notifications unsupported', () => { + const fired: string[] = []; + const svc = new NotificationService({ + isSupported: () => false, + send: (body) => { fired.push(body); } + }); + svc.celebrate('id-1'); + expect(fired).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Verify failure** + +`npx vitest run tests/unit/NotificationService.test.ts` → FAIL. + +- [ ] **Step 3: Implement** + +```ts +import { createHash } from 'node:crypto'; + +export const REWARD_COPIES = [ + '이 생각은 이제 Inkling이 들고 있습니다.', + '나중에 찾을 수 있게 보관했습니다.', + '방금 하나의 업무 기억을 구출했습니다.', + '기록 완료. 이제 잊어도 됩니다.' +] as const; + +export interface NotificationDeps { + isSupported: () => boolean; + send: (body: string) => void; +} + +export class NotificationService { + constructor(private deps: NotificationDeps) {} + + celebrate(noteId: string): void { + if (!this.deps.isSupported()) return; + const idx = this.pick(noteId); + const body = REWARD_COPIES[idx]!; + try { + this.deps.send(body); + } catch { + // Swallow notification errors — capture must not fail because of toast. + } + } + + private pick(noteId: string): number { + const hash = createHash('sha256').update(noteId).digest(); + return hash[0]! % REWARD_COPIES.length; + } +} +``` + +- [ ] **Step 4: Run + commit** + +```bash +npx vitest run tests/unit/NotificationService.test.ts +git add src/main/services/NotificationService.ts tests/unit/NotificationService.test.ts +git commit -m "feat(notify): NotificationService with 4 rotating reward copies" +``` + +--- + +## Task 16: IPC captureApi handlers + +**Files:** Create `src/main/ipc/captureApi.ts` + +- [ ] **Step 1: Implement** ```ts import { ipcMain } from 'electron'; @@ -2434,7 +2410,6 @@ export function registerCaptureApi( ipcMain.handle('capture:submit', async (_e, payload: { text: string; images: ArrayBuffer[] }) => { return captureService.submit(payload); }); - ipcMain.on('capture:hide', () => { getQuickCaptureWindow()?.hide(); }); @@ -2445,19 +2420,16 @@ export function registerCaptureApi( ```bash git add src/main/ipc/captureApi.ts -git commit -m "feat(ipc): register capture:submit and capture:hide handlers" +git commit -m "feat(ipc): capture:submit and capture:hide handlers" ``` --- -## Task 16: HotkeyService +## Task 17: HotkeyService -**Files:** -- Create: `src/main/services/HotkeyService.ts` +**Files:** Create `src/main/services/HotkeyService.ts` -- [ ] **Step 1: Implement the hotkey service** - -Write `src/main/services/HotkeyService.ts`: +- [ ] **Step 1: Implement** ```ts import { globalShortcut } from 'electron'; @@ -2475,10 +2447,8 @@ export class HotkeyService { if (globalShortcut.isRegistered(accel)) { return { ok: false, reason: `${accel} already registered by another app` }; } - const success = globalShortcut.register(accel, binding.onTrigger); - if (!success) { - return { ok: false, reason: `failed to register ${accel}` }; - } + const ok = globalShortcut.register(accel, binding.onTrigger); + if (!ok) return { ok: false, reason: `failed to register ${accel}` }; this.registered.push(accel); return { ok: true }; } @@ -2494,19 +2464,16 @@ export class HotkeyService { ```bash git add src/main/services/HotkeyService.ts -git commit -m "feat(hotkey): add HotkeyService wrapping globalShortcut with conflict reporting" +git commit -m "feat(hotkey): HotkeyService wrapping globalShortcut" ``` --- -## Task 17: QuickCaptureWindow +## Task 18: QuickCaptureWindow -**Files:** -- Create: `src/main/windows/quickCaptureWindow.ts` +**Files:** Create `src/main/windows/quickCaptureWindow.ts` -- [ ] **Step 1: Implement quickCaptureWindow** - -Write `src/main/windows/quickCaptureWindow.ts`: +- [ ] **Step 1: Implement** ```ts import { BrowserWindow, screen } from 'electron'; @@ -2516,34 +2483,22 @@ import { fileURLToPath } from 'node:url'; let win: BrowserWindow | null = null; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -export function getQuickCaptureWindow(): BrowserWindow | null { - return win; -} +export function getQuickCaptureWindow(): BrowserWindow | null { return win; } export function createQuickCaptureWindow(): BrowserWindow { if (win && !win.isDestroyed()) return win; - const primary = screen.getPrimaryDisplay(); - const W = 640; - const H = 280; + const W = 640, H = 280; const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x); const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y); win = new BrowserWindow({ - width: W, - height: H, - x, - y, - frame: false, - show: false, - alwaysOnTop: true, - skipTaskbar: true, - resizable: false, + width: W, height: H, x, y, + frame: false, show: false, alwaysOnTop: true, + skipTaskbar: true, resizable: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false + contextIsolation: true, nodeIntegration: false, sandbox: false } }); @@ -2553,17 +2508,13 @@ export function createQuickCaptureWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/quickcapture/index.html')); } - win.on('blur', () => { - if (win?.isVisible()) win.hide(); - }); - + win.on('blur', () => { if (win?.isVisible()) win.hide(); }); return win; } export function showQuickCapture(): void { const w = createQuickCaptureWindow(); - w.show(); - w.focus(); + w.show(); w.focus(); } ``` @@ -2571,22 +2522,16 @@ export function showQuickCapture(): void { ```bash git add src/main/windows/quickCaptureWindow.ts -git commit -m "feat(window): add frameless QuickCapture window centered on primary display" +git commit -m "feat(window): frameless QuickCaptureWindow centered on primary display" ``` --- -## Task 18: QuickCapture renderer UI +## Task 19: QuickCapture renderer (with v0.2 copy) -**Files:** -- Create: `src/renderer/quickcapture/index.html` -- Create: `src/renderer/quickcapture/main.tsx` -- Create: `src/renderer/quickcapture/App.tsx` -- Create: `src/renderer/quickcapture/api.ts` +**Files:** Create `src/renderer/quickcapture/index.html`, `main.tsx`, `App.tsx`, `api.ts` -- [ ] **Step 1: HTML entry** - -Write `src/renderer/quickcapture/index.html`: +- [ ] **Step 1: HTML** ```html @@ -2600,25 +2545,12 @@ Write `src/renderer/quickcapture/index.html`: body { -webkit-app-region: drag; } .card { -webkit-app-region: no-drag; - background: #1e1e24; - color: #eee; - border-radius: 12px; - box-shadow: 0 12px 48px rgba(0,0,0,0.4); - height: calc(100% - 24px); - margin: 12px; - display: flex; - flex-direction: column; - padding: 12px; - } - textarea { - flex: 1; - background: transparent; - color: inherit; - border: none; - outline: none; - font-size: 14px; - resize: none; + background: #1e1e24; color: #eee; + border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.4); + height: calc(100% - 24px); margin: 12px; + display: flex; flex-direction: column; padding: 12px; } + textarea { flex: 1; background: transparent; color: inherit; border: none; outline: none; font-size: 14px; resize: none; } .thumbs { display: flex; gap: 6px; } .thumbs img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; } .hint { color: #888; font-size: 11px; margin-top: 6px; } @@ -2632,40 +2564,29 @@ Write `src/renderer/quickcapture/index.html`: ``` -- [ ] **Step 2: `api.ts` wrapper** - -Write `src/renderer/quickcapture/api.ts`: +- [ ] **Step 2: `api.ts`** ```ts import type { CaptureApi } from '@shared/types'; - export const captureApi: CaptureApi = window.inkling.capture; ``` -- [ ] **Step 3: React main** - -Write `src/renderer/quickcapture/main.tsx`: +- [ ] **Step 3: `main.tsx`** ```tsx import React from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.js'; - createRoot(document.getElementById('root')!).render(); ``` -- [ ] **Step 4: React App with paste + submit** - -Write `src/renderer/quickcapture/App.tsx`: +- [ ] **Step 4: `App.tsx`** ```tsx import React, { useCallback, useEffect, useRef, useState } from 'react'; import { captureApi } from './api.js'; -interface PastedImage { - url: string; - buffer: ArrayBuffer; -} +interface PastedImage { url: string; buffer: ArrayBuffer; } export function App(): React.ReactElement { const [text, setText] = useState(''); @@ -2673,46 +2594,29 @@ export function App(): React.ReactElement { const [err, setErr] = useState(null); const ref = useRef(null); - useEffect(() => { - ref.current?.focus(); - }, []); + useEffect(() => { ref.current?.focus(); }, []); const submit = useCallback(async () => { setErr(null); - const trimmed = text.trim(); - if (trimmed.length === 0 && images.length === 0) return; + if (text.trim().length === 0 && images.length === 0) return; try { - await captureApi.submit({ - text, - images: images.map((i) => i.buffer) - }); - setText(''); - setImages([]); - captureApi.hide(); - } catch (e) { - setErr((e as Error).message); - } + await captureApi.submit({ text, images: images.map((i) => i.buffer) }); + setText(''); setImages([]); captureApi.hide(); + } catch (e) { setErr('저장에 실패했습니다. 다시 시도해주세요.'); } }, [text, images]); const cancel = useCallback(() => { if (text.trim().length > 5) { - const ok = window.confirm('정말 버릴까요?'); + const ok = window.confirm('이 한 줄을 흘려보낼까요?'); if (!ok) return; } - setText(''); - setImages([]); - captureApi.hide(); + setText(''); setImages([]); captureApi.hide(); }, [text]); useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - cancel(); - } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - void submit(); - } + if (e.key === 'Escape') { e.preventDefault(); cancel(); } + else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -2739,16 +2643,14 @@ export function App(): React.ReactElement { value={text} onChange={(e) => setText(e.target.value)} onPaste={onPaste} - placeholder="한 줄 던져놓으세요…" + placeholder="지금 머릿속에 있는 것 한 줄. 정리는 나중입니다." /> {images.length > 0 && (
- {images.map((i, idx) => ( - - ))} + {images.map((i, idx) => ())}
)} -
Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기 가능
+
Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기
{err &&
{err}
} ); @@ -2759,33 +2661,118 @@ export function App(): React.ReactElement { ```bash git add src/renderer/quickcapture/ -git commit -m "feat(quickcapture): add React UI with paste image support and keyboard submit" +git commit -m "feat(quickcapture): React UI with v0.2 recovery-friendly copy" ``` --- -## Task 19: IPC inboxApi handlers +## Task 20: IntentService -**Files:** -- Create: `src/main/ipc/inboxApi.ts` +**Files:** Create `src/main/services/IntentService.ts`, `tests/unit/IntentService.test.ts` -- [ ] **Step 1: Implement handlers** +- [ ] **Step 1: Failing tests** -Write `src/main/ipc/inboxApi.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; +import { IntentService } from '@main/services/IntentService.js'; + +describe('IntentService', () => { + let db: Database.Database; + let repo: NoteRepository; + let svc: IntentService; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + svc = new IntentService(repo); + }); + + it('setIntent stores text on note', () => { + const { id } = repo.create({ rawText: 'x' }); + svc.setIntent(id, '내일의 나에게'); + expect(repo.findById(id)?.userIntent).toBe('내일의 나에게'); + }); + + it('dismissIntent stamps prompted_at without setting intent', () => { + const { id } = repo.create({ rawText: 'x' }); + svc.dismissIntent(id); + const note = repo.findById(id)!; + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).not.toBeNull(); + }); + + it('rejects empty intent text', () => { + const { id } = repo.create({ rawText: 'x' }); + expect(() => svc.setIntent(id, ' ')).toThrow(/empty/i); + }); + + it('throws if note does not exist', () => { + expect(() => svc.setIntent('nonexistent', 'x')).toThrow(/not found/i); + expect(() => svc.dismissIntent('nonexistent')).toThrow(/not found/i); + }); +}); +``` + +- [ ] **Step 2: Verify failure** + +`npx vitest run tests/unit/IntentService.test.ts` → FAIL. + +- [ ] **Step 3: Implement** + +```ts +import type { NoteRepository } from '../repository/NoteRepository.js'; + +export class IntentService { + constructor(private repo: NoteRepository) {} + + setIntent(noteId: string, text: string): void { + if (text.trim().length === 0) throw new Error('empty intent text'); + if (!this.repo.findById(noteId)) throw new Error('note not found'); + this.repo.setIntent(noteId, text); + } + + dismissIntent(noteId: string): void { + if (!this.repo.findById(noteId)) throw new Error('note not found'); + this.repo.dismissIntent(noteId); + } +} +``` + +- [ ] **Step 4: Run + commit** + +```bash +npx vitest run tests/unit/IntentService.test.ts +git add src/main/services/IntentService.ts tests/unit/IntentService.test.ts +git commit -m "feat(intent): IntentService for set/dismiss with validation" +``` + +--- + +## Task 21: IPC inboxApi (with v0.2 endpoints) + +**Files:** Create `src/main/ipc/inboxApi.ts` + +- [ ] **Step 1: Implement** ```ts import { ipcMain, BrowserWindow } from 'electron'; import type { NoteRepository } from '../repository/NoteRepository.js'; -import type { StreakService } from '../services/StreakService.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'; export interface InboxIpcDeps { repo: NoteRepository; - streak: StreakService; + continuity: ContinuityService; capture: CaptureService; health: HealthChecker; + intent: IntentService; getInboxWindow: () => BrowserWindow | null; } @@ -2805,10 +2792,19 @@ export function registerInboxApi(deps: InboxIpcDeps): void { await deps.capture.deleteNote(noteId); }); - ipcMain.handle('inbox:streak', () => deps.streak.get()); + ipcMain.handle( + 'inbox:setIntent', + (_e, arg: { noteId: string; text: string }) => { + deps.intent.setIntent(arg.noteId, arg.text); + } + ); + ipcMain.handle('inbox:dismissIntent', (_e, noteId: string) => { + deps.intent.dismissIntent(noteId); + }); + + ipcMain.handle('inbox:continuity', () => deps.continuity.get()); ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount()); - ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus()); } @@ -2823,214 +2819,82 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): ```bash git add src/main/ipc/inboxApi.ts -git commit -m "feat(ipc): register inbox list/update/delete/streak/pending/ollama handlers" +git commit -m "feat(ipc): inbox handlers with v0.2 setIntent/dismissIntent/continuity" ``` --- -## Task 20: Inbox React app (listing) +## Task 22: Inbox React shell + store + recovery toast helper -**Files:** -- Replace: `src/renderer/inbox/index.html` (restore real script tag) -- Create: `src/renderer/inbox/main.tsx` -- Create: `src/renderer/inbox/App.tsx` -- Create: `src/renderer/inbox/store.ts` -- Create: `src/renderer/inbox/api.ts` +**Files:** Replace `src/renderer/inbox/index.html`. Create `src/renderer/inbox/main.tsx`, `App.tsx`, `store.ts`, `api.ts`, `recoveryToast.ts`. Create component stubs for `NoteCard`, `EditableField`, `IntentBanner`, `RecoveryToast`, `ContinuityBadge`, `PendingBanner`, `OllamaBanner` so the shell compiles before later tasks fill them. -- [ ] **Step 1: Restore real script tag in `index.html`** - -Replace the inline script placeholder in `src/renderer/inbox/index.html` with: +- [ ] **Step 1: Replace `index.html` body** ```html - - + + + + + + Inkling + + + +
+ + + ``` -- [ ] **Step 2: API wrapper** - -Write `src/renderer/inbox/api.ts`: +- [ ] **Step 2: `api.ts`** ```ts import type { InboxApi } from '@shared/types'; - export const inboxApi: InboxApi = window.inkling.inbox; ``` -- [ ] **Step 3: Zustand store** - -Write `src/renderer/inbox/store.ts`: +- [ ] **Step 3: `recoveryToast.ts`** ```ts -import { create } from 'zustand'; -import type { Note, StreakInfo } from '@shared/types'; +const KEY = 'inkling.recoveryDismissedAt'; -interface InboxState { - notes: Note[]; - streak: StreakInfo; - pendingCount: number; - ollamaStatus: { ok: boolean; reason?: string }; - loading: boolean; - loadInitial: () => Promise; - upsertNote: (note: Note) => void; - removeNote: (id: string) => void; +export function isRecoveryDismissedToday(now = new Date()): boolean { + const v = localStorage.getItem(KEY); + if (!v) return false; + const stored = new Date(v); + // Same KST date? + const kstNow = new Date(now.getTime() + 9 * 3600_000).toISOString().slice(0, 10); + const kstStored = new Date(stored.getTime() + 9 * 3600_000).toISOString().slice(0, 10); + return kstNow === kstStored; } -import { inboxApi } from './api.js'; - -export const useInbox = create((set, get) => ({ - notes: [], - streak: { current: 0, longest: 0 }, - pendingCount: 0, - ollamaStatus: { ok: true }, - loading: false, - async loadInitial() { - set({ loading: true }); - const [notes, streak, pendingCount, ollamaStatus] = await Promise.all([ - inboxApi.listNotes({ limit: 50 }), - inboxApi.getStreak(), - inboxApi.getPendingCount(), - inboxApi.getOllamaStatus() - ]); - set({ notes, streak, pendingCount, ollamaStatus, loading: false }); - }, - upsertNote(note) { - const existing = get().notes.findIndex((n) => n.id === note.id); - if (existing >= 0) { - const next = get().notes.slice(); - next[existing] = note; - set({ notes: next }); - } else { - set({ notes: [note, ...get().notes] }); - } - }, - removeNote(id) { - set({ notes: get().notes.filter((n) => n.id !== id) }); - } -})); -``` - -- [ ] **Step 4: React main** - -Write `src/renderer/inbox/main.tsx`: - -```tsx -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './App.js'; - -createRoot(document.getElementById('root')!).render(); -``` - -- [ ] **Step 5: Shell `App.tsx` with listing + pending banner** - -Write `src/renderer/inbox/App.tsx`: - -```tsx -import React, { useEffect } from 'react'; -import { useInbox } from './store.js'; -import { inboxApi } from './api.js'; -import { NoteCard } from './components/NoteCard.js'; -import { StreakBadge } from './components/StreakBadge.js'; -import { PendingBanner } from './components/PendingBanner.js'; -import { OllamaBanner } from './components/OllamaBanner.js'; - -export function App(): React.ReactElement { - const { notes, loading, loadInitial, upsertNote, removeNote } = useInbox(); - - useEffect(() => { - void loadInitial(); - const unsub = inboxApi.onNoteUpdated((note) => { - upsertNote(note); - void refresh(); - }); - const onFocus = () => { - void refresh(); - }; - window.addEventListener('focus', onFocus); - return () => { - unsub(); - window.removeEventListener('focus', onFocus); - }; - }, [loadInitial, upsertNote]); - - async function refresh() { - const [pendingCount, streak, ollamaStatus] = await Promise.all([ - inboxApi.getPendingCount(), - inboxApi.getStreak(), - inboxApi.getOllamaStatus() - ]); - useInbox.setState({ pendingCount, streak, ollamaStatus }); - } - - return ( - <> -
-

Inkling

- -
-
- - - {loading && notes.length === 0 ? ( -
불러오는 중…
- ) : notes.length === 0 ? ( -
Ctrl+Shift+J로 첫 메모를 남겨보세요.
- ) : ( - notes.map((n) => ( - removeNote(n.id)} /> - )) - )} -
- - ); +export function markRecoveryDismissed(now = new Date()): void { + localStorage.setItem(KEY, now.toISOString()); } ``` -- [ ] **Step 6: Commit (components are placeholders until later tasks; add stubs first to compile)** - -Create component stubs so the shell compiles: +- [ ] **Step 4: Component stubs** `src/renderer/inbox/components/NoteCard.tsx`: - ```tsx import React from 'react'; import type { Note } from '@shared/types'; -export function NoteCard({ note }: { note: Note; onDeleted: () => void }) { +export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) { return
{note.rawText}
; } ``` -`src/renderer/inbox/components/StreakBadge.tsx`: - -```tsx -import React from 'react'; -export function StreakBadge() { return null; } -``` - -`src/renderer/inbox/components/PendingBanner.tsx`: - -```tsx -import React from 'react'; -export function PendingBanner() { return null; } -``` - -`src/renderer/inbox/components/OllamaBanner.tsx`: - -```tsx -import React from 'react'; -export function OllamaBanner() { return null; } -``` - `src/renderer/inbox/components/EditableField.tsx`: - ```tsx import React, { CSSProperties } from 'react'; export function EditableField(props: { @@ -3043,166 +2907,414 @@ export function EditableField(props: { } ``` +`src/renderer/inbox/components/IntentBanner.tsx`: +```tsx +import React from 'react'; +export function IntentBanner(_: { noteId: string; onResolved: () => void }) { return null; } +``` + +`src/renderer/inbox/components/RecoveryToast.tsx`: +```tsx +import React from 'react'; +export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; } +``` + +`src/renderer/inbox/components/ContinuityBadge.tsx`: +```tsx +import React from 'react'; +export function ContinuityBadge() { return null; } +``` + +`src/renderer/inbox/components/PendingBanner.tsx`: +```tsx +import React from 'react'; +export function PendingBanner() { return null; } +``` + +`src/renderer/inbox/components/OllamaBanner.tsx`: +```tsx +import React from 'react'; +export function OllamaBanner() { return null; } +``` + +- [ ] **Step 5: Zustand store** + +`src/renderer/inbox/store.ts`: +```ts +import { create } from 'zustand'; +import type { Note, WeeklyContinuity } from '@shared/types'; +import { inboxApi } from './api.js'; + +interface InboxState { + notes: Note[]; + continuity: WeeklyContinuity; + pendingCount: number; + ollamaStatus: { ok: boolean; reason?: string }; + loading: boolean; + loadInitial: () => Promise; + refreshMeta: () => Promise; + upsertNote: (note: Note) => void; + removeNote: (id: string) => void; +} + +const emptyContinuity: WeeklyContinuity = { + weekStart: '', weekCount: 0, weekTarget: 7, + consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null +}; + +export const useInbox = create((set, get) => ({ + notes: [], + continuity: emptyContinuity, + pendingCount: 0, + ollamaStatus: { ok: true }, + loading: false, + async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, loading: false }); + }, + async refreshMeta() { + const [continuity, pendingCount, ollamaStatus] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus() + ]); + set({ continuity, pendingCount, ollamaStatus }); + }, + 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 }); + } else { + set({ notes: [note, ...get().notes] }); + } + }, + removeNote(id) { + set({ notes: get().notes.filter((n) => n.id !== id) }); + } +})); +``` + +- [ ] **Step 6: `main.tsx`** + +```tsx +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +createRoot(document.getElementById('root')!).render(); +``` + +- [ ] **Step 7: `App.tsx`** + +```tsx +import React, { useEffect, useState } from 'react'; +import { useInbox } from './store.js'; +import { inboxApi } from './api.js'; +import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js'; +import { NoteCard } from './components/NoteCard.js'; +import { ContinuityBadge } from './components/ContinuityBadge.js'; +import { PendingBanner } from './components/PendingBanner.js'; +import { OllamaBanner } from './components/OllamaBanner.js'; +import { RecoveryToast } from './components/RecoveryToast.js'; + +export function App(): React.ReactElement { + const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox(); + const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); + + useEffect(() => { + void loadInitial(); + const unsub = inboxApi.onNoteUpdated((note) => { + upsertNote(note); + void refreshMeta(); + }); + const onFocus = () => { void refreshMeta(); }; + window.addEventListener('focus', onFocus); + return () => { unsub(); window.removeEventListener('focus', onFocus); }; + }, [loadInitial, refreshMeta, upsertNote]); + + const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; + + return ( + <> +
+

Inkling

+ +
+
+ + { markRecoveryDismissed(); setRecoveryDismissed(true); }} + /> + + {loading && notes.length === 0 ? ( +
불러오는 중…
+ ) : notes.length === 0 ? ( +
첫 기억을 구출해보세요. Ctrl+Shift+J
+ ) : ( + notes.map((n) => ( + removeNote(n.id)} onUpdated={(u) => upsertNote(u)} /> + )) + )} +
+ + ); +} +``` + +- [ ] **Step 8: Commit** + ```bash git add src/renderer/inbox/ -git commit -m "feat(inbox): add React shell with zustand store and component stubs" +git commit -m "feat(inbox): React shell + store + component stubs (v0.2)" ``` --- -## Task 21: NoteCard rendering for all states +## Task 23: ContinuityBadge -**Files:** -- Replace: `src/renderer/inbox/components/NoteCard.tsx` +**Files:** Replace `src/renderer/inbox/components/ContinuityBadge.tsx` -- [ ] **Step 1: Implement full NoteCard** +- [ ] **Step 1: Implement** -Replace `src/renderer/inbox/components/NoteCard.tsx`: +```tsx +import React from 'react'; +import { useInbox } from '../store.js'; + +export function ContinuityBadge(): React.ReactElement | null { + const c = useInbox((s) => s.continuity); + if (!c.weekStart) return null; + if (c.weekCount === 0) { + return
이번 주 한 줄이면 시작입니다
; + } + if (c.weekCount < c.weekTarget) { + return ( +
+ 이번 주 {c.weekCount}/{c.weekTarget} +
+ ); + } + return ( +
+ 이번 주 {c.weekCount}/{c.weekTarget} ✓ + {c.consecutiveCompleteWeeks > 0 && ( + · 연속 {c.consecutiveCompleteWeeks}주 완성 + )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/renderer/inbox/components/ContinuityBadge.tsx +git commit -m "feat(inbox): ContinuityBadge with recovery-friendly copy" +``` + +--- + +## Task 24: PendingBanner + +**Files:** Replace `src/renderer/inbox/components/PendingBanner.tsx` + +- [ ] **Step 1: Implement** + +```tsx +import React from 'react'; +import { useInbox } from '../store.js'; + +export function PendingBanner(): React.ReactElement | null { + const count = useInbox((s) => s.pendingCount); + if (count === 0) return null; + return ( +
+ 🟡 Inkling이 정리하는 중: {count} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/renderer/inbox/components/PendingBanner.tsx +git commit -m "feat(inbox): PendingBanner with v0.2 copy" +``` + +--- + +## Task 25: NoteCard with AI labels + intent badge + IntentBanner integration + +**Files:** Replace `src/renderer/inbox/components/NoteCard.tsx` + +- [ ] **Step 1: Implement** ```tsx import React, { useState } from 'react'; import type { Note } from '@shared/types'; import { inboxApi } from '../api.js'; import { EditableField } from './EditableField.js'; +import { IntentBanner } from './IntentBanner.js'; interface Props { note: Note; onDeleted: () => void; + onUpdated: (n: Note) => void; } -export function NoteCard({ note, onDeleted }: Props): React.ReactElement { +const aiBadgeStyle: React.CSSProperties = { + display: 'inline-block', marginLeft: 6, padding: '1px 5px', + background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle' +}; + +export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement { const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done'); - const [localNote, setLocalNote] = useState(note); + const [local, setLocal] = useState(note); + + React.useEffect(() => { setLocal(note); }, [note]); const formatted = new Date(note.createdAt).toLocaleString('ko-KR'); async function handleDelete() { - const ok = window.confirm('이 메모를 삭제할까요?'); - if (!ok) return; + if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return; await inboxApi.deleteNote(note.id); onDeleted(); } async function saveTitle(next: string) { await inboxApi.updateAiFields(note.id, { title: next }); - setLocalNote({ ...localNote, aiTitle: next }); + const updated = { ...local, aiTitle: next, titleEditedByUser: true }; + setLocal(updated); onUpdated(updated); } async function saveSummary(next: string) { await inboxApi.updateAiFields(note.id, { summary: next }); - setLocalNote({ ...localNote, aiSummary: next }); + const updated = { ...local, aiSummary: next, summaryEditedByUser: true }; + setLocal(updated); onUpdated(updated); } async function removeTag(tagName: string) { - const next = localNote.tags.filter((t) => t.name !== tagName).map((t) => t.name); + const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name); await inboxApi.updateAiFields(note.id, { tags: next }); - setLocalNote({ ...localNote, tags: localNote.tags.filter((t) => t.name !== tagName) }); + const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) }; + setLocal(updated); onUpdated(updated); } + async function saveIntent(next: string) { + await inboxApi.setIntent(note.id, next); + const now = new Date().toISOString(); + const updated = { ...local, userIntent: next, intentPromptedAt: local.intentPromptedAt ?? now }; + setLocal(updated); onUpdated(updated); + } + + const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null; + return ( -
+
{formatted}
- {localNote.aiStatus === 'pending' && ( + {showIntentBanner && ( + { + const now = new Date().toISOString(); + const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now }; + setLocal(updated); onUpdated(updated); + }} + /> + )} + + {local.aiStatus === 'pending' && (
- 처리 중… + Inkling이 정리하는 중…
)} - {localNote.aiStatus === 'failed' && ( -
- AI 처리 실패 + {local.aiStatus === 'failed' && ( +
+ 정리 보류 — 원문은 안전합니다
)} - {localNote.aiStatus === 'done' && ( + {local.aiStatus === 'done' && ( <> - - - {localNote.tags.length > 0 && ( +
+ + {!local.titleEditedByUser && AI} +
+
+ + {!local.summaryEditedByUser && AI} +
+ {local.tags.length > 0 && (
- {localNote.tags.map((t) => ( + {local.tags.map((t) => ( void removeTag(t.name)} style={{ background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4', color: t.source === 'ai' ? '#0a4b80' : '#236b1a', - padding: '2px 8px', - borderRadius: 12, - fontSize: 12, - cursor: 'pointer' + padding: '2px 8px', borderRadius: 12, fontSize: 12, cursor: 'pointer' }} - title="클릭으로 제거" + title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'} > - {t.name} + {t.name}{t.source === 'ai' && AI} ))}
)} + {local.userIntent !== null && ( +
+ 💡 + +
+ )} )} - {localNote.media.length > 0 && ( + {local.media.length > 0 && (
- {localNote.media.map((m) => ( -
+ {local.media.map((m) => ( +
))}
)}
- {rawOpen && ( -
-            {localNote.rawText}
+          
+            {local.rawText}
           
)}
-
@@ -3215,19 +3327,16 @@ export function NoteCard({ note, onDeleted }: Props): React.ReactElement { ```bash git add src/renderer/inbox/components/NoteCard.tsx -git commit -m "feat(inbox): render NoteCard for pending/done/failed states with media + raw toggle" +git commit -m "feat(inbox): NoteCard with AI proposal labels, intent badge, IntentBanner slot" ``` --- -## Task 22: EditableField +## Task 26: EditableField -**Files:** -- Replace: `src/renderer/inbox/components/EditableField.tsx` (stub created in Task 20) +**Files:** Replace `src/renderer/inbox/components/EditableField.tsx` (stub from Task 22) -- [ ] **Step 1: Implement EditableField** - -Replace `src/renderer/inbox/components/EditableField.tsx`: +- [ ] **Step 1: Implement** ```tsx import React, { useEffect, useRef, useState, CSSProperties } from 'react'; @@ -3240,47 +3349,30 @@ interface Props { } export function EditableField({ - value, - onSave, - style, - singleLine = true + value, onSave, style, singleLine = true }: Props): React.ReactElement { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); const [error, setError] = useState(false); const ref = useRef(null); - useEffect(() => { - if (!editing) setDraft(value); - }, [value, editing]); - - useEffect(() => { - if (editing) ref.current?.focus(); - }, [editing]); + useEffect(() => { if (!editing) setDraft(value); }, [value, editing]); + useEffect(() => { if (editing) ref.current?.focus(); }, [editing]); async function commit() { - if (draft === value) { - setEditing(false); - return; - } - try { - await onSave(draft); - setEditing(false); - } catch { - setError(true); - setDraft(value); - setTimeout(() => setError(false), 800); - } + if (draft === value) { setEditing(false); return; } + try { await onSave(draft); setEditing(false); } + catch { setError(true); setDraft(value); setTimeout(() => setError(false), 800); } } if (!editing) { return ( -
setEditing(true)} style={{ ...style, cursor: 'text', outline: error ? '1px solid #c93030' : 'none' }} > - {value} -
+ {value || (비어 있음)} + ); } @@ -3293,10 +3385,7 @@ export function EditableField({ onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - if (e.key === 'Escape') { - setDraft(value); - setEditing(false); - } + if (e.key === 'Escape') { setDraft(value); setEditing(false); } }} style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 2 }} /> @@ -3310,10 +3399,7 @@ export function EditableField({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { - if (e.key === 'Escape') { - setDraft(value); - setEditing(false); - } + if (e.key === 'Escape') { setDraft(value); setEditing(false); } }} style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 4, width: '100%', minHeight: 60 }} /> @@ -3325,51 +3411,91 @@ export function EditableField({ ```bash git add src/renderer/inbox/components/EditableField.tsx -git commit -m "feat(inbox): add EditableField with blur-save, enter-commit, esc-cancel" +git commit -m "feat(inbox): EditableField with blur-save, enter-commit, esc-cancel" ``` --- -## Task 23: StreakBadge + PendingBanner +## Task 27: IntentBanner component -**Files:** -- Replace: `src/renderer/inbox/components/StreakBadge.tsx` -- Replace: `src/renderer/inbox/components/PendingBanner.tsx` +**Files:** Replace `src/renderer/inbox/components/IntentBanner.tsx`. Create `src/shared/intentPrompts.ts`. -- [ ] **Step 1: StreakBadge** +- [ ] **Step 1: Prompt constants** -Replace `src/renderer/inbox/components/StreakBadge.tsx`: +`src/shared/intentPrompts.ts`: +```ts +export const INTENT_PROMPTS = [ + '내일의 내가 이 메모에서 꼭 알아야 할 것은?', + '이 메모가 중요한 이유를 한 줄로?', + '이 문제를 다시 만나면 무엇을 먼저 확인할까요?', + '동료에게 공유한다면 제목을 뭐라고?' +] as const; -```tsx -import React from 'react'; -import { useInbox } from '../store.js'; - -export function StreakBadge(): React.ReactElement | null { - const streak = useInbox((s) => s.streak); - if (streak.current === 0 && streak.longest === 0) return null; - return ( -
- 🔥 연속 {streak.current}일{' '} - (최장 {streak.longest}일) -
- ); +export function pickIntentPrompt(noteId: string): string { + let h = 0; + for (let i = 0; i < noteId.length; i++) h = (h * 31 + noteId.charCodeAt(i)) | 0; + const idx = Math.abs(h) % INTENT_PROMPTS.length; + return INTENT_PROMPTS[idx]!; } ``` -- [ ] **Step 2: PendingBanner** - -Replace `src/renderer/inbox/components/PendingBanner.tsx`: +- [ ] **Step 2: Implement IntentBanner** ```tsx -import React from 'react'; -import { useInbox } from '../store.js'; +import React, { useState, useCallback } from 'react'; +import { inboxApi } from '../api.js'; +import { pickIntentPrompt } from '@shared/intentPrompts'; + +interface Props { + noteId: string; + onResolved: (intentText: string | null) => void; +} + +export function IntentBanner({ noteId, onResolved }: Props): React.ReactElement { + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const prompt = pickIntentPrompt(noteId); + + const submit = useCallback(async () => { + const text = draft.trim(); + if (text.length === 0) return; + setBusy(true); + try { + await inboxApi.setIntent(noteId, text); + onResolved(text); + } finally { setBusy(false); } + }, [draft, noteId, onResolved]); + + const skip = useCallback(async () => { + setBusy(true); + try { + await inboxApi.dismissIntent(noteId); + onResolved(null); + } finally { setBusy(false); } + }, [noteId, onResolved]); -export function PendingBanner(): React.ReactElement | null { - const count = useInbox((s) => s.pendingCount); - if (count === 0) return null; return ( -
- 🟡 Ollama 처리 중: {count}건 +
+
💭 {prompt}
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }} + placeholder="한 줄 입력 (200자)" + disabled={busy} + style={{ flex: 1, border: '1px solid #ddd', borderRadius: 4, padding: '4px 6px', fontSize: 13 }} + maxLength={200} + /> + + +
); } @@ -3378,21 +3504,51 @@ export function PendingBanner(): React.ReactElement | null { - [ ] **Step 3: Commit** ```bash -git add src/renderer/inbox/components/StreakBadge.tsx src/renderer/inbox/components/PendingBanner.tsx -git commit -m "feat(inbox): render streak badge and pending-count banner" +git add src/renderer/inbox/components/IntentBanner.tsx src/shared/intentPrompts.ts +git commit -m "feat(inbox): IntentBanner with rotating prompts (Strategy §2.2)" ``` --- -## Task 24: HealthChecker + OllamaBanner +## Task 28: RecoveryToast component -**Files:** -- Create: `src/main/services/HealthChecker.ts` -- Replace: `src/renderer/inbox/components/OllamaBanner.tsx` +**Files:** Replace `src/renderer/inbox/components/RecoveryToast.tsx` -- [ ] **Step 1: Implement HealthChecker** +- [ ] **Step 1: Implement** -Write `src/main/services/HealthChecker.ts`: +```tsx +import React from 'react'; + +interface Props { + show: boolean; + onDismiss: () => void; +} + +export function RecoveryToast({ show, onDismiss }: Props): React.ReactElement | null { + if (!show) return null; + return ( +
+ 🌱 흐름을 다시 이어갑니다 + +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/renderer/inbox/components/RecoveryToast.tsx +git commit -m "feat(inbox): RecoveryToast for ≥7-day gap reentry" +``` + +--- + +## Task 29: HealthChecker + OllamaBanner with v0.2 copy + +**Files:** Create `src/main/services/HealthChecker.ts`. Replace `src/renderer/inbox/components/OllamaBanner.tsx`. + +- [ ] **Step 1: HealthChecker** ```ts import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js'; @@ -3406,16 +3562,12 @@ export class HealthChecker { return this.last; } - lastStatus(): HealthResult { - return this.last; - } + lastStatus(): HealthResult { return this.last; } } ``` - [ ] **Step 2: OllamaBanner** -Replace `src/renderer/inbox/components/OllamaBanner.tsx`: - ```tsx import React from 'react'; import { useInbox } from '../store.js'; @@ -3423,10 +3575,13 @@ import { useInbox } from '../store.js'; export function OllamaBanner(): React.ReactElement | null { const status = useInbox((s) => s.ollamaStatus); if (status.ok) return null; + const isMissing = status.reason?.includes('not installed'); + const message = isMissing + ? '`ollama pull gemma4:9b` 실행 후 앱을 재시작해주세요.' + : 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.'; return (
- ⚠ {status.reason ?? 'Ollama 상태 확인 실패'} — Ollama를 실행하거나 `ollama pull gemma4:9b` - 후 앱을 재시작하세요. + ⚠ {message}
); } @@ -3436,20 +3591,16 @@ export function OllamaBanner(): React.ReactElement | null { ```bash git add src/main/services/HealthChecker.ts src/renderer/inbox/components/OllamaBanner.tsx -git commit -m "feat(health): add HealthChecker and Inbox banner for Ollama status" +git commit -m "feat(health): HealthChecker + OllamaBanner v0.2 copy" ``` --- -## Task 25: Tray + lifecycle wiring +## Task 30: Tray + main wiring with NotificationService + IntentService -**Files:** -- Create: `src/main/tray.ts` -- Modify: `src/main/index.ts` +**Files:** Create `src/main/tray.ts`. Replace `src/main/index.ts`. -- [ ] **Step 1: Implement tray** - -Write `src/main/tray.ts`: +- [ ] **Step 1: Tray with v0.2 copy** ```ts import { app, Tray, Menu, nativeImage } from 'electron'; @@ -3460,21 +3611,14 @@ let tray: Tray | null = null; const __dirname = fileURLToPath(new URL('.', import.meta.url)); export function createTray(showInbox: () => void, showCapture: () => void): Tray { - // 16x16 transparent placeholder; replace with real icon in later task. const icon = nativeImage.createEmpty(); tray = new Tray(icon); tray.setToolTip('Inkling'); const menu = Menu.buildFromTemplate([ - { label: 'Inbox 열기', click: showInbox }, - { label: 'Quick Capture', click: showCapture }, + { label: '구출한 메모 보기', click: showInbox }, + { label: '기억 구출하기', click: showCapture }, { type: 'separator' }, - { - label: 'Quit', - click: () => { - app.isQuitting = true; - app.quit(); - } - } + { label: '종료', click: () => { app.isQuitting = true; app.quit(); } } ]); tray.setContextMenu(menu); tray.on('click', showInbox); @@ -3482,29 +3626,32 @@ export function createTray(showInbox: () => void, showCapture: () => void): Tray } ``` -- [ ] **Step 2: Wire services and services into main** - -Replace `src/main/index.ts`: +- [ ] **Step 2: `src/main/index.ts` final wiring** ```ts -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, Notification } from 'electron'; import '@shared/types'; import { initLogger, logger } from './logger.js'; import { resolveProfilePaths } from './paths.js'; import { openDb } from './db/index.js'; import { NoteRepository } from './repository/NoteRepository.js'; import { MediaStore } from './services/MediaStore.js'; -import { StreakService } from './services/StreakService.js'; +import { ContinuityService } from './services/ContinuityService.js'; import { CaptureService } from './services/CaptureService.js'; +import { NotificationService } from './services/NotificationService.js'; 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 { AiWorker } from './ai/AiWorker.js'; import { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js'; import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; -import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow } from './windows/quickCaptureWindow.js'; +import { + createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow +} from './windows/quickCaptureWindow.js'; import { createTray } from './tray.js'; +import { MediaGc } from './services/MediaGc.js'; app.whenReady().then(async () => { initLogger(); @@ -3514,7 +3661,8 @@ app.whenReady().then(async () => { const db = openDb(paths.dbFile); const repo = new NoteRepository(db); const store = new MediaStore(paths.profileDir); - const streak = new StreakService(db); + const continuity = new ContinuityService(db); + const intent = new IntentService(repo); const provider = new LocalOllamaProvider(); const health = new HealthChecker(provider); @@ -3525,16 +3673,21 @@ app.whenReady().then(async () => { logger }); + const notify = new NotificationService({ + isSupported: () => Notification.isSupported(), + send: (body) => { + new Notification({ title: 'Inkling', body, silent: false }).show(); + } + }); + const capture = new CaptureService(repo, store, { - enqueue: (id) => worker.enqueue(id) + enqueue: (id) => worker.enqueue(id), + celebrate: (id) => notify.celebrate(id) }); registerCaptureApi(capture, getQuickCaptureWindow); registerInboxApi({ - repo, - streak, - capture, - health, + repo, continuity, capture, health, intent, getInboxWindow }); @@ -3554,51 +3707,35 @@ app.whenReady().then(async () => { await worker.loadFromDb(); + const gc = new MediaGc(db, store); + void gc.run().then((r) => logger.info('media.gc', r)); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); }); }); -app.on('before-quit', () => { - app.isQuitting = true; -}); - -app.on('will-quit', () => { - // Unregister global shortcuts automatically via Electron; no-op here. -}); +app.on('before-quit', () => { app.isQuitting = true; }); ``` -- [ ] **Step 3: Verify app starts end-to-end** +- [ ] **Step 3: Verify dev** -Run: `npm run dev` - -Expected: -1. Log line `app.start` in the log file. -2. Inbox window shows empty state. -3. Trigger hotkey → QuickCapture opens, type "테스트", Ctrl+Enter. -4. Inbox gains a new card in `pending` state. -5. If Ollama is running with `gemma4:9b`, card flips to `done` within ~30s with Korean title/summary. - -Stop with `Ctrl+C`. +`npm run dev` — submit a note, expect OS notification fires (or silent fallback if denied), Inbox shows pending → done, IntentBanner appears once on done card. - [ ] **Step 4: Commit** ```bash git add src/main/tray.ts src/main/index.ts -git commit -m "feat(app): wire services, IPC, hotkeys, and tray in main entrypoint" +git commit -m "feat(app): wire NotificationService, IntentService, ContinuityService into main" ``` --- -## Task 26: MediaGc for orphan cleanup +## Task 31: MediaGc -**Files:** -- Create: `src/main/services/MediaGc.ts` -- Modify: `src/main/index.ts` +**Files:** Create `src/main/services/MediaGc.ts` -- [ ] **Step 1: Implement MediaGc** - -Write `src/main/services/MediaGc.ts`: +- [ ] **Step 1: Implement** ```ts import type Database from 'better-sqlite3'; @@ -3613,53 +3750,30 @@ export class MediaGc { const known = new Set(rows.map((r) => r.id)); let removed = 0; for (const d of dirs) { - if (!known.has(d)) { - await this.store.deleteNoteDirectory(d); - removed += 1; - } + if (!known.has(d)) { await this.store.deleteNoteDirectory(d); removed += 1; } } return { removed }; } } ``` -- [ ] **Step 2: Wire GC on startup** - -Modify `src/main/index.ts`: - -Add to the import block: -```ts -import { MediaGc } from './services/MediaGc.js'; -``` - -After `await worker.loadFromDb();`, add: -```ts - const gc = new MediaGc(db, store); - void gc.run().then((r) => logger.info('media.gc', r)); -``` - -- [ ] **Step 3: Commit** +- [ ] **Step 2: Commit** ```bash -git add src/main/services/MediaGc.ts src/main/index.ts -git commit -m "feat(media): run orphan directory GC on app startup" +git add src/main/services/MediaGc.ts +git commit -m "feat(media): MediaGc for orphan dir cleanup on startup" ``` --- -## Task 27: E2E smoke test +## Task 32: E2E smoke test -**Files:** -- Create: `playwright.config.ts` -- Create: `tests/e2e/smoke.spec.ts` +**Files:** Create `playwright.config.ts`, `tests/e2e/smoke.spec.ts` - [ ] **Step 1: Playwright config** -Write `playwright.config.ts`: - ```ts import { defineConfig } from '@playwright/test'; - export default defineConfig({ testDir: './tests/e2e', timeout: 60_000, @@ -3671,13 +3785,11 @@ export default defineConfig({ - [ ] **Step 2: Smoke test** -Write `tests/e2e/smoke.spec.ts`: - ```ts import { test, expect, _electron as electron } from '@playwright/test'; import { resolve } from 'node:path'; -test('inbox shell shows empty state on first run', async () => { +test('inbox shell shows v0.2 empty state', async () => { const app = await electron.launch({ args: [resolve('out/main/index.js')], env: { ...process.env, INKLING_DEBUG: '1' } @@ -3685,75 +3797,75 @@ test('inbox shell shows empty state on first run', async () => { const inbox = await app.firstWindow(); await inbox.waitForLoadState('domcontentloaded'); await expect(inbox.getByText('Inkling')).toBeVisible(); - await expect(inbox.getByText('Ctrl+Shift+J로 첫 메모를 남겨보세요.')).toBeVisible(); + await expect(inbox.getByText('첫 기억을 구출해보세요.')).toBeVisible(); await app.close(); }); ``` -- [ ] **Step 3: Build and run smoke test** +- [ ] **Step 3: Build + run** -Run: ```bash npm run build npx playwright install chromium npm run test:e2e ``` -Expected: 1 test passes. - - [ ] **Step 4: Commit** ```bash git add playwright.config.ts tests/e2e/smoke.spec.ts -git commit -m "test(e2e): add Playwright smoke test verifying Inbox empty state" +git commit -m "test(e2e): smoke test verifying v0.2 inbox empty state" ``` --- -## Task 28: Full verification pass +## Task 33: Full verification pass -- [ ] **Step 1: Run all unit tests** +- [ ] **Step 1: Run unit tests** -Run: `npm test` - -Expected: all unit tests pass. +`npm test` → all pass. - [ ] **Step 2: Typecheck** -Run: `npm run typecheck` +`npm run typecheck` → exit 0. -Expected: exit 0. +- [ ] **Step 3: Manual dogfood checklist (Strategy + base flows)** -- [ ] **Step 3: Manual dogfood checklist (Windows preferred, macOS smoke)** +Start Ollama with `gemma4:9b`. Then: -Start Ollama with `gemma4:9b` pulled. Then: - -- [ ] Press `Ctrl+Shift+J` from another app → QuickCapture opens within ~100ms. -- [ ] Type `회의 중 A프로젝트 API 타임아웃 재발. 재현 로그 확보 예정.` → Ctrl+Enter → window closes. -- [ ] Inbox shows new card as `pending` with raw text expanded. -- [ ] Within ≤30s (p95 goal), card flips to `done` with Korean title, 3-line Korean summary, kebab-case tags. -- [ ] Click title → edit → blur saves; click a tag → it disappears. +- [ ] Press `Ctrl+Shift+J` from another app → QuickCapture opens within ~100ms with placeholder "지금 머릿속에 있는 것 한 줄. 정리는 나중입니다." +- [ ] Type "회의 중 A프로젝트 API 타임아웃 재발. 재현 로그 확보 예정." → Ctrl+Enter → window closes. +- [ ] OS native notification appears within 1s with one of the 4 reward copies (e.g. "이 생각은 이제 Inkling이 들고 있습니다."). +- [ ] Inbox shows new card with "Inkling이 정리하는 중…" and raw text expanded. +- [ ] Within ~30s the card flips to `done` with Korean title (with "AI" gray badge), 3-line Korean summary (with "AI" gray badge), kebab-case tags (each with "AI" subscript). +- [ ] An IntentBanner appears at the top of the card with one of the 4 rotating prompts. +- [ ] Type "다시 만나면 trace 먼저 확인" + Enter → banner disappears, "💡 다시 만나면 trace 먼저 확인" badge appears on card permanently. +- [ ] On a separate note: click "건너뛰기" instead → banner disappears and never returns. +- [ ] Click the title → edit → blur → "AI" badge on title disappears (titleEditedByUser=1). +- [ ] Click a tag → it disappears. - [ ] Paste a screenshot into QuickCapture and submit → thumbnail shown on card. -- [ ] Stop Ollama → new submissions accumulate as `pending`; OllamaBanner appears on Inbox. +- [ ] Stop Ollama → new submissions accumulate as `pending`; OllamaBanner appears with "Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요." - [ ] Restart Ollama and app → pending jobs resume and complete. -- [ ] Streak badge shows "🔥 연속 1일" after first day. -- [ ] Close Inbox window → app stays alive in tray; tray "Quit" exits. +- [ ] ContinuityBadge shows "이번 주 한 줄이면 시작입니다" before any note today; flips to "이번 주 1/7" then "이번 주 2/7" etc; at 7 shows "이번 주 7/7 ✓ · 연속 K주 완성". +- [ ] Simulate 8+ day gap (set system clock or insert old data manually): submit one note → "🌱 흐름을 다시 이어갑니다" banner appears for that day. +- [ ] Close Inbox window → app stays alive in tray; tray "종료" exits. -- [ ] **Step 4: Commit verification notes** - -If any change was needed to fix issues, commit them individually; otherwise, tag the release: +- [ ] **Step 4: If all green, tag** ```bash -git tag v0.1.0-slice +git tag v0.2.0-slice ``` --- ## Notes for the Engineer -- **TDD discipline:** Every task that creates business logic follows Red → Green → Commit. Do not skip running the failing test — you must see the failure message once, so you trust the assertion after it passes. -- **Commits are cheap and frequent:** one per completed task. Do not squash. -- **Logs and PII:** never add `raw_text`, `ai_title`, or `ai_summary` content to log lines; only IDs, lengths, and hash prefixes. -- **Original preservation:** `NoteRepository` has no method to change `raw_text`. If you feel you need one, revisit the spec — this is a load-bearing invariant. -- **Ollama not running:** local dev continues to work, notes stay `pending`; the app must not crash or block capture. -- **Versions:** §7.2 of the spec lists pin candidates. If `npm install` surfaces a newer stable major at Task 1, update both `package.json` and the spec file in the same PR. +- **TDD discipline:** every business-logic task is Red → Green → Commit. Run failing tests once before implementing — you must see the assertion fail to trust it. +- **Frequent commits:** one per task minimum. Do not squash. +- **Logs and PII:** never log `raw_text`, `ai_title`, `ai_summary`, or `user_intent` content. Only IDs, lengths, and hash prefixes. +- **Original preservation:** `NoteRepository` has no method to mutate `raw_text`. AI re-runs do not overwrite user-edited title/summary (guarded by `*_edited_by_user` columns). +- **Intent flow:** the IntentBanner appears exactly once per note (gate: `intent_prompted_at IS NULL`). Both setIntent and dismissIntent close the gate. +- **Continuity copy:** the words "실패", "끊김", "연속 실패" are forbidden in any UI string in this slice. If a future contributor adds them, fail review. +- **Native notifications:** if the OS denies permission, swallow silently — capture must succeed regardless. Do not block submit on notification result. +- **Recovery toast:** dismissal persists in renderer `localStorage` keyed to KST date. Re-shown on a new recovery event (different date). +- **Versions:** if `npm install` surfaces a newer stable major than spec §7.2, update both `package.json` and the spec in the same PR.