diff --git a/docs/superpowers/plans/2026-05-09-v028-cut-a.md b/docs/superpowers/plans/2026-05-09-v028-cut-a.md new file mode 100644 index 0000000..26f9b87 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-v028-cut-a.md @@ -0,0 +1,705 @@ +# v0.2.8 Cut A Implementation Plan — 이미지 렌더링 + 앱 아이콘 + +> **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:** F22 (NoteCard 의 회색 placeholder → 실제 `` + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합). + +**Architecture:** Electron renderer 보안 정책 우회를 위해 main process 에 `inkling-media://` custom protocol 등록 — `/media//` 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 `` 로 사용. 클릭 시 IPC `inbox:open-media` → `shell.openPath` 로 OS default viewer 열기. 앱 아이콘은 `assets/icon.svg` (이미 작성) 를 `electron-icon-builder` 로 한 번 빌드 → `build/icon.ico/icns/png` 산출물 git 추적 + electron-builder config 매핑. + +**Tech Stack:** Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback) + +**선행 spec:** [docs/superpowers/specs/2026-05-09-v028-cut-a-design.md](docs/superpowers/specs/2026-05-09-v028-cut-a-design.md) + +--- + +## File Structure + +### 신규 파일 + +| 경로 | 책임 | +|---|---| +| `src/main/protocol/inklingMedia.ts` | `inkling-media://` scheme 권한 + handler 등록 (path traversal 검사 + inferMime) | +| `tests/unit/inklingMedia.test.ts` | protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) | +| `assets/icon.svg` | (이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) | + +### 수정 파일 + +| 경로 | 변경 | +|---|---| +| `src/main/index.ts` | top-level `protocol.registerSchemesAsPrivileged` + whenReady 안 `registerInklingMediaProtocol(paths.profileDir)` 호출 + `registerInboxApi` 가 신규 IPC 채널 등록 | +| `src/main/ipc/inboxApi.ts` | 신규 IPC `inbox:open-media` 핸들러 (path traversal 검사 + `shell.openPath`) | +| `src/preload/index.ts` | `inbox:open-media` 채널 화이트리스트 | +| `src/shared/types.ts` | `InboxApi` 에 `openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }>` 시그니처 추가 | +| `src/renderer/inbox/api.ts` | inboxApi 객체에 `openMedia` wrapper | +| `src/renderer/inbox/components/NoteCard.tsx` | 회색 `
` placeholder → `` + onClick | +| `tests/unit/NoteCard.test.tsx` | 신규 또는 추가 — `` 렌더 + 클릭 시 IPC 호출 | +| `package.json` | devDep `electron-icon-builder` + script `build:icons` + `build.win/mac/linux.icon` 경로 | +| `.gitignore` | `build/` 안 `icon.ico/icns/png` 만 추적 (whitelist) | +| `build/icon.ico` / `build/icon.icns` / `build/icon.png` | 빌드 산출물 commit | +| `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` | F22 진행 상태 🚀 promoted 마킹 | +| `docs/superpowers/v024-backlog.md` | (해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) | + +--- + +## Phase 개요 + +``` +Phase 1: F22 inkling-media protocol (Task 1) +Phase 2: F22 NoteCard + 클릭 (Task 2) +Phase 3: F22 IPC inbox:open-media (Task 3) +Phase 4: chore 앱 아이콘 빌드 + config (Task 4) +Phase 5: verification + version bump (Task 5) +``` + +--- + +## Phase 1: inkling-media protocol + +### Task 1: protocol 등록 + handler + 단위 테스트 + +**Files:** + +- Create: `src/main/protocol/inklingMedia.ts` +- Create: `tests/unit/inklingMedia.test.ts` +- Modify: `src/main/index.ts` + +- [ ] **Step 1: failing test 작성** + +```ts +// tests/unit/inklingMedia.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { join, sep } from 'node:path'; + +const mockReadFile = vi.fn(); +vi.mock('node:fs/promises', () => ({ readFile: mockReadFile })); + +const mockHandle = vi.fn(); +vi.mock('electron', () => ({ + default: { + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: mockHandle + } + } +})); + +import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia'; + +describe('inferMime', () => { + it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); }); + it('returns image/jpeg for .jpg and .jpeg', () => { + expect(inferMime('foo.jpg')).toBe('image/jpeg'); + expect(inferMime('foo.jpeg')).toBe('image/jpeg'); + }); + it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); }); + it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); }); + it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); }); +}); + +describe('inkling-media protocol handler', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + function getHandler(profileDir: string): (req: Request) => Promise { + registerInklingMediaProtocol(profileDir); + return mockHandle.mock.calls[0][1]; + } + + it('serves valid file with correct mime', async () => { + mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3])); + const handler = getHandler('/profile'); + const res = await handler(new Request('inkling-media://media/note1/img.png')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png')); + }); + + it('returns 403 on path traversal attempt', async () => { + const handler = getHandler('/profile'); + const res = await handler(new Request('inkling-media://media/../etc/passwd')); + expect(res.status).toBe(403); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('returns 404 when file missing', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + const handler = getHandler('/profile'); + const res = await handler(new Request('inkling-media://media/note1/missing.png')); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npm run rebuild:node +npx vitest run tests/unit/inklingMedia.test.ts +``` + +Expected: `Cannot find module '../../src/main/protocol/inklingMedia'`. + +- [ ] **Step 3: protocol handler 작성** + +```ts +// src/main/protocol/inklingMedia.ts +import electron from 'electron'; +import { promises as fs } from 'node:fs'; +import { join, normalize, sep, extname } from 'node:path'; + +const { protocol } = electron; + +export function registerSchemesAsPrivileged(): void { + protocol.registerSchemesAsPrivileged([ + { scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } } + ]); +} + +export function inferMime(filename: string): string { + const ext = extname(filename).toLowerCase(); + switch (ext) { + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.gif': return 'image/gif'; + case '.webp': return 'image/webp'; + default: return 'application/octet-stream'; + } +} + +export function registerInklingMediaProtocol(profileDir: string): void { + const mediaRoot = join(profileDir, 'media'); + protocol.handle('inkling-media', async (req) => { + const url = new URL(req.url); + // host + pathname 합쳐서 relPath 구성. inkling-media://media// 형식. + // URL parse 시 host = 'media', pathname = '//' + const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/')); + const target = normalize(join(profileDir, relPath)); + if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) { + return new Response(null, { status: 403 }); + } + try { + const data = await fs.readFile(target); + return new Response(new Uint8Array(data), { + headers: { 'content-type': inferMime(target) } + }); + } catch { + return new Response(null, { status: 404 }); + } + }); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/inklingMedia.test.ts +``` + +Expected: 모든 test pass. + +- [ ] **Step 5: src/main/index.ts 통합** + +`src/main/index.ts` 의 import 추가: + +```ts +import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js'; +``` + +top-level (whenReady 이전, app 생성 직후) 에서: + +```ts +registerSchemesAsPrivileged(); +``` + +`whenReady` 안에서 (paths 초기화 후): + +```ts +registerInklingMediaProtocol(paths.profileDir); +``` + +- [ ] **Step 6: typecheck + 전체 회귀** + +```bash +npm run typecheck +npx vitest run +``` + +Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3). + +- [ ] **Step 7: commit** + +```bash +git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts +git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사" +``` + +--- + +## Phase 2: NoteCard `` + 클릭 + +### Task 2: NoteCard placeholder → `` 교체 + +**Files:** + +- Modify: `src/renderer/inbox/components/NoteCard.tsx` +- Create: `tests/unit/NoteCard.test.tsx` (없으면 신규) + +- [ ] **Step 1: failing test 작성** + +```tsx +// tests/unit/NoteCard.test.tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import type { Note } from '@shared/types'; + +const mockOpenMedia = vi.fn(async () => ({ ok: true })); +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + openMedia: mockOpenMedia, + // 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음) + permanentDeleteNote: vi.fn(), + restoreNote: vi.fn() + } +})); + +import { NoteCard } from '../../src/renderer/inbox/components/NoteCard'; + +const baseNote: Note = { + id: 'n1', + rawText: 'test', + title: 'T', + summary: '', + tags: [], + aiStatus: 'complete', + createdAt: '2026-05-09T00:00:00Z', + updatedAt: '2026-05-09T00:00:00Z', + deletedAt: null, + media: [ + { id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' }, + { id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' } + ] +} as unknown as Note; + +describe('NoteCard — image rendering', () => { + beforeEach(() => { vi.clearAllMocks(); cleanup(); }); + + it('renders for each media item', () => { + render(); + const imgs = screen.getAllByRole('img'); + expect(imgs).toHaveLength(2); + expect(imgs[0].getAttribute('src')).toBe('inkling-media://media/n1/img1.png'); + expect(imgs[1].getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg'); + }); + + it('clicking calls inboxApi.openMedia', () => { + render(); + fireEvent.click(screen.getAllByRole('img')[0]); + expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png'); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run tests/unit/NoteCard.test.tsx +``` + +Expected: `getAllByRole('img')` 미존재 (현재 회색 div 만). + +- [ ] **Step 3: NoteCard 갱신** + +[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340) 의 회색 placeholder div 부분을 다음으로 교체: + +```tsx +{local.media.length > 0 && ( +
+ {local.media.map((m) => ( + { void inboxApi.openMedia(m.relPath); }} + style={{ + width: 48, + height: 48, + objectFit: 'cover', + borderRadius: 4, + cursor: 'pointer', + border: '1px solid #e0e0e0' + }} + /> + ))} +
+)} +``` + +`inboxApi` import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가: + +```tsx +import { inboxApi } from '../api.js'; +``` + +- [ ] **Step 4: 테스트 통과 + typecheck** + +```bash +npx vitest run tests/unit/NoteCard.test.tsx +npm run typecheck +``` + +Expected: 2 test pass (`openMedia` 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작. + +만약 typecheck error 발생: `inboxApi.openMedia` 가 InboxApi 인터페이스에 없음 → 일시 `(inboxApi as any).openMedia(m.relPath)` 로 cast. **Task 3 에서 정식 시그니처 추가 후 cast 제거.** + +- [ ] **Step 5: commit (with TODO note for Task 3)** + +```bash +git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx +git commit -m "feat(v028): NoteCard 이미지 렌더링 + onClick (openMedia 시그니처는 Task 3)" +``` + +--- + +## Phase 3: IPC `inbox:open-media` + +### Task 3: main IPC 핸들러 + api.ts wrapper + types + +**Files:** + +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/renderer/inbox/components/NoteCard.tsx` (Task 2 cast 제거) + +- [ ] **Step 1: failing test 작성 (IPC handler 단위)** + +```ts +// tests/unit/inboxApi-openMedia.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { join, sep } from 'node:path'; + +const handlers: Record = {}; +const mockOpenPath = vi.fn(async () => ''); + +vi.mock('electron', () => ({ + default: { + ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } }, + shell: { openPath: mockOpenPath } + } +})); + +import { registerInboxApi } from '../../src/main/ipc/inboxApi'; + +describe('inbox:open-media IPC', () => { + beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; }); + + it('opens valid relPath with shell.openPath', async () => { + // 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용 + registerInboxApi({ paths: { profileDir: '/profile' } } as any); + const r = await handlers['inbox:open-media'](null, 'media/note1/img.png'); + expect(r).toEqual({ ok: true }); + expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png')); + }); + + it('rejects path traversal', async () => { + registerInboxApi({ paths: { profileDir: '/profile' } } as any); + const r = await handlers['inbox:open-media'](null, '../etc/passwd'); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid path'); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run tests/unit/inboxApi-openMedia.test.ts +``` + +Expected: handler 미등록. + +- [ ] **Step 3: src/main/ipc/inboxApi.ts 핸들러 추가** + +기존 `registerInboxApi(deps)` 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴): + +```ts +import { join, normalize, sep } from 'node:path'; +import electron from 'electron'; +const { ipcMain, shell } = electron; + +// registerInboxApi 안: +ipcMain.handle('inbox:open-media', async (_e, relPath: string) => { + const mediaRoot = join(deps.paths.profileDir, 'media'); + const target = normalize(join(deps.paths.profileDir, relPath)); + if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) { + return { ok: false, reason: 'invalid path' }; + } + await shell.openPath(target); + return { ok: true }; +}); +``` + +(기존 deps 타입에 `paths.profileDir` 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.) + +- [ ] **Step 4: src/shared/types.ts InboxApi 갱신** + +```ts +// 기존 InboxApi 인터페이스 안: +openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>; +``` + +- [ ] **Step 5: preload + api.ts wrapper** + +`src/preload/index.ts` 의 invoke whitelist 또는 API expose 객체 안에 `'inbox:open-media'` 추가 (기존 `'inbox:*'` 채널 패턴 따름). + +`src/renderer/inbox/api.ts` 의 inboxApi 객체에 추가: + +```ts +async openMedia(relPath: string) { + return await window.inkling.invoke('inbox:open-media', relPath); +} +``` + +(또는 기존 wildcard re-export 사용 시 자동 노출 — `feb7c62` commit 의 `onNavigate` 처럼.) + +- [ ] **Step 6: NoteCard.tsx cast 제거** + +Task 2 에서 임시 `(inboxApi as any).openMedia` cast 했다면 → `inboxApi.openMedia` 정상 사용으로 변경. typecheck 통과 확인. + +- [ ] **Step 7: 테스트 + typecheck + 회귀** + +```bash +npx vitest run tests/unit/inboxApi-openMedia.test.ts +npm run typecheck +npx vitest run +``` + +Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck. + +- [ ] **Step 8: commit** + +```bash +git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts +git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리" +``` + +--- + +## Phase 4: 앱 아이콘 빌드 + config + +### Task 4: electron-icon-builder + 산출물 + builder config + +**Files:** + +- Modify: `package.json` +- Modify: `.gitignore` +- Create: `build/icon.ico`, `build/icon.icns`, `build/icon.png` (빌드 산출물) +- (조건부) Create: `scripts/svg-to-png.mjs` (SVG → PNG fallback if needed) + +- [ ] **Step 1: devDep 설치** + +```bash +npm install --save-dev electron-icon-builder +``` + +확인: `package.json` 의 devDependencies 에 `"electron-icon-builder": "^2.x.x"` 추가됨. + +- [ ] **Step 2: package.json scripts + builder config** + +`package.json` 의 scripts 블록에 추가: + +```json +"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten" +``` + +(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.) + +`build` 블록 갱신 (기존 win/mac/linux 에 icon 키 추가): + +```json +"win": { "icon": "build/icon.ico", ... 기존 그대로 ... }, +"mac": { "icon": "build/icon.icns", ... 기존 그대로 ... }, +"linux": { "icon": "build/icon.png", ... 기존 그대로 ... } +``` + +- [ ] **Step 3: 빌드 실행** + +```bash +npm run build:icons +``` + +Expected: +- `build/icon.ico` (Win) +- `build/icon.icns` (macOS) +- `build/icon.png` (1024x1024, Linux) + +만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음): + +1. `scripts/svg-to-png.mjs` 작성: + +```js +import sharp from 'sharp'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const [,, input, output, size = '1024'] = process.argv; +const svg = readFileSync(input); +const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer(); +writeFileSync(output, png); +console.log(`OK: ${output} (${size}x${size})`); +``` + +2. `package.json` scripts 갱신: + +```json +"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024", +"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten" +``` + +3. `npm install --save-dev sharp` (이미 있으면 skip). + +4. 다시 `npm run build:icons` 실행. + +- [ ] **Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)** + +`.gitignore` 의 `build/` 또는 `dist/` 항목 확인. 만약 `build/` 가 ignore 되어 있다면: + +``` +build/ +!build/icon.ico +!build/icon.icns +!build/icon.png +``` + +(만약 `build/` 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.) + +- [ ] **Step 5: 산출물 확인 + commit** + +```bash +ls -la build/icon.ico build/icon.icns build/icon.png +``` + +Expected: 3 파일 모두 size 양수 (수십 KB 이상). + +```bash +git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png +git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config" +``` + +(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.) + +- [ ] **Step 6: typecheck + 전체 회귀** + +```bash +npm run typecheck +npx vitest run +``` + +Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X. + +--- + +## Phase 5: verification + version bump + +### Task 5: 회귀 + dogfood-feedback 마킹 + version bump + +**Files:** + +- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22 promoted 마킹) +- Modify: `package.json` (version 0.2.7 → 0.2.8) + +- [ ] **Step 1: 단위 + e2e + typecheck 일괄** + +```bash +npm run rebuild:node +npm test +npm run typecheck +npm run rebuild:electron +npm run test:e2e +``` + +Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1. + +- [ ] **Step 2: 수동 launch 검증 (Win + macOS 가능 시)** + +```bash +npm run start +``` + +체크리스트: + +- inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X) +- thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림 +- 새 아이콘이 트레이 / Windows taskbar / dock 정확 표시 +- 다중 이미지 노트의 grid layout (flex-wrap) 자연스러움 + +- [ ] **Step 3: dogfood-feedback.md F22 promoted 마킹** + +`docs/superpowers/specs/2026-04-25-dogfood-feedback.md` 의 F22 entry 헤더 갱신: + +```markdown +## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md) +``` + +진행 상태 line 도 `🚀 promoted` 로 갱신. + +- [ ] **Step 4: package.json version bump** + +```json +"version": "0.2.8" +``` + +- [ ] **Step 5: commit** + +```bash +git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json +git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)" +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec 섹션 | task | +|---|---| +| §3-1 protocol 등록 | Task 1 | +| §3-2 NoteCard `` | Task 2 | +| §3-3 IPC inbox:open-media | Task 3 | +| §3-4 보안 (path traversal) | Task 1 + Task 3 | +| §4-1 devDep + scripts | Task 4 | +| §4-2 builder config | Task 4 | +| §4-3 산출물 git 추적 | Task 4 | +| §4-4 SVG → PNG fallback | Task 4 (조건부) | +| §5 테스트 | 각 task 안 단위 + Task 5 회귀 | +| §6 Risk | Task 4 fallback 분기 | + +모든 spec 요구가 task 매핑됨. + +**Placeholder scan**: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태. + +**Type consistency**: + +- `InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>` — Task 3 정의, Task 2 cast → Task 3 cast 제거 일관. +- `inferMime(filename: string): string` — Task 1 정의, Task 1 안 사용. +- `registerInklingMediaProtocol(profileDir: string): void` / `registerSchemesAsPrivileged(): void` — Task 1 export, src/main/index.ts import 일관. + +이슈 없음. + +--- + +## Execution Handoff + +Plan 작성 완료, `docs/superpowers/plans/2026-05-09-v028-cut-a.md` 저장. + +두 가지 실행 옵션: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration + +**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review + +어느 쪽? diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index a991f2d..ee93a44 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1305,6 +1305,341 @@ app.on('activate', () => { - **트레이 코드 단순화**: 13 항목 → 3~4 항목. v0.2.6 의 TrayCallbacks 객체화 (C2) 효과 가시화. - Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상. +--- +## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🌱 raw — v0.2.8 후보, 큰 design 결정) + +**진행 상태:** 🌱 raw — 본인 dogfood 발견. F18 (사유 입력) 와 강하게 연관. v0.2.8 brainstorm 시 함께 triage. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. + +### 관찰 + +- 현재 메모 destination = active(`deleted_at IS NULL`) 또는 휴지통(`deleted_at != NULL`) 단일 분기. +- 사용자 의도가 갈리는 case: + - (a) **불필요해서 버림** — 잘못 적은 캡처, 더 이상 의미 없음. 휴지통 자연스러움. + - (b) **완료해서 더 이상 안 띄움** — 작업 끝났지만 기록은 보존하고 싶음. 휴지통은 의미 부적합 (회수 대상이 아니라 정리 대상). + - (c) **급하지 않아 미루기** — 지금은 안 띄우지만 나중에 다시 보고 싶음. 휴지통도 active 도 어색. +- 모두 휴지통으로 보내면 의미 혼재 + 30일 후 영구 삭제 정책 (현재) 이 (b)/(c) 의도 깨뜨림. + +### 제안 방향 (옵션 분석) + +**A. status 컬럼 분기** (notes 테이블에 `status`: `'active' | 'completed' | 'archived' | 'trashed'`): +- `completed`: 작업 끝, 영구 보관 (회수 대상 아님, 검색 가능) +- `archived`: 장기 보관 (회수 가능, 별도 view) +- `trashed`: 30일 후 영구 삭제 (현재 휴지통) +- inbox 탭 + 휴지통 탭 외에 "완료" / "보관함" 탭 추가 +- 마이그레이션 비용 + UI 변경 (NoteCard 액션 메뉴 + 헤더 탭) + +**B. AI 자동 분류** (옵션 A 의 extension): +- "완료" 키워드 / 패턴 (예: "X 끝남", "처리됨", "결재됨") AI 가 감지 → 자동 `status='completed'` 제안 +- 사용자가 confirm/dismiss +- F19 의 recall 강화와 결합 시 효율 ↑ + +**C. 휴지통 의미 유지 + 보관함만 별도** (간단): +- 휴지통은 그대로 (불필요 — 30일 영구 삭제) +- 보관함만 신규 — `status='archived'` (회수 가능, 영구 보존) +- 완료 / 미루기 모두 보관함 사용 +- 변경 작음, 의미 명확 + +### 결정 대기 (v0.2.8 brainstorm) + +- 보관함 detail UI: 별도 탭 vs 별도 라우트 vs filter 토글 +- "완료" 와 "보관" 을 같은 destination 으로 둘지 (옵션 C) vs 분리 (옵션 A) +- AI 자동 분류 (옵션 B) — 정확도 + false-positive risk 측정 필요. v0.2.8 본 cut 보다 나중 가능. + +### 가설·측정 + +- 본인 dogfood: 2주간 휴지통 이동한 메모 중 (a)/(b)/(c) 비율 — telemetry 또는 자기 회상 으로 측정. (b)/(c) 비율 ≥30% 면 의미 분기 가치 큼. +- 옵션 C 후 보관함 사용 빈도 — active 메모 대비 비율. + +### 범위 + +- 옵션 A: 큰 작업 (DB 마이그레이션 + UI 탭 + IPC + telemetry kind 추가). 별도 spec 가치. +- 옵션 C: 1주 spike 가능 (status 컬럼 또는 별도 `archived_at` 추가 + UI 탭). +- 옵션 B: 추가 cut — 옵션 A/C 안정 후. + +### 영향 + +- 메모 의미가 명확해짐 — "버림" vs "정리" 구분. +- 휴지통의 30일 영구 삭제 정책 유지 가능 (의미 혼재 X). +- F18 (사유 입력) 와 결합 시 사용자 의도 데이터 누적 → recall 알고리즘 (F19) 기여. + +--- + +## F18. 메모 휴지통/보관 이동 시 사유 입력 (🌱 raw — v0.2.8 후보, F17 와 묶음) + +**진행 상태:** 🌱 raw — F17 과 강한 연관. v0.2.8 brainstorm 시 함께 triage. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. + +### 관찰 + +- 휴지통 이동 = silent (단순 `deleted_at` 셋팅). +- 사용자 의도 다양 ("완료", "급하지 않음", "더 이상 필요 없음", "잘못 적음" 등) — 데이터로 보존 안 됨. +- 사유 데이터가 있으면: + - 자기 회상 (왜 버렸는지 다시 보기) + - recall 알고리즘 (F19) 학습 입력 — "급하지 않음" 메모는 N일 후 재surface + - telemetry 분석 (어떤 종류 메모가 빠르게 trash 되는가) + +### 제안 방향 + +**A. 자유 텍스트 reason 필드** (notes.deleted_reason VARCHAR 또는 별도 trash_log 테이블): +- 휴지통 이동 시 prompt: "왜 버려? (선택사항)" 한 줄 입력 +- 빈 값 허용 +- 검색/필터 가능 + +**B. preset 사유 + 자유 텍스트 옵션**: +- 빠른 선택: "완료" / "급하지 않음" / "잘못 적음" / "기타" +- "기타" 선택 시 자유 텍스트 +- 통계 가능 (preset 분류) + +**C. F17 의 status 분기 + 사유 결합**: +- status='completed' 이면 사유 = "완료" 자동 inferred +- status='archived' 이면 사유 입력 prompt +- status='trashed' 이면 사유 선택사항 + +### 결정 대기 + +- 사유 입력 friction vs 데이터 가치 — 매번 묻으면 capture 흐름 깨짐 risk +- preset 만 vs 자유 텍스트 — preset 만이 friction 최소 +- 위치: NoteCard 인라인 dropdown vs 별도 modal + +### 가설·측정 + +- 본인 dogfood 1주 — 사유 입력 비율 (skip 없이 입력) ≥50% 면 데이터 가치 충분. +- 사유 distribution — preset N개 / "기타" 비율 — preset 명세 검증. + +### 범위 + +- A: 1일 (DB column + UI prompt + IPC). 가장 작음. +- B: 1.5일 (+ preset UI). +- C: F17 의 옵션 A/C 안에 포함 가능 (한 cut). + +### 영향 + +- 사용자 의도 데이터 누적 → F19 recall 알고리즘 입력 + 자기 회상 surface. +- F17 의 status 분기 가치 ↑ (사유 + status 의 의미 결합). +- friction 우려 — preset 또는 skip 가능 으로 완화. + +--- + +## F19. 획기적 recall 메커니즘 (🌱 raw — v0.2.8+ 큰 영역, 본질 재설계 가능) + +**진행 상태:** 🌱 raw — 핵심 가치 영역. v0.2.8 brainstorm 시 별도 spec 후보 (recall 만 단독 cut 가치). + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. "메모의 빠른 기록도 중요하지만 적절한 recall 도 훨씬 중요" — 본인 표현. + +### 관찰 + +- 현재 recall surface: + - **RecallBanner** (v0.2.3 #6) — 1일 1회, 14~21일 전 candidate 1건. 한 번에 1건 + dismiss 후 다음 날까지 안 뜸. + - **RecoveryToast** — 주간 회복 ping (1주 capture 끊김 시 한정). + - **search/filter** — tag 필터링만, free text search 없음. +- 빠른 기록 (capture) 는 잘 됨 — 한 줄 적기 + 트레이 hotkey 가 ≤1초 완성. +- recall 은 약함: + - 적극적 recall 안 됨 (메모를 다시 봐야 의미 있는데 surface 가 너무 약함) + - search 부재 — "뭐였더라" 회상 시 인덱스 없음 + - context-based recall 없음 (시간/태그/장소 기반) + - AI 가 capture 단계에서만 활용, recall 단계 X +- 사용자 표현 "획기적 방법" — 현 RecallBanner 의 점진 개선이 아니라 질적 변화 요구. + +### 제안 방향 (브레인스토밍 후보 — v0.2.8 spec 단계에서 압축) + +**A. Free text search**: +- inbox 헤더 search box → raw_text + summary + tags 인덱스 검색 +- SQLite FTS5 (Full-Text Search) 활용 +- 가장 작은 단위 첫걸음. 다른 옵션의 prerequisite. + +**B. Context-based recall**: +- 시간: "이 시간대 (오전/오후)" 메모 추천 +- 요일: "이전 월요일" 메모 +- 태그 클러스터링 — 현재 active tag 와 연관 메모 surface +- 사용자가 inbox 보고 있을 때 sidebar 또는 banner 로 노출 + +**C. AI-driven 연관 메모 추천**: +- 현재 보고 있는 메모와 의미 유사한 옛 메모 surface (embedding-based or LLM-judged) +- "이거랑 비슷한 옛날 메모 N건" UI +- Ollama embedding API 활용 가능 (모델 추가 부담) + +**D. 회고 view (일/주/월)**: +- "지난 주 기록" 한 페이지 — N건 메모 + tag distribution + due date 진행 +- 정해진 시점 (월요일 아침 등) banner 또는 별도 라우트 + +**E. Spaced repetition recall**: +- Anki 같은 SM-2 알고리즘 — "잊을 만한 시점" 에 surface +- 사용자가 confirm/dismiss → 다음 surface 시점 학습 +- 현 RecallBanner 의 발전형 + +**F. 검색 + AI 자연어 query**: +- "지난 달 회의 메모 보여줘" 자연어 → SQL or filter 자동 변환 +- Ollama function-calling 또는 prompt template +- C/D 의 통합 진입점. + +### 결정 대기 (v0.2.8 brainstorm) + +- "획기적" 의 정의: 현 RecallBanner 점진 vs 새 핵심 surface +- 1차 cut scope — A (search) 만으로 충분한 가치인가, B/C 와 묶어야 가치인가 +- AI 의존 (C/F) 의 latency / 정확도 trade-off — Ollama 추론 비용 +- F17/F18 의 status + 사유 데이터 가 recall 입력으로 활용 가능한가 + +### 가설·측정 + +- 본인 dogfood: 옛 메모 다시 본 횟수 / 캡처 수 비율 — 현재 < 5% 추정. ≥20% 까지 끌어올리는 것이 "획기적" 기준. +- search 사용 빈도 (A 만 도입 시) — 일 1회 미만이면 의미 약함. + +### 범위 + +- A: 3-4일 (FTS5 + UI search box). +- B: 1주 + (시간/태그/요일 로직 + UI). +- C: 2주 (embedding 인프라 추가 — Ollama embedding 모델 + 벡터 저장). +- D: 1주 (회고 라우트 + aggregate query). +- E: 2주 (SM-2 + UI + 사용자 feedback loop). +- F: C 위에 추가 1주. + +→ 한 cut 에 다 넣기 무리. v0.2.8 = A + D 또는 A + B 권장. C/E/F 는 v0.3+. + +### 영향 + +- 핵심 가치 (capture → 의미 있는 보존 → 다시 보기) 의 후반 절반 완성. +- F1 (Due Date), F4 (Aha Moment), F17 (status 분기) 모두 recall 강화로 가치 ↑. +- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 1주차 검증 항목 = recall 효과 측정. +- 큰 cut — separate spec 이 자연스러움. + +--- + +## F20. 기존 메모 본문 (raw_text) 수정 가능성 (🌱 raw — v0.2.8 후보, **load-bearing invariant 재검토**) + +**진행 상태:** 🌱 raw — 메모리 정책 `raw_text 불변` 재논의 트리거. v0.2.8 brainstorm 시 invariant 변경 여부 결정. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. + +### 관찰 + +- 현재 NoteCard 의 EditableField 는 AI 결과 필드 (title / summary / tags) 만 수정 가능. +- raw_text (capture 시점 원본 본문) 는 read-only — 메모리 정책상 **load-bearing invariant**. +- dogfood 시 빈도 높은 use case: + - **오타 정정** — 빠른 capture 중 잘못 입력 (예: "회으" → "회의") + - **의미 보강** — 나중에 보니 정보 부족 ("회의" → "월요일 분기 회의 안건") + - **잘못된 캡처 정정** — 음성/clipboard 자동 입력 시 오인식 + +### 제안 방향 (옵션 분석 — invariant trade-off) + +**A. raw_text 수정 허용** (invariant 폐기): +- 가장 단순 — EditableField 가 raw_text 도 수정 가능. +- 비용: capture 시점 원본 lost. AI 재실행 시 input 이 user-edited 본문 — 그 시점 의도와 다를 수 있음. +- 영향: 다른 spec (F1 Due Date, F4 Aha Moment 등) 의 raw_text 불변 가정 재검토 필요. + +**B. raw_text 불변 유지 + `user_edited_text` 필드 추가**: +- 원본 보존 + 사용자 정정 별도 컬럼. +- NoteCard 가 user_edited_text 우선 표시 (없으면 raw_text fallback). +- AI 재실행 시 어느 입력을 사용할지 결정 — 원본 (안정성) 또는 사용자 정정 (의도 정확성). +- 마이그레이션 + UI 분기 비용. + +**C. raw_text 수정 허용 + revision history**: +- `note_revisions` 테이블 — 변경 이력 보존. +- 사용자가 옛 버전 회수 가능. +- 비용 가장 큼 (스키마 + UI + 회수 흐름). + +**D. invariant 유지 (현 동작)** — 이 피드백 reject: +- 정책 사유: capture = "기록", 수정 시 의미 본질 변경. +- 그러나 dogfood 실용 마찰 클 가능성. + +### 결정 대기 (v0.2.8 brainstorm — 핵심 결정) + +- **invariant 재검토**: 본인 dogfood 1주 누적 시 raw_text 정정 욕구 빈도 측정. 주 ≥3건 면 옵션 A/B/C 검토 가치. +- AI 재실행 input: raw_text vs user_edited — 메모리 정책 `AI 재실행은 user-edited 필드 덮어쓰기 금지` 와 정합 검토. +- F1 (Due Date 파서), F4 (Aha Moment) 의 raw_text 가정 영향 분석. + +### 가설·측정 + +- 본인 dogfood 1주: 메모 정정 욕구 발생 횟수 / 캡처 수. ≥10% 면 옵션 A 또는 B 강한 motivation. +- 옵션 B 시 user_edited 사용 비율 — ≥30% 면 분기 가치. + +### 범위 + +- A: 1일 (EditableField 가 raw_text 도 처리 + IPC 수정). +- B: 3-4일 (스키마 마이그레이션 + UI 분기 + AI 재실행 정책 결정). +- C: 1주 + (revisions 테이블 + UI 회수 흐름). + +### 영향 + +- **load-bearing invariant 재검토** — 메모리 정책 갱신 가치. +- F1 / F4 / F17 / F19 모두 raw_text 가정 재검토 영향. +- 사용자 마찰 ↓ (오타/오인식 정정 가능) vs 기록 본질 약화 trade-off. +- 옵션 B 가 가장 균형 — 원본 보존 + 사용자 정정 모두 가능. + +--- + +## F21. 다기기 git-based 동기화 (🌱 raw — v0.2.8 후보, **부분 구현됨**) + +**진행 상태:** 🌱 raw — `SyncService` + `GitClient` 가 이미 push-only 형태로 존재. **양방향 동기화 + UI 구성** 이 누락된 핵심 부분. v0.2.8 brainstorm 시 명확한 cut. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "그 중심에 git repo 를 쓸 수 있으면 좋겠어". + +### 관찰 (현재 동작) + +[src/main/services/SyncService.ts](src/main/services/SyncService.ts) 의 `sync()`: + +1. SQLite → markdown export (ExportService) 를 `/sync/` 에 산출 +2. `git add -A && git commit -m "chore(notes): sync " && git push` +3. `not_configured` 시 skip (`/sync/` 가 git repo + origin remote 가져야 함) + +즉 **outbound only** — 다른 기기로 보내는 흐름은 있음. + +### 누락 부분 (다기기 동기화 충족 X) + +- **Pull**: 다른 기기에서 push 한 변경 가져오기 — `git fetch && git pull` 흐름 부재 +- **Re-import**: pull 한 markdown 을 SQLite 로 다시 적재 (`ImportService` 가 활용 가능 — F6 백업 복원 흐름과 유사) +- **Conflict resolution**: 같은 노트를 두 기기에서 동시 수정 시 우선순위 + merge 정책 +- **Configure UI**: 사용자가 `/sync/` 에 git init + `git remote add origin ` 수동 — GUI 부재 +- **raw_text 불변 + user-edited 덮어쓰기 금지** (메모리 정책) 다기기 환경에서 어느 기기 user-edited 가 정답인가 결정 필요 — F20 (raw_text 수정 옵션 B `user_edited_text`) 와 강하게 연관 + +### 제안 방향 + +**A. SyncService 양방향화** (가장 작은 첫걸음): +- `sync()` 가 push 전 pull 먼저 — `git fetch && git rebase origin/main` 또는 `merge` +- pull 후 변경된 markdown → re-import 하여 SQLite 갱신 +- conflict 시 user prompt (또는 일단 fail + 수동 resolve 안내) + +**B. Configure UI** (설정 페이지 안 신규 sub-section 또는 별도 섹션): +- "동기화 저장소 URL" 입력 → SyncService 가 `/sync/` 에 git init + remote add origin 자동 +- 인증 안내 (SSH key / token) — Gitea/GitHub 양쪽 호환 +- 마지막 sync 결과 + 시간 표시 + +**C. Conflict resolution UX**: +- 옵션 1: `git merge` 시도 → 실패하면 "양쪽 비교" UI (note id 단위, 각 기기 본문 + AI 결과 비교) +- 옵션 2: timestamp 기반 자동 (마지막 수정 우선) — 데이터 lost risk +- 옵션 3: "내 기기 우선" / "원격 우선" / "수동 merge" 사용자 선택 + +**D. F20 (user_edited_text) 옵션 B 와 결합**: +- raw_text = 캡처 시점 원본 (절대 충돌 X — capture 는 한 기기에서만) +- user_edited_text = 다기기 sync 대상. timestamp + conflict resolution 적용 +- AI 결과 (title/summary/tags) = 어느 기기 가장 최근 결과 사용 + +### 결정 대기 (v0.2.8 brainstorm) + +- 충돌 처리 정책 — A 옵션의 default (rebase / merge / fail) 결정 +- F20 invariant 결정 후 결합 — F20 옵션 B (`user_edited_text`) 가 채택되면 sync 가치 ↑ +- pull 후 re-import 시점 — manual ("지금 동기화" 클릭) vs 주기적 (5분/30분 자동) +- 다기기 운영 시 Aha Moment metric (7일/3일) 측정 — sync lag 영향 + +### 가설·측정 + +- 본인 dogfood: Mac + Windows 두 기기 사용 시 (메모: 본인 Mac=업무, Windows=개인+dogfood) — 현재 single-device. 양방향 sync 후 Mac dogfood 가능성 측정. +- conflict 발생 빈도 — 양 기기에서 동일 노트 수정 케이스 (낮을 것 추정). + +### 범위 + +- A (양방향 sync) + B (Configure UI): 1주 spike 가능. +- A + B + C (conflict UI): 2주. +- D (F20 결합): F20 채택 후 추가 1주. + +### 영향 + +- 다기기 운영 → Aha Moment metric 직접 기여 (Mac 업무 시간에도 capture 가능). +- F20 (raw_text 수정) + F19 (recall) 모두 sync 데이터 일관성 의존. +- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 핵심 인프라 — 단일 기기 dogfood 의 한계 극복. + --- ## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md) @@ -1375,9 +1710,264 @@ app.on('activate', () => { - v0.2.8 narrow scope 에 포함 가치 (1-2일 작업). --- +## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🌱 raw — v0.2.8 후보, 큰 영향) + +**진행 상태:** 🌱 raw — Ollama 의존성 옵션화. v0.2.8 brainstorm 시 cut. F17/F19 와 연관. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "Ollama 를 쓰지 못하는 환경을 위해 로컬 llm 활성화 옵션을 만들고, Ollama 를 안 쓰는 경우 그냥 원문만 저장하고 보여주도록". + +### 관찰 + +- 현재 capture 흐름: `CaptureService.create()` → notes INSERT (ai_status='pending') → `pending_jobs` enqueue → AiWorker → Ollama 호출 → title/summary/tags 채움 → NoteCard 표시. +- Ollama 의존 강제 — 회사 환경 / offline / low-resource device / 사용자 선호도 등 Ollama 안 쓰고 싶은 경우 옵션 부재. +- 현재 Ollama 끊긴 동안 capture 는 가능 (notes INSERT + pending_jobs enqueue), 단 ai_status 가 'pending' 또는 'failed' 로 고착 → FailedBanner / OllamaBanner 가 지속 노출 (사용자 불필요한 마찰). + +### 제안 방향 + +**핵심 설계: "AI 활성화" 토글 (default ON, OFF 시 raw-only 모드)** + +설정 페이지 → AI 제공자 섹션 → 새 토글 "AI 자동 처리 사용" (default ON). + +#### A. OFF 일 때 동작 + +1. `CaptureService.create()` 가 notes INSERT 시 ai_status='disabled' (신규 enum value) + pending_jobs enqueue **skip**. +2. AiWorker 가 'disabled' 상태 노트를 pull 안 함. +3. NoteCard 표시: + - title = raw_text 첫 줄 (또는 빈 값) — fallback rendering + - summary = 빈 값 (UI 에서 hide) + - tags = 빈 배열 (tag filter 비활성) + - raw_text 그대로 노출 (이미 NoteCard 가 표시함) +4. OllamaBanner / FailedBanner 비활성 (AI off 면 의미 없음). +5. 트레이/banner 의 "지금 AI 처리" / "Ollama 재확인" surface 비활성. + +#### B. OFF → ON 전환 시 + +기존 ai_status='disabled' 노트들 — 두 옵션: + +- **B1**: 기존 disabled 잔류 (사용자가 "지금 처리" 버튼 1회 눌러야 재처리). 안전. +- **B2**: 자동 enqueue (모든 disabled → pending + pending_jobs INSERT). 사용자 의도 불일치 risk (옛 메모 갑자기 AI 처리되며 큐 폭증). + +추천: **B1** — 사용자 명시적 trigger 만 옛 노트 처리. 새 노트는 ON 이후 capture 분만 자동 처리. + +#### C. ON → OFF 전환 시 + +- 큐의 pending 잔재 — drain (현 실행 중) + enqueue stop. +- 옵션: pending → disabled 자동 변환 (대량 cleanup) vs 잔류 (다시 ON 시 재개). +- 추천: 잔류 (사용자 의도 보존). + +### 결정 대기 (v0.2.8 brainstorm) + +- ai_status 새 enum 값 'disabled' vs 별도 컬럼 (notes.ai_enabled BOOLEAN) +- B1 vs B2 default — B1 추천. +- raw-only 모드의 NoteCard title fallback — raw_text 첫 줄 / 첫 N자 / 사용자 입력 prompt. +- F19 (recall) 가 raw-only 모드에서도 동작 — tag 부재 시 시간 기반 candidate 만. +- F17 (status 분기) 의 AI 자동 분류 (옵션 B) 가 raw-only 모드에서 비활성 — 정합 검토. +- 처음 설치 시 default — 메모리 정책 "주 타깃 OS 는 Windows + 본인 dogfood 우선" 고려, default ON 유지 (LAN Ollama 가정). + +### 가설·측정 + +- 본인 dogfood: 회사 환경 (Mac 업무) 에서 Ollama 못 쓰는 시간 — raw-only 모드로 capture 만 가능해지면 dogfood metric 영향 측정. +- 사용자 (외부) 가 raw-only 시작 후 ON 전환 비율 — onboarding 흐름 검증. + +### 범위 + +- A 기본 (토글 + ai_status 'disabled' + capture skip): 2-3일. +- B/C 전환 정책 (B1 추천 — 가장 작음): + 0.5일. +- raw-only NoteCard fallback (title=raw_text 첫 줄): + 0.5일. +- 합 3-4일 — v0.2.8 narrow scope 가능. + +### 영향 + +- **Ollama 의존성 옵션화** — 환경 다양성 (회사 / offline / 저사양) 대응. +- F17 (status 자동 분류 옵션 B) 무력화 — AI off 시 옵션 C (보관함만 별도) 가 default 흐름. +- F19 (recall) 가 tag 데이터 부재로 단순화 — context-based / spaced repetition 기반 가능 (search 는 raw_text 가 인덱스 source). +- F1 (Due Date 파서) 는 raw_text 정규식 기반이라 raw-only 모드에서도 동작 ✅. +- 메모리 정책 "raw_text 불변" 의 가치 ↑ — raw_text 자체가 1차 surface 가 되므로 보존 의의 강화. +- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 대안 경로 — Mac 업무 시간 raw-only capture + 저녁 Windows 에서 batch AI 처리 가능. + +--- + +## F24. 이미지 멀티모달 AI 분석 (🌱 raw — v0.2.8/v0.3 후보, capability gated) + +**진행 상태:** 🌱 raw — Ollama vision 모델 (llava / llama3.2-vision / gemma3-multimodal 등) 활용. 사용자 표현: "가능할 경우만 하면 될 것 같다" — capability detection + opt-in 명시. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. F22 (이미지 렌더링) + F23 (Ollama-less 모드) 와 강하게 연관. + +### 관찰 + +[src/main/ai/LocalOllamaProvider.ts:33-42](src/main/ai/LocalOllamaProvider.ts#L33-L42) — 현재 `/api/generate` 호출 시 text-only prompt: + +```ts +const res = await request(`${this.endpoint}/api/generate`, { + prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []), + ... +}); +``` + +`InferenceProvider.GenerateInput` 인터페이스에 `images` 필드 부재. Ollama API 자체는 `images: string[]` (base64) 지원하나 모델이 vision 지원해야 함. + +이미지가 있는 capture (paste 또는 첨부) 의 경우: +- 현재: title/summary/tags 모두 raw_text 기반만 — 이미지 내용 ignore +- 이미지만 있는 capture (raw_text 빈 값): AI 처리 무의미 → 사용자가 빈 메모 받음 + +### 제안 방향 + +**A. Vision capability detection + opt-in 모델 선택** (권장): +1. 설정 페이지 → AI 제공자 섹션 → "이미지 분석 모델" 입력 (별도 필드, default 빈 값 = 비활성). +2. main process 가 startup / 사용자 트리거 시 `GET /api/tags` 로 사용자 Ollama 의 모델 목록 조회 + vision capable 모델 (llava / llama3.2-vision / gemma3 family 등) 자동 감지 → 추천 표시. +3. 사용자가 vision 모델 명시 + 본인 cluster 에 해당 모델 pull 되어 있어야 enable. +4. capability 부재 (모델 없음 / endpoint 끊김 / 빈 값) 면 vision 분석 skip — 텍스트만 + raw_text 처리. + +**B. 분석 흐름 (vision enabled 시)**: + +각 capture 의 이미지 + raw_text 결합 prompt 전송: + +- raw_text 있음 + 이미지 있음 → "다음 텍스트 + 이미지를 종합 요약" prompt +- raw_text 빈 값 + 이미지만 → "이미지 내용 요약 + 한국어 태그" prompt +- AI 응답 형식은 기존 (`title`/`summary`/`tags`) 그대로 — vision 결과가 raw_text 자리에 들어가 자연스럽게 채워짐 + +**C. InferenceProvider 인터페이스 확장**: + +```ts +interface GenerateInput { + text: string; + images?: Array<{ base64: string; mime: string }>; + ... +} +``` + +`LocalOllamaProvider` 가 `images` 비어 있지 않으면 Ollama `/api/generate` 의 `images: [base64...]` 필드 추가 + vision 모델로 호출. provider 가 vision capability 부재 시 images ignore (graceful degrade). + +### 결정 대기 (v0.2.8/v0.3 brainstorm) + +- vision 모델 default 추천 — 한국어 + 이미지 동시 잘하는 모델 (llama3.2-vision / gemma3 family 등 — dogfood 검증 후 결정) +- 이미지 base64 변환 위치 — main process (MediaStore 가 이미 file path 보유) 가 자연스러움 +- 처리 비용 — vision 모델 추론 시간 (수 초~수십 초) + 메모리 부담. capture 흐름 backend 처리 라 사용자 대기 X. +- raw-only 모드 (F23 OFF) 와 정합 — F23 토글 OFF 시 vision 도 OFF (자명). +- 이미지 alt text 자동 생성 (F22 가설 슬롯) 가능 — vision 모델이 alt 도 같이 출력하도록 prompt 설계. + +### 가설·측정 + +- 본인 dogfood: capture 시 이미지 첨부 비율 — 현재 추정 < 일 1건. 일 ≥ 1건 누적 + vision 분석 결과 정확도 측정. +- vision 결과의 사용자 수정 비율 — 높으면 모델 부적합 (다른 모델 / prompt 튜닝). +- "이미지만 있는 capture" 의 처리 가능성 — vision 으로 의미 있는 title/summary 생성 가능한지 검증. + +### 범위 + +- A (capability detection + 설정) + B (vision prompt) + C (InferenceProvider 확장): 1주. +- 이미지 alt text 자동 생성: + 0.5일. +- F22 (이미지 렌더링) 가 선행 prerequisite — 같이 묶으면 1.5주. + +### 영향 + +- 멀티모달 capture — "사진만 찍고 끝" 흐름 가능 (회의 화이트보드 / 영수증 / 메뉴판 등). +- F22 (이미지 렌더링) 의 가치 ↑ — 보이는 이미지 + AI 가 의미 추출. +- F19 (recall) 강화 — vision-derived tags + summary 가 이미지 내용 기반 검색 가능하게 함. +- F23 (Ollama-less 모드) 와 trade-off — AI 토글 OFF 시 vision 도 자동 OFF (자명, 추가 분기 X). +- 메모리 정책 "raw_text 불변" 그대로 — vision 결과는 summary/tags 에만 반영. +- Ollama 의존성 ↑ (vision 모델 추가 pull 부담) — F23 OFF 시 회피 가능. + +--- + +## F25. 사이드바 — 메모 리스트 + 메모 저장소 리스트 (🌱 raw — v0.2.8/v0.3 후보, layout 큰 변화) + +**진행 상태:** 🌱 raw — layout 재구성 + "저장소" 정의 필요. v0.2.8 brainstorm 시 F17/F21 와 함께 triage. + +**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "사이드에 메모 리스트, 메모 저장소 리스트도 보여줬으면". + +### 관찰 + +- 현재 inbox layout = single-pane (header + NoteCard list). 사이드바 부재. +- "메모 저장소" 의미 모호 — 추정 옵션: + - (a) **다중 profile/database** — "회사 메모 / 개인 / 학습" 등 분리. 현재 single profile (`/default/`). + - (b) **카테고리/폴더** — 단일 DB 안 그룹화. 현재 tag 만 존재. + - (c) **다중 sync repo** — F21 의 git sync 가 여러 remote 가능. 본인 + 외부 collaborator. +- 사용자 의도 = (a) 가 가장 자연스러움 ("저장소" 표현). 본인이 dogfood 중 분리 욕구 발생 추정. + +### 제안 방향 (사이드바 + 저장소 모델 분리 결정) + +#### 1. 사이드바 layout + +좌측 또는 우측 column. 폭 240-320px. 상단: 저장소 selector. 하단: 메모 list (현 inbox compact view). + +``` +┌───────────┬────────────────────────────┐ +│ 저장소 │ inbox / 휴지통 (탭) │ +│ • 기본 ├────────────────────────────┤ +│ • 회사 │ │ +│ • 학습 │ NoteCard detail or grid │ +├───────────┤ │ +│ 메모 리스트│ │ +│ - title 1│ │ +│ - title 2│ │ +│ - title 3│ │ +└───────────┴────────────────────────────┘ +``` + +#### 2. "저장소" 정의 (큰 결정) + +**A. 다중 profile** (`//` 별도 DB): +- 가장 정의 명확 — sqlite db / media / sync 모두 저장소 단위 분리 +- 사용자가 새 저장소 생성 → migrations 새로 적용 → 빈 DB +- 메모 / 태그 / due / pending_jobs 모두 저장소 안 격리 +- 마이그레이션 부담 — `resolveProfilePaths` 가 'default' fixed 인 부분 다중 지원 +- AiWorker / HealthChecker / SyncService 등 모두 active profile 기반 재초기화 필요 (큰 refactor) + +**B. 카테고리/폴더** (단일 DB 안 `notebook_id` 컬럼): +- 가벼움 — schema 추가만, services 영향 적음 +- 단점: "회사 메모 따로 백업/sync" 불가 (단일 DB 백업) +- F17 (status 분기) 와 비슷한 의미 layer (status + notebook 두 분기 가치 충돌 가능) + +**C. 단일 profile + 다중 git remote** (F21 sync 관련): +- 한 DB → 여러 sync 대상 (회사 git + 개인 git) +- "저장소" 가 sync target 의미라면 자연스러움 +- 데이터 자체는 분리 안 됨 — "메모 저장소" 이름과 의미 불일치 risk + +추천: **A** — "저장소" 의 사용자 의도가 데이터 분리 (회사/개인) 라면 명확. 그러나 큰 refactor. +대안: **B** — 빠른 구현, 사용자 의도 (a) 일부 충족. + +#### 3. 메모 리스트 (사이드바 안) + +현 inbox NoteCard list 의 compact 버전 — title + tag chip 만, raw_text/summary hide. 클릭 시 main 에서 NoteCard expand. + +main pane 의 view 옵션: +- (i) 단일 detail (사이드 클릭으로 전환) +- (ii) 현재처럼 NoteCard grid (사이드는 scroll/jump 용) + +추천: (ii) — 현 흐름 보존, 사이드바는 navigation 보조. + +### 결정 대기 (v0.2.8 brainstorm) + +- "저장소" 정의 — A/B/C 중 사용자 의도 확인 (직접 묻거나, dogfood 중 욕구 정확히 파악) +- 사이드바 토글 — default visible vs hide-by-default (좁은 화면 + 단일 저장소 시 noise) +- 메모 리스트 sort 기준 — 최신순 / tag / due date +- F17 (status 분기), F19 (recall search) 와 layout 조화 — 검색 박스 위치 (사이드 vs header) + +### 가설·측정 + +- 본인 dogfood: "회사" / "개인" 메모 분리 욕구 빈도 — 1주 soak 후 측정. 빈도 낮으면 옵션 B 또는 C 충분. +- 사이드바 사용 빈도 — main pane 만으로 동작 가능하면 사이드바 noise. + +### 범위 + +- A (다중 profile + 사이드바 + 저장소 selector): 2-3주. AiWorker / HealthChecker / SyncService / 마이그레이션 모두 영향. +- B (notebook_id + 사이드바): 1주. schema + repo 메서드 + UI. +- C (다중 sync remote + 사이드바 navigation 만): 0.5주 (F21 의 일부). +- 사이드바 자체 (저장소 결정 무관, navigation 기능만): 2-3일. + +### 영향 + +- **layout 큰 변화** — 현재 single-pane → two-pane. 좁은 화면 (1280×720 dev) 영향 검증 필요. +- F17 (status 분기) 와 conceptual overlap — "저장소" + "status" + "tag" 세 분기 layer 가 사용자 정신 부담. +- F19 (recall search) — 사이드바에 search box 둘지 결정. +- F21 (git sync) — 옵션 A 시 저장소별 별도 sync repo 자연스러움. +- 본인 dogfood metric — Mac 업무 (회사 메모) + Windows 개인 (dogfood) 분리 시 가치 ↑. + +--- + ## (다음 항목 자리) -새 피드백 추가 시 `## F23. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. +새 피드백 추가 시 `## F26. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. v0.2.8 release 후 dogfood ≥1주 soak 동안 새 발견 항목들 여기 누적 → v0.2.9 brainstorm 트리거. diff --git a/docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md b/docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md new file mode 100644 index 0000000..962b09c --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md @@ -0,0 +1,204 @@ +# v0.2.10 — Cut C Design (raw_text 수정 + revision history) + +**작성일:** 2026-05-09 +**선행 문서:** + +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F20) +- `docs/superpowers/strategy/v028plus-roadmap.md` Cut C + +**Cut 라벨:** v0.2.10 — load-bearing invariant 변경 (raw_text 불변 폐기 + revision history). semver 엄밀히 minor 이지만 v0.2.x 관습. + +--- + +## 1. Cut 정체성 + +메모리 정책 `raw_text 불변` invariant 폐기 + 변경 이력 (revision) 보존. 사용자가 raw_text 자유 수정 + 옛 버전 회수 가능. + +**load-bearing 정책 변경**: + +- 옛: `raw_text 불변` (capture 시점 원본 영구 보존) +- 새: `raw_text 가변` + `note_revisions 테이블` (옛 버전 모두 보존, rollback 가능) + +이는 F1 / F4 / F17 / F19 의 raw_text 가정에 영향 — 모두 current latest raw_text 기준으로 동작 (시간 경과 시 정정된 값 사용). + +--- + +## 2. 범위 + +| 항목 | 결정 | +|---|---| +| **F20** | C 옵션 — raw_text 수정 허용 + `note_revisions` 테이블 + 옛 버전 회수 UI. AI 재실행 input = current latest raw_text (B 옵션). | + +--- + +## 3. Schema 마이그레이션 (m005) + +```sql +CREATE TABLE note_revisions ( + rev_id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id TEXT NOT NULL, + raw_text TEXT NOT NULL, + edited_at TEXT NOT NULL, + edited_by TEXT NOT NULL DEFAULT 'user', -- 'user' or 'capture' (최초 캡처) + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE +); + +CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC); + +-- 기존 notes 의 모든 raw_text 를 첫 revision 으로 backfill +INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + SELECT id, raw_text, created_at, 'capture' FROM notes; +``` + +`note_revisions.rev_id = AUTOINCREMENT` — chronological 순서 보장. `edited_by` = 'user' (사용자 정정) 또는 'capture' (최초). + +`notes.raw_text` 컬럼 그대로 — current latest 값. 검색 인덱스 (F19 FTS5) 가 이걸 source 로 사용 → revision 검색 X (latest only). YAGNI. + +--- + +## 4. NoteRepository 메서드 + +```ts +class NoteRepository { + // 기존 + insert(input: ...): Note; // 내부에서 note_revisions INSERT (edited_by='capture') + + // 신규 + updateRawText(id: string, newText: string, now: Date): void { + const tx = this.db.transaction(() => { + this.db.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`).run(newText, now.toISOString(), id); + this.db.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')`).run(id, newText, now.toISOString()); + }); + tx(); + } + + listRevisions(id: string): NoteRevision[] { + return this.db.prepare(`SELECT * FROM note_revisions WHERE note_id=? ORDER BY edited_at DESC`).all(id) as NoteRevision[]; + } + + restoreRevision(id: string, revId: number, now: Date): void { + const rev = this.db.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`).get(revId, id) as { raw_text: string } | undefined; + if (!rev) throw new Error(`revision ${revId} not found`); + this.updateRawText(id, rev.raw_text, now); // 새 revision 으로 복원 (linear history 유지) + } +} +``` + +`restoreRevision` 은 옛 raw_text 를 **새 revision** 으로 INSERT — chain 끊지 않고 latest = restored. timestamp/순서 명확. + +--- + +## 5. UI — NoteCard 수정 흐름 + +### 5-1. raw_text 편집 UI + +기존 NoteCard 의 "원문 보기" 펼침 → 추가 "편집" 버튼: + +```tsx +{rawOpen && ( +
+ {editingRaw ? ( + <> +