Task 2 of the slice plan. Creates the minimal Electron main
process: app entry that opens an Inbox BrowserWindow on
whenReady, an inboxWindow module that handles show/hide/close-
to-tray semantics, an HTML placeholder renderer ("Inkling Inbox
(renderer pending)"), and the minimum @shared/types augmentation
for app.isQuitting (Task 3 expands this file).
Plan tsconfig template adjustment: TypeScript 6 deprecates
baseUrl. Dropped it and made paths entries explicitly relative
("./src/...") so they resolve from tsconfig.json's directory.
Updated both the in-repo tsconfig.json and Task 1 Step 3 in the
plan to match.
Verification: `npm run typecheck` exits 0. Full `npm run dev`
sanity check is deferred until Task 3 (preload) and Task 19
(quickcapture HTML) land — electron-vite.config.ts wires both
entries that don't yet exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 KiB
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.
Plan version: v0.4 (gemma4:e4b standard tier, downsized from 26b). Updated 2026-04-25.
Goal: Implement Inkling Vertical Slice v0.4 — 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 an Ollama provider (gemma4:e4b, endpoint configurable via INKLING_OLLAMA_ENDPOINT, defaults to localhost:11434), 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; endpoint from INKLING_OLLAMA_ENDPOINT env var with localhost:11434 fallback; model gemma4:e4b).
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 (v0.2)
Strategy reference: docs/superpowers/strategy/strategy.md
File Structure
inkling/
├── .nvmrc # already exists (24.15.0)
├── package.json # Task 1
├── tsconfig.json # Task 1
├── tsconfig.node.json # Task 1
├── electron.vite.config.ts # Task 1
├── vitest.config.ts # Task 1
├── playwright.config.ts # Task 32
├── .gitignore # Task 1
├── src/
│ ├── shared/
│ │ ├── 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 + Task 30 final wiring
│ │ ├── logger.ts # Task 4
│ │ ├── paths.ts # Task 5
│ │ ├── windows/
│ │ │ ├── inboxWindow.ts # Task 2
│ │ │ └── quickCaptureWindow.ts # Task 18
│ │ ├── db/
│ │ │ ├── index.ts # Task 6
│ │ │ └── migrations/
│ │ │ ├── index.ts # Task 6
│ │ │ └── m001_initial.ts # Task 6
│ │ ├── repository/
│ │ │ └── NoteRepository.ts # Task 7
│ │ ├── services/
│ │ │ ├── MediaStore.ts # Task 8
│ │ │ ├── ContinuityService.ts # Task 9
│ │ │ ├── CaptureService.ts # Task 14
│ │ │ ├── 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
│ │ │ ├── LocalOllamaProvider.ts # Task 12
│ │ │ ├── prompt.ts # Task 10
│ │ │ └── AiWorker.ts # Task 13
│ │ ├── ipc/
│ │ │ ├── captureApi.ts # Task 16
│ │ │ └── inboxApi.ts # Task 21
│ │ └── tray.ts # Task 30
│ └── renderer/
│ ├── inbox/
│ │ ├── 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 25
│ │ ├── EditableField.tsx # Task 26
│ │ ├── IntentBanner.tsx # Task 27
│ │ ├── RecoveryToast.tsx # Task 28
│ │ ├── ContinuityBadge.tsx # Task 23
│ │ ├── PendingBanner.tsx # Task 24
│ │ └── OllamaBanner.tsx # Task 29
│ └── quickcapture/
│ ├── 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
│ ├── 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
│ ├── NotificationService.test.ts # Task 15
│ └── IntentService.test.ts # Task 20
├── integration/
│ └── ollama-golden.test.ts # Task 12
└── e2e/
└── smoke.spec.ts # Task 32
Task 1: Bootstrap project
Files:
-
Create:
package.json,tsconfig.json,tsconfig.node.json,electron.vite.config.ts,vitest.config.ts,.gitignore -
Step 1: Initialize
package.json
{
"name": "inkling",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"start": "electron-vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "INKLING_INTEGRATION=1 vitest run tests/integration",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
}
}
- Step 2: Install dependencies
npm install --save-exact \
electron@41.3.0 \
react@19.2.5 react-dom@19.2.5 \
zustand@5.0.12 \
better-sqlite3@12.9.0 \
zod@4.3.6 \
electron-log@5.2.0 \
uuid@11.0.3
npm install --save-exact --save-dev \
typescript@6.0.3 \
@types/node@24.0.0 \
@types/react@19.0.0 @types/react-dom@19.0.0 \
@types/better-sqlite3@7.6.11 \
electron-vite@5.0.0 \
vite@7.3.2 \
@vitejs/plugin-react@5.1.4 \
vitest@4.1.5 \
undici@8.1.0 \
@playwright/test@1.59.1
If a version is not yet published, run npm view <pkg> version and use the latest stable; update spec §7.2 in the same PR.
Resolution log (2026-04-25 install): v0.4 had
electron-vite@2.3.0(peervite@^4||^5) conflicting withvite@6.0.3. Resolved by promoting the build-tool chain toelectron-vite@5.0.0+vite@7.3.2+@vitejs/plugin-react@5.1.4(all sharevite@^7as the compatible overlap; vitest@4.1.5 also accepts vite@7).@types/uuidline removed: uuid@11 ships its own type definitions, so@types/uuid@11.0.0is a deprecated stub.
- Step 3:
tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"],
"paths": {
"@shared/*": ["./src/shared/*"],
"@main/*": ["./src/main/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "out", "dist"]
}
- Step 4:
tsconfig.node.json
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["electron.vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}
- Step 5:
electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: { index: resolve(__dirname, 'src/main/index.ts') }
}
},
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: { index: resolve(__dirname, 'src/preload/index.ts') }
}
},
resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } }
},
renderer: {
plugins: [react()],
resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } },
build: {
rollupOptions: {
input: {
inbox: resolve(__dirname, 'src/renderer/inbox/index.html'),
quickcapture: resolve(__dirname, 'src/renderer/quickcapture/index.html')
}
}
}
}
});
- Step 6:
vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['tests/unit/**/*.test.ts'],
exclude: ['tests/integration/**', 'tests/e2e/**'],
coverage: { reporter: ['text', 'html'] }
},
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main')
}
}
});
- Step 7:
.gitignore
node_modules/
out/
dist/
*.log
.vite/
.DS_Store
coverage/
playwright-report/
test-results/
- Step 8: Verify
npx tsc --version
npx vitest --version
npx electron --version
Expected: TypeScript 6.x, Vitest 4.x, Electron 41.x.
- Step 9: Commit
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 (v0.2 deps)"
Task 2: Electron main entry + Inbox window shell
Files: Create src/main/index.ts, src/main/windows/inboxWindow.ts, src/renderer/inbox/index.html
- Step 1: Inbox HTML placeholder
src/renderer/inbox/index.html:
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Inkling</title>
</head>
<body>
<div id="root"></div>
<script>document.getElementById('root').textContent = 'Inkling Inbox (renderer pending)';</script>
</body>
</html>
(Task 22 restores the real module script.)
- Step 2:
inboxWindow.ts
import { BrowserWindow, app } from 'electron';
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 {
return inboxWindow;
}
export function createInboxWindow(): BrowserWindow {
if (inboxWindow && !inboxWindow.isDestroyed()) {
inboxWindow.show();
inboxWindow.focus();
return inboxWindow;
}
inboxWindow = new BrowserWindow({
width: 900,
height: 720,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
if (process.env.ELECTRON_RENDERER_URL) {
inboxWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/inbox/index.html`);
} else {
inboxWindow.loadFile(join(__dirname, '../renderer/inbox/index.html'));
}
inboxWindow.on('close', (e) => {
if (!app.isQuitting) {
e.preventDefault();
inboxWindow?.hide();
}
});
inboxWindow.once('ready-to-show', () => inboxWindow?.show());
return inboxWindow;
}
- Step 3:
src/shared/types.tsminimum (Task 3 expands it)
declare global {
namespace Electron {
interface App { isQuitting?: boolean; }
}
}
export {};
- Step 4:
src/main/index.ts
import { app, BrowserWindow } from 'electron';
import '@shared/types';
import { createInboxWindow } from './windows/inboxWindow.js';
app.whenReady().then(() => {
createInboxWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
});
});
app.on('before-quit', () => { app.isQuitting = true; });
- Step 5: Verify dev start
npm run dev → window shows placeholder; close X hides; Ctrl+C exits.
- Step 6: Commit
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 + Inbox window shell"
Task 3: Preload typed bridge with v0.2 types
Files: Create src/preload/index.ts, modify src/shared/types.ts
- Step 1: Replace
src/shared/types.ts
declare global {
namespace Electron { interface App { isQuitting?: boolean; } }
interface Window { inkling: InklingApi; }
}
export interface NoteMedia {
id: string;
kind: 'image';
relPath: string;
mime: string;
bytes: number;
}
export type AiStatus = 'pending' | 'done' | 'failed';
export interface NoteTag {
name: string;
source: 'ai' | 'user';
}
export interface Note {
id: string;
rawText: string;
aiTitle: string | null;
aiSummary: string | null;
aiStatus: AiStatus;
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 WeeklyContinuity {
weekStart: string; // ISO date (KST 월요일)
weekCount: number;
weekTarget: number; // 7
consecutiveCompleteWeeks: number;
showRecoveryToast: boolean;
lastNoteAt: string | null;
}
export interface CaptureApi {
submit(payload: { text: string; images: ArrayBuffer[] }): Promise<{ noteId: string }>;
hide(): void;
}
export interface InboxApi {
listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
updateAiFields(
noteId: string,
fields: { title?: string; summary?: string; tags?: string[] }
): Promise<void>;
deleteNote(noteId: string): Promise<void>;
setIntent(noteId: string, text: string): Promise<void>;
dismissIntent(noteId: string): Promise<void>;
getContinuity(): Promise<WeeklyContinuity>;
getPendingCount(): Promise<number>;
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
onNoteUpdated(cb: (note: Note) => void): () => void;
}
export interface InklingApi {
capture: CaptureApi;
inbox: InboxApi;
}
export {};
- Step 2:
src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { InklingApi, Note } from '@shared/types';
const api: InklingApi = {
capture: {
submit: (payload) => ipcRenderer.invoke('capture:submit', payload),
hide: () => ipcRenderer.send('capture:hide')
},
inbox: {
listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts),
updateAiFields: (noteId, fields) =>
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
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) => {
const listener = (_e: unknown, note: Note) => cb(note);
ipcRenderer.on('note:updated', listener);
return () => ipcRenderer.off('note:updated', listener);
}
}
};
contextBridge.exposeInMainWorld('inkling', api);
- Step 3: Typecheck and commit
npm run typecheck
git add src/preload/index.ts src/shared/types.ts
git commit -m "feat(preload): expose typed InklingApi (v0.2: intent + continuity)"
Task 4: Logger with PII protection
Files: Create src/main/logger.ts, modify src/main/index.ts
- Step 1:
logger.ts
import log from 'electron-log/main';
import { app } from 'electron';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
let initialized = false;
export function initLogger(): void {
if (initialized) return;
const logDir = join(app.getPath('userData'), 'Inkling', 'logs');
log.transports.file.resolvePathFn = () =>
join(logDir, `main-${new Date().toISOString().slice(0, 10)}.log`);
log.transports.file.maxSize = 5 * 1024 * 1024;
log.transports.file.level = 'info';
log.transports.console.level = process.env.INKLING_DEBUG === '1' ? 'debug' : 'info';
initialized = true;
}
export function hashPrefix(text: string): string {
return createHash('sha256').update(text).digest('hex').slice(0, 8);
}
export const logger = {
info: (msg: string, meta?: Record<string, unknown>) => log.info(msg, meta ?? {}),
warn: (msg: string, meta?: Record<string, unknown>) => log.warn(msg, meta ?? {}),
error: (msg: string, meta?: Record<string, unknown>) => log.error(msg, meta ?? {}),
debug: (msg: string, meta?: Record<string, unknown>) => log.debug(msg, meta ?? {})
};
- Step 2: Wire in
src/main/index.ts
Add to imports:
import { initLogger, logger } from './logger.js';
Insert at top of whenReady body:
initLogger();
logger.info('app.start', { platform: process.platform, version: app.getVersion() });
- Step 3: Verify and commit
npm run dev → check {userData}/Inkling/logs/main-YYYY-MM-DD.log has app.start entry.
git add src/main/logger.ts src/main/index.ts
git commit -m "feat(logger): add electron-log with PII-safe helpers"
Task 5: Paths utility
Files: Create src/main/paths.ts
- Step 1: Implement
import { app } from 'electron';
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';
export interface ProfilePaths {
profileDir: string;
dbFile: string;
mediaDir: string;
}
export function resolveProfilePaths(profile = 'default'): ProfilePaths {
const root = join(app.getPath('userData'), 'Inkling', 'profiles', profile);
const p: ProfilePaths = {
profileDir: root,
dbFile: join(root, 'inkling.sqlite'),
mediaDir: join(root, 'media')
};
mkdirSync(p.mediaDir, { recursive: true });
return p;
}
- Step 2: Commit
git add src/main/paths.ts
git commit -m "feat(paths): per-profile directory resolver"
Task 6: DB initialization + v1 migration with intent + edited columns
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:
m001_initial.ts
import type Database from 'better-sqlite3';
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,
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);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id),
source TEXT NOT NULL CHECK (source IN ('ai','user')),
PRIMARY KEY (note_id, tag_id)
);
CREATE TABLE media (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('image')),
rel_path TEXT NOT NULL,
mime TEXT NOT NULL,
bytes INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_media_note_id ON media(note_id);
CREATE TABLE pending_jobs (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
attempts INTEGER NOT NULL DEFAULT 0,
next_run_at TEXT NOT NULL,
last_error TEXT
);
`);
}
- Step 2:
migrations/index.ts
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
const migrations = [m001];
export function runMigrations(db: Database.Database): void {
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(() => {
m.up(db);
db.pragma(`user_version = ${m.version}`);
});
tx();
}
}
}
- Step 3:
db/index.ts
import Database from 'better-sqlite3';
import { runMigrations } from './migrations/index.js';
export function openDb(dbFile: string): Database.Database {
const db = new Database(dbFile);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
- Step 4:
tests/unit/migrations.test.ts
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
describe('migrations', () => {
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 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();
});
it('is idempotent', () => {
const db = new Database(':memory:');
runMigrations(db);
runMigrations(db);
expect((db.prepare('PRAGMA user_version').get() as any).user_version).toBe(1);
db.close();
});
});
- Step 5: Run + commit
npx vitest run tests/unit/migrations.test.ts
git add src/main/db/ tests/unit/migrations.test.ts
git commit -m "feat(db): v1 schema with user_intent + edited flags"
Task 7: NoteRepository with intent + edited flags
Files: Create src/main/repository/NoteRepository.ts, tests/unit/NoteRepository.test.ts
- Step 1: Failing test for create + edited flag default 0
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';
function freshDb() {
const db = new Database(':memory:');
runMigrations(db);
return db;
}
describe('NoteRepository', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); });
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: Verify failure
npx vitest run tests/unit/NoteRepository.test.ts → FAIL.
- Step 3: Implement repository
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 NewMediaRow {
noteId: string;
kind: 'image';
relPath: string;
mime: string;
bytes: number;
}
export class NoteRepository {
constructor(private db: Database.Database) {}
create(input: CreateNoteInput): { id: string } {
const id = uuidv7();
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', ?, ?)`)
.run(id, input.rawText, now, now);
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
});
tx();
return { id };
}
insertMedia(rows: NewMediaRow[]): void {
if (rows.length === 0) return;
const now = new Date().toISOString();
const stmt = this.db.prepare(
`INSERT INTO media (id, note_id, kind, rel_path, mime, bytes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
);
const tx = this.db.transaction(() => {
for (const r of rows) {
stmt.run(uuidv4(), r.noteId, r.kind, r.relPath, r.mime, r.bytes, now);
}
});
tx();
}
findById(id: string): Note | null {
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
if (!row) return null;
return this.hydrate(row);
}
list(opts: { limit: number; cursor?: string }): Note[] {
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 ?`)
.all(opts.cursor, limit) as any[])
: (this.db
.prepare(`SELECT * FROM notes ORDER BY created_at DESC LIMIT ?`)
.all(limit) as any[]);
return rows.map((r) => this.hydrate(r));
}
updateAiResult(
id: string,
result: { title: string; summary: string; tags: string[]; provider: string }
): 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 = 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);
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkTag = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
for (const t of result.tags) {
const tagRow = getOrInsertTag.get(t) as { id: number };
linkTag.run(id, tagRow.id);
}
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
});
tx();
}
markAiFailed(id: string, error: string): void {
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=?`)
.run(error.slice(0, 500), now, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
});
tx();
}
updateUserAiFields(
id: string,
fields: { title?: string; summary?: string; tags?: string[] }
): void {
const now = new Date().toISOString();
const tx = this.db.transaction(() => {
const updates: string[] = [];
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) {
updates.push('updated_at=?');
params.push(now);
params.push(id);
this.db.prepare(`UPDATE notes SET ${updates.join(', ')} WHERE id=?`).run(...params);
}
if (fields.tags !== undefined) {
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(id);
const getOrInsert = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const link = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of fields.tags) {
const row = getOrInsert.get(t) as { id: number };
link.run(id, row.id);
}
}
});
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);
}
getPendingCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
.get() as { c: number };
return row.c;
}
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
const rows = this.db
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
.all() as any[];
return rows.map((r) => ({
noteId: r.note_id,
attempts: r.attempts,
nextRunAt: r.next_run_at
}));
}
incrementJobAttempt(noteId: string, nextRunAt: string, lastError: string): void {
this.db
.prepare(
`UPDATE pending_jobs
SET attempts = attempts + 1,
next_run_at = ?,
last_error = ?
WHERE note_id = ?`
)
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
private hydrate(row: any): Note {
const tags = this.db
.prepare(
`SELECT t.name, nt.source
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = ? ORDER BY t.name`
)
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
const media = this.db
.prepare(
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
)
.all(row.id) as NoteMedia[];
return {
id: row.id,
rawText: row.raw_text,
aiTitle: row.ai_title,
aiSummary: row.ai_summary,
aiStatus: row.ai_status,
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[],
media
};
}
}
- Step 4: Verify first test passes
npx vitest run tests/unit/NoteRepository.test.ts → first test passes.
- Step 5: Append remaining tests
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, 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:e4b'
});
const note = repo.findById(id)!;
expect(note.aiStatus).toBe('done');
expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']);
expect(note.tags.every((t) => t.source === 'ai')).toBe(true);
expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined();
});
it('markAiFailed truncates and clears pending job', () => {
const { id } = repo.create({ rawText: 'x' });
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', () => {
const { id } = repo.create({ rawText: 'x' });
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.tags).toEqual([{ name: 'user-tag', source: 'user' }]);
});
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' });
repo.delete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 });
});
it('list returns notes in descending created_at', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]);
});
it('getPendingCount counts pending notes', () => {
repo.create({ rawText: 'a' });
const { id } = repo.create({ rawText: 'b' });
expect(repo.getPendingCount()).toBe(2);
repo.markAiFailed(id, 'err');
expect(repo.getPendingCount()).toBe(1);
});
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;
expect(row.attempts).toBe(1);
expect(row.last_error).toBe('boom');
});
- Step 6: Run + commit
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): NoteRepository with intent, edited flags, AI overwrite guard"
Task 8: MediaStore
Files: Create src/main/services/MediaStore.ts, tests/unit/MediaStore.test.ts
- Step 1: Failing tests
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MediaStore } from '@main/services/MediaStore.js';
describe('MediaStore', () => {
let tmp: string;
let store: MediaStore;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'inkling-media-'));
store = new MediaStore(tmp);
});
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.bytes).toBe(bytes.length);
expect(readFileSync(join(tmp, saved.relPath)).equals(bytes)).toBe(true);
});
it('saves multiple images with unique filenames', async () => {
const bytes = Buffer.from('abc');
const a = await store.saveImage('n', bytes, 'image/png');
const b = await store.saveImage('n', bytes, 'image/png');
expect(a.relPath).not.toBe(b.relPath);
});
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 dir names', async () => {
await store.saveImage('alpha', Buffer.from('a'), 'image/png');
await store.saveImage('beta', Buffer.from('b'), 'image/png');
expect((await store.listNoteDirs()).sort()).toEqual(['alpha', 'beta']);
});
});
- Step 2: Verify failure
npx vitest run tests/unit/MediaStore.test.ts → FAIL.
- Step 3: Implement
import { mkdir, writeFile, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
export interface SavedMedia {
relPath: string;
mime: string;
bytes: number;
}
export class MediaStore {
constructor(private profileDir: string) {}
async saveImage(noteId: string, bytes: Buffer, mime: string): Promise<SavedMedia> {
const dir = join(this.profileDir, 'media', noteId);
await mkdir(dir, { recursive: true });
const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'bin';
const filename = `${uuidv4()}.${ext}`;
await writeFile(join(dir, filename), bytes);
return { relPath: `media/${noteId}/${filename}`, mime, bytes: bytes.length };
}
async deleteNoteDirectory(noteId: string): Promise<void> {
await rm(join(this.profileDir, 'media', noteId), { recursive: true, force: true });
}
async listNoteDirs(): Promise<string[]> {
const root = join(this.profileDir, 'media');
try {
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 [];
throw err;
}
}
}
- Step 4: Run + commit
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): MediaStore for image persistence and cleanup"
Task 9: ContinuityService (Weekly Continuity)
Files: Create src/main/services/ContinuityService.ts, tests/unit/ContinuityService.test.ts
- Step 1: Failing tests
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { ContinuityService } from '@main/services/ContinuityService.js';
function dbWithDates(isoDates: string[]): Database.Database {
const db = new Database(':memory:');
runMigrations(db);
const insert = db.prepare(
`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);
return db;
}
describe('ContinuityService', () => {
it('empty db returns zero counts and no recovery toast', () => {
const db = new Database(':memory:');
runMigrations(db);
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 notes in current KST week (월~일)', () => {
// KST 2026-04-20 (월) ~ 2026-04-26 (일)
const db = dbWithDates([
'2026-04-20T01:00:00+09:00', // 월
'2026-04-22T03:00:00+09:00', // 수
'2026-04-25T22:00:00+09:00' // 토 (조사 시점 직전)
]);
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);
});
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('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-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(false);
});
});
- Step 2: Verify failure
npx vitest run tests/unit/ContinuityService.test.ts → FAIL.
- Step 3: Implement
import type Database from 'better-sqlite3';
import type { WeeklyContinuity } from '@shared/types';
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);
}
export class ContinuityService {
constructor(
private db: Database.Database,
private now: () => Date = () => new Date()
) {}
get(): WeeklyContinuity {
const rows = this.db
.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
};
}
// Group by KST week (Mon-Sun)
const byWeek = new Map<string, number>();
for (const d of dates) {
const wk = kstMondayOf(d);
byWeek.set(wk, (byWeek.get(wk) ?? 0) + 1);
}
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 + commit
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, src/main/ai/prompt.ts, tests/unit/ai-schema.test.ts
- Step 1: Failing tests
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', () => {
const r = parseAiResponse({
title: '회의 요약',
summary: '첫 줄\n둘째 줄\n셋째 줄',
tags: ['api-timeout', 'meeting']
});
expect(r.title).toBe('회의 요약');
expect(r.summary.split('\n')).toHaveLength(3);
expect(r.tags).toEqual(['api-timeout', 'meeting']);
});
it('rejects title without Korean', () => {
expect(() =>
parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
).toThrow(/korean/i);
});
it('pads short summary to 3 lines', () => {
const r = parseAiResponse({ title: '제목', summary: '한 줄', tags: [] });
expect(r.summary.split('\n')).toHaveLength(3);
});
it('compresses long summary to 3 lines', () => {
const r = parseAiResponse({
title: '제목', summary: 'a\nb\nc\nd\ne', tags: []
});
const lines = r.summary.split('\n');
expect(lines).toHaveLength(3);
expect(lines[2]).toBe('c d e');
});
it('filters invalid tags', () => {
const r = parseAiResponse({
title: '제목', summary: 'a\nb\nc',
tags: ['good-tag', 'BadCase', 'has space', 'ok2', '']
});
expect(r.tags).toEqual(['good-tag', 'ok2']);
});
it('caps tags to 3', () => {
const r = parseAiResponse({
title: '제목', summary: 'a\nb\nc',
tags: ['a', 'b', 'c', 'd', 'e']
});
expect(r.tags).toHaveLength(3);
});
it('rejects non-object input', () => {
expect(() => parseAiResponse('nope')).toThrow();
});
});
- Step 2: Verify failure
npx vitest run tests/unit/ai-schema.test.ts → FAIL.
- Step 3: Implement schema
import { z } from 'zod';
const KOREAN_REGEX = /[가-힣]/;
const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const RawResponseSchema = z.object({
title: z.string().trim().min(1).max(200),
summary: z.string().min(1),
tags: z.array(z.string()).default([])
});
export interface AiResponse {
title: string;
summary: string;
tags: string[];
}
function normalizeSummary(raw: string): string {
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) {
while (lines.length < 3) lines.push('');
return lines.join('\n');
}
const head = lines.slice(0, 2);
const tail = lines.slice(2).join(' ');
return [...head, tail].join('\n');
}
export function parseAiResponse(raw: unknown): AiResponse {
const parsed = RawResponseSchema.parse(raw);
if (!KOREAN_REGEX.test(parsed.title)) {
throw new Error('title must contain Korean characters');
}
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: Implement prompt
src/main/ai/prompt.ts:
export const PROMPT_VERSION = 1;
export function buildPrompt(rawText: string): string {
return `You organize raw personal notes into structured metadata.
Input note (raw text, may be fragmented, any language):
---
${rawText}
---
Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
Rules:
- title and summary MUST be written in Korean regardless of input language.
- tags MUST be English kebab-case (for consistency across notes; easier to search/group).
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.`;
}
- Step 5: Run + commit
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): zod schema validator + Korean-first prompt"
Task 11: InferenceProvider interface
Files: Create src/main/ai/InferenceProvider.ts
- Step 1: Implement
import type { AiResponse } from './schema.js';
export interface GenerateInput { text: string; }
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
export interface InferenceProvider {
readonly name: string;
generate(input: GenerateInput): Promise<AiResponse>;
healthCheck(): Promise<HealthResult>;
}
- Step 2: Commit
git add src/main/ai/InferenceProvider.ts
git commit -m "feat(ai): InferenceProvider interface"
Task 12: LocalOllamaProvider
Files: Create src/main/ai/LocalOllamaProvider.ts, tests/unit/LocalOllamaProvider.test.ts, tests/integration/ollama-golden.test.ts
- Step 1: Failing unit tests
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
describe('LocalOllamaProvider', () => {
let mock: MockAgent;
let original: ReturnType<typeof getGlobalDispatcher>;
beforeEach(() => {
original = getGlobalDispatcher();
mock = new MockAgent();
mock.disableNetConnect();
setGlobalDispatcher(mock);
});
afterEach(async () => {
setGlobalDispatcher(original);
await mock.close();
});
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 r = await new LocalOllamaProvider().generate({ text: 'x' });
expect(r.title).toBe('회의');
});
it('generate throws on non-JSON', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'not json'
});
await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i);
});
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 model present', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
models: [{ name: 'gemma4:e4b' }]
});
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(true);
expect(h.model).toBe('gemma4:e4b');
});
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 h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toMatch(/gemma4:e4b/);
});
it('healthCheck ok=false on connection error', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('ECONNREFUSED'));
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toMatch(/connect|refused|unreachable/i);
});
});
- Step 2: Verify failure
npx vitest run tests/unit/LocalOllamaProvider.test.ts → FAIL.
- Step 3: Implement
import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
export interface LocalOllamaOptions {
endpoint?: string;
model?: string;
timeoutMs?: number;
temperature?: number;
numPredict?: number;
}
export class LocalOllamaProvider implements InferenceProvider {
readonly name: string;
private endpoint: string;
private model: string;
private timeoutMs: number;
private temperature: number;
private numPredict: number;
constructor(opts: LocalOllamaOptions = {}) {
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
this.model = opts.model ?? 'gemma4:e4b';
this.timeoutMs = opts.timeoutMs ?? 120_000;
this.temperature = opts.temperature ?? 0.2;
this.numPredict = opts.numPredict ?? 512;
this.name = `local-ollama/${this.model}`;
}
async generate(input: GenerateInput): Promise<AiResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
signal: controller.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
}
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)}`); }
return parseAiResponse(parsed);
} finally {
clearTimeout(timer);
}
}
async healthCheck(): Promise<HealthResult> {
try {
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
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` };
} catch (err) {
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
}
}
}
- Step 4: Integration golden test (opt-in)
tests/integration/ollama-golden.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
const skip = process.env.INKLING_INTEGRATION !== '1';
describe.skipIf(skip)('LocalOllamaProvider integration', () => {
const provider = new LocalOllamaProvider({
endpoint: process.env.INKLING_OLLAMA_ENDPOINT
});
beforeAll(async () => {
const h = await provider.healthCheck();
if (!h.ok) throw new Error(`Ollama not ready: ${h.reason}`);
});
const cases = [
'회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음.',
'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.',
'오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.'
];
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]+)*$/);
}, 180_000);
});
- Step 5: Run + commit
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): LocalOllamaProvider with 120s timeout + integration harness"
Task 13: AiWorker
Files: Create src/main/ai/AiWorker.ts, tests/unit/AiWorker.test.ts
- Step 1: Failing tests
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
return {
name: 'mock',
generate: vi.fn(async (): Promise<AiResponse> => ({
title: '제목', summary: 'a\nb\nc', tags: ['tag']
})),
healthCheck: vi.fn(async () => ({ ok: true })),
...overrides
} as InferenceProvider;
}
describe('AiWorker', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('processes a pending job and marks done', async () => {
const { id } = repo.create({ rawText: 'x' });
const updates: string[] = [];
const w = new AiWorker(repo, makeProvider(), {
backoffsMs: [0, 0, 0],
onUpdate: (note) => updates.push(note.aiStatus)
});
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)?.aiStatus).toBe('done');
expect(updates).toContain('done');
});
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'); })
});
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', 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 sequentially (concurrency 1)', async () => {
const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
let running = 0;
let max = 0;
const provider = makeProvider({
generate: vi.fn(async () => {
running++; max = Math.max(max, running);
await new Promise((r) => setTimeout(r, 10));
running--;
return { title: '제목', summary: 'a\nb\nc', tags: [] };
})
});
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: Verify failure
npx vitest run tests/unit/AiWorker.test.ts → FAIL.
- Step 3: Implement
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
export interface AiWorkerOptions {
backoffsMs?: number[];
onUpdate?: (note: Note) => void;
logger?: {
info: (msg: string, meta?: Record<string, unknown>) => void;
warn: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
};
}
interface Job { noteId: string; attempts: number; }
export class AiWorker {
private queue: Job[] = [];
private running = false;
private drainResolvers: Array<() => void> = [];
private backoffsMs: number[];
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
constructor(
private repo: NoteRepository,
private provider: InferenceProvider,
opts: AiWorkerOptions = {}
) {
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
}
async enqueue(noteId: string): Promise<void> {
this.queue.push({ noteId, attempts: 0 });
this.kick();
}
async loadFromDb(): Promise<void> {
for (const j of this.repo.getAllPendingJobs()) {
this.queue.push({ noteId: j.noteId, attempts: j.attempts });
}
this.kick();
}
async drain(): Promise<void> {
if (!this.running && this.queue.length === 0) return;
await new Promise<void>((resolve) => {
this.drainResolvers.push(resolve);
this.kick();
});
}
private kick(): void {
if (this.running) return;
if (this.queue.length === 0) { this.resolveDrainers(); return; }
this.running = true;
void this.loop();
}
private async loop(): Promise<void> {
try {
while (this.queue.length > 0) {
const job = this.queue.shift()!;
await this.processJob(job);
}
} finally {
this.running = false;
this.resolveDrainers();
}
}
private resolveDrainers(): void {
const r = this.drainResolvers.splice(0);
for (const fn of r) fn();
}
private async processJob(job: Job): Promise<void> {
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,
provider: this.provider.name
});
this.logger.info('ai.done', { noteId: job.noteId, attempt });
this.emit(job.noteId);
return;
} catch (err) {
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();
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
this.emit(job.noteId);
return;
}
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
}
}
}
private emit(noteId: string): void {
if (!this.onUpdate) return;
const note = this.repo.findById(noteId);
if (note) this.onUpdate(note);
}
private sleep(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((r) => setTimeout(r, ms));
}
}
- Step 4: Run + commit
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): AiWorker with sequential queue, 3-attempt backoff"
Task 14: CaptureService (with notification trigger)
Files: Create src/main/services/CaptureService.ts, tests/unit/CaptureService.test.ts
- Step 1: Failing tests
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { MediaStore } from '@main/services/MediaStore.js';
import { CaptureService } from '@main/services/CaptureService.js';
describe('CaptureService', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let enqueued: string[];
let celebrated: string[];
let svc: CaptureService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
enqueued = [];
celebrated = [];
svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: (id) => { celebrated.push(id); }
});
});
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}/', 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)!;
expect(note.media).toHaveLength(1);
expect(note.media[0].relPath.startsWith(`media/${noteId}/`)).toBe(true);
});
it('rejects empty submit', async () => {
await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i);
expect(celebrated).toHaveLength(0);
});
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);
expect(repo.findById(noteId)).toBeNull();
});
});
- Step 2: Verify failure
npx vitest run tests/unit/CaptureService.test.ts → FAIL.
- Step 3: Implement
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
}
export interface SubmitInput {
text: string;
images: ArrayBuffer[];
}
export class CaptureService {
constructor(
private repo: NoteRepository,
private store: MediaStore,
private deps: CaptureDeps
) {}
async submit(input: SubmitInput): Promise<{ noteId: string }> {
const trimmed = input.text.trim();
if (trimmed.length === 0 && input.images.length === 0) {
throw new Error('empty submission');
}
const { id } = this.repo.create({ rawText: input.text });
if (input.images.length > 0) {
const rows = [];
for (const img of input.images) {
const buf = Buffer.from(img);
const saved = await this.store.saveImage(id, buf, 'image/png');
rows.push({
noteId: id,
kind: 'image' as const,
relPath: saved.relPath,
mime: saved.mime,
bytes: saved.bytes
});
}
this.repo.insertMedia(rows);
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
return { noteId: id };
}
async deleteNote(noteId: string): Promise<void> {
this.repo.delete(noteId);
await this.store.deleteNoteDirectory(noteId);
}
}
- Step 4: Run + commit
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): CaptureService with enqueue + celebrate hooks"
Task 15: NotificationService (post-submit reward toast)
Files: Create src/main/services/NotificationService.ts, tests/unit/NotificationService.test.ts
- Step 1: Failing tests
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
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
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
import { ipcMain } from 'electron';
import type { CaptureService } from '../services/CaptureService.js';
import type { BrowserWindow } from 'electron';
export function registerCaptureApi(
captureService: CaptureService,
getQuickCaptureWindow: () => BrowserWindow | null
): void {
ipcMain.handle('capture:submit', async (_e, payload: { text: string; images: ArrayBuffer[] }) => {
return captureService.submit(payload);
});
ipcMain.on('capture:hide', () => {
getQuickCaptureWindow()?.hide();
});
}
- Step 2: Commit
git add src/main/ipc/captureApi.ts
git commit -m "feat(ipc): capture:submit and capture:hide handlers"
Task 17: HotkeyService
Files: Create src/main/services/HotkeyService.ts
- Step 1: Implement
import { globalShortcut } from 'electron';
export interface HotkeyBinding {
accelerator: string;
onTrigger: () => void;
}
export class HotkeyService {
private registered: string[] = [];
register(binding: HotkeyBinding): { ok: boolean; reason?: string } {
const accel = binding.accelerator;
if (globalShortcut.isRegistered(accel)) {
return { ok: false, reason: `${accel} already registered by another app` };
}
const ok = globalShortcut.register(accel, binding.onTrigger);
if (!ok) return { ok: false, reason: `failed to register ${accel}` };
this.registered.push(accel);
return { ok: true };
}
unregisterAll(): void {
for (const a of this.registered) globalShortcut.unregister(a);
this.registered = [];
}
}
- Step 2: Commit
git add src/main/services/HotkeyService.ts
git commit -m "feat(hotkey): HotkeyService wrapping globalShortcut"
Task 18: QuickCaptureWindow
Files: Create src/main/windows/quickCaptureWindow.ts
- Step 1: Implement
import { BrowserWindow, screen } from 'electron';
import { join } from 'node:path';
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 createQuickCaptureWindow(): BrowserWindow {
if (win && !win.isDestroyed()) return win;
const primary = screen.getPrimaryDisplay();
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,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false
}
});
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
} else {
win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
}
win.on('blur', () => { if (win?.isVisible()) win.hide(); });
return win;
}
export function showQuickCapture(): void {
const w = createQuickCaptureWindow();
w.show(); w.focus();
}
- Step 2: Commit
git add src/main/windows/quickCaptureWindow.ts
git commit -m "feat(window): frameless QuickCaptureWindow centered on primary display"
Task 19: QuickCapture renderer (with v0.2 copy)
Files: Create src/renderer/quickcapture/index.html, main.tsx, App.tsx, api.ts
- Step 1: HTML
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Inkling Capture</title>
<style>
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; }
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; }
.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; }
.err { color: #ff6a6a; font-size: 11px; margin-top: 6px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/quickcapture/main.tsx"></script>
</body>
</html>
- Step 2:
api.ts
import type { CaptureApi } from '@shared/types';
export const captureApi: CaptureApi = window.inkling.capture;
- Step 3:
main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);
- Step 4:
App.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { captureApi } from './api.js';
interface PastedImage { url: string; buffer: ArrayBuffer; }
export function App(): React.ReactElement {
const [text, setText] = useState('');
const [images, setImages] = useState<PastedImage[]>([]);
const [err, setErr] = useState<string | null>(null);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
const submit = useCallback(async () => {
setErr(null);
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('저장에 실패했습니다. 다시 시도해주세요.'); }
}, [text, images]);
const cancel = useCallback(() => {
if (text.trim().length > 5) {
const ok = window.confirm('이 한 줄을 흘려보낼까요?');
if (!ok) return;
}
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(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [cancel, submit]);
const onPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(e.clipboardData.items);
const imgs = items.filter((i) => i.type.startsWith('image/'));
if (imgs.length === 0) return;
e.preventDefault();
for (const it of imgs) {
const blob = it.getAsFile();
if (!blob) continue;
const buffer = await blob.arrayBuffer();
const url = URL.createObjectURL(blob);
setImages((prev) => [...prev, { url, buffer }]);
}
}, []);
return (
<div className="card">
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
onPaste={onPaste}
placeholder="지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
/>
{images.length > 0 && (
<div className="thumbs">
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
</div>
)}
<div className="hint">Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기</div>
{err && <div className="err">{err}</div>}
</div>
);
}
- Step 5: Commit
git add src/renderer/quickcapture/
git commit -m "feat(quickcapture): React UI with v0.2 recovery-friendly copy"
Task 20: IntentService
Files: Create src/main/services/IntentService.ts, tests/unit/IntentService.test.ts
- Step 1: Failing tests
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
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
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
import { ipcMain, BrowserWindow } from 'electron';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
import type { IntentService } from '../services/IntentService.js';
import type { Note } from '@shared/types';
export interface InboxIpcDeps {
repo: NoteRepository;
continuity: ContinuityService;
capture: CaptureService;
health: HealthChecker;
intent: IntentService;
getInboxWindow: () => BrowserWindow | null;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string }) =>
deps.repo.list(opts)
);
ipcMain.handle(
'inbox:updateAi',
(_e, arg: { noteId: string; fields: { title?: string; summary?: string; tags?: string[] } }) => {
deps.repo.updateUserAiFields(arg.noteId, arg.fields);
}
);
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
await deps.capture.deleteNote(noteId);
});
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());
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('note:updated', note);
}
- Step 2: Commit
git add src/main/ipc/inboxApi.ts
git commit -m "feat(ipc): inbox handlers with v0.2 setIntent/dismissIntent/continuity"
Task 22: Inbox React shell + store + recovery toast helper
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: Replace
index.htmlbody
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
<title>Inkling</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
.header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: white; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 10; }
.main { max-width: 780px; margin: 0 auto; padding: 20px; }
.banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
.banner.warn { background: #fff4d6; color: #7a5a00; }
.banner.info { background: #e3f2ff; color: #0a4b80; }
.banner.recovery { background: #e9f9e4; color: #236b1a; }
.banner button { background: none; border: none; color: inherit; cursor: pointer; font-size: 13px; }
.empty { text-align: center; color: #888; padding: 60px 0; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/inbox/main.tsx"></script>
</body>
</html>
- Step 2:
api.ts
import type { InboxApi } from '@shared/types';
export const inboxApi: InboxApi = window.inkling.inbox;
- Step 3:
recoveryToast.ts
const KEY = 'inkling.recoveryDismissedAt';
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;
}
export function markRecoveryDismissed(now = new Date()): void {
localStorage.setItem(KEY, now.toISOString());
}
- Step 4: Component stubs
src/renderer/inbox/components/NoteCard.tsx:
import React from 'react';
import type { Note } from '@shared/types';
export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) {
return <div style={{ background: 'white', padding: 12, marginBottom: 10, borderRadius: 8 }}>{note.rawText}</div>;
}
src/renderer/inbox/components/EditableField.tsx:
import React, { CSSProperties } from 'react';
export function EditableField(props: {
value: string;
onSave: (next: string) => Promise<void>;
style?: CSSProperties;
singleLine?: boolean;
}): React.ReactElement {
return <div style={props.style}>{props.value}</div>;
}
src/renderer/inbox/components/IntentBanner.tsx:
import React from 'react';
export function IntentBanner(_: { noteId: string; onResolved: () => void }) { return null; }
src/renderer/inbox/components/RecoveryToast.tsx:
import React from 'react';
export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; }
src/renderer/inbox/components/ContinuityBadge.tsx:
import React from 'react';
export function ContinuityBadge() { return null; }
src/renderer/inbox/components/PendingBanner.tsx:
import React from 'react';
export function PendingBanner() { return null; }
src/renderer/inbox/components/OllamaBanner.tsx:
import React from 'react';
export function OllamaBanner() { return null; }
- Step 5: Zustand store
src/renderer/inbox/store.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<void>;
refreshMeta: () => Promise<void>;
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<InboxState>((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
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);
- Step 7:
App.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 (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<ContinuityBadge />
</div>
<main className="main">
<OllamaBanner />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
{loading && notes.length === 0 ? (
<div className="empty">불러오는 중…</div>
) : notes.length === 0 ? (
<div className="empty">첫 기억을 구출해보세요. <code>Ctrl+Shift+J</code></div>
) : (
notes.map((n) => (
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
))
)}
</main>
</>
);
}
- Step 8: Commit
git add src/renderer/inbox/
git commit -m "feat(inbox): React shell + store + component stubs (v0.2)"
Task 23: ContinuityBadge
Files: Replace src/renderer/inbox/components/ContinuityBadge.tsx
- Step 1: Implement
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 <div style={{ fontSize: 13, color: '#666' }}>이번 주 한 줄이면 시작입니다</div>;
}
if (c.weekCount < c.weekTarget) {
return (
<div style={{ fontSize: 13, color: '#444' }}>
이번 주 <b>{c.weekCount}/{c.weekTarget}</b>
</div>
);
}
return (
<div style={{ fontSize: 13, color: '#236b1a' }}>
이번 주 <b>{c.weekCount}/{c.weekTarget}</b> ✓
{c.consecutiveCompleteWeeks > 0 && (
<span style={{ color: '#888' }}> · 연속 {c.consecutiveCompleteWeeks}주 완성</span>
)}
</div>
);
}
- Step 2: Commit
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
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 (
<div className="banner info">
<span>🟡 Inkling이 정리하는 중: <b>{count}</b>건</span>
</div>
);
}
- Step 2: Commit
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
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;
}
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 [local, setLocal] = useState(note);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function handleDelete() {
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
await inboxApi.deleteNote(note.id);
onDeleted();
}
async function saveTitle(next: string) {
await inboxApi.updateAiFields(note.id, { title: next });
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function saveSummary(next: string) {
await inboxApi.updateAiFields(note.id, { summary: next });
const updated = { ...local, aiSummary: next, summaryEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function removeTag(tagName: string) {
const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
await inboxApi.updateAiFields(note.id, { tags: next });
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 (
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{showIntentBanner && (
<IntentBanner
noteId={note.id}
onResolved={(intentText) => {
const now = new Date().toISOString();
const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now };
setLocal(updated); onUpdated(updated);
}}
/>
)}
{local.aiStatus === 'pending' && (
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
Inkling이 정리하는 중…
</div>
)}
{local.aiStatus === 'failed' && (
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
정리 보류 — 원문은 안전합니다
</div>
)}
{local.aiStatus === 'done' && (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
onClick={() => 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'
}}
title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
)}
</>
)}
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
))}
</div>
)}
<div style={{ marginTop: 10 }}>
<button onClick={() => setRawOpen((o) => !o)} style={{ background: 'none', border: 'none', color: '#555', fontSize: 12, cursor: 'pointer', padding: 0 }}>
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
</button>
{rawOpen && (
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
)}
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑 삭제
</button>
</div>
</div>
);
}
- Step 2: Commit
git add src/renderer/inbox/components/NoteCard.tsx
git commit -m "feat(inbox): NoteCard with AI proposal labels, intent badge, IntentBanner slot"
Task 26: EditableField
Files: Replace src/renderer/inbox/components/EditableField.tsx (stub from Task 22)
- Step 1: Implement
import React, { useEffect, useRef, useState, CSSProperties } from 'react';
interface Props {
value: string;
onSave: (next: string) => Promise<void>;
style?: CSSProperties;
singleLine?: boolean;
}
export function EditableField({
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<HTMLTextAreaElement | HTMLInputElement>(null);
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 (!editing) {
return (
<span
onClick={() => setEditing(true)}
style={{ ...style, cursor: 'text', outline: error ? '1px solid #c93030' : 'none' }}
>
{value || <em style={{ color: '#bbb' }}>(비어 있음)</em>}
</span>
);
}
if (singleLine) {
return (
<input
ref={ref as React.RefObject<HTMLInputElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') { setDraft(value); setEditing(false); }
}}
style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 2 }}
/>
);
}
return (
<textarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Escape') { setDraft(value); setEditing(false); }
}}
style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 4, width: '100%', minHeight: 60 }}
/>
);
}
- Step 2: Commit
git add src/renderer/inbox/components/EditableField.tsx
git commit -m "feat(inbox): EditableField with blur-save, enter-commit, esc-cancel"
Task 27: IntentBanner component
Files: Replace src/renderer/inbox/components/IntentBanner.tsx. Create src/shared/intentPrompts.ts.
- Step 1: Prompt constants
src/shared/intentPrompts.ts:
export const INTENT_PROMPTS = [
'내일의 내가 이 메모에서 꼭 알아야 할 것은?',
'이 메모가 중요한 이유를 한 줄로?',
'이 문제를 다시 만나면 무엇을 먼저 확인할까요?',
'동료에게 공유한다면 제목을 뭐라고?'
] as const;
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: Implement IntentBanner
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]);
return (
<div style={{ marginTop: 8, padding: 10, background: '#fff8d6', borderRadius: 8, border: '1px solid #f1d97c' }}>
<div style={{ fontSize: 12, color: '#7a5a00', marginBottom: 6 }}>💭 {prompt}</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
value={draft}
onChange={(e) => 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}
/>
<button onClick={() => void submit()} disabled={busy || draft.trim().length === 0}
style={{ background: '#f1d97c', border: 'none', borderRadius: 4, padding: '4px 10px', cursor: 'pointer', fontSize: 12 }}>
저장
</button>
<button onClick={() => void skip()} disabled={busy}
style={{ background: 'transparent', border: 'none', color: '#7a5a00', cursor: 'pointer', fontSize: 12 }}>
건너뛰기
</button>
</div>
</div>
);
}
- Step 3: Commit
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 28: RecoveryToast component
Files: Replace src/renderer/inbox/components/RecoveryToast.tsx
- Step 1: Implement
import React from 'react';
interface Props {
show: boolean;
onDismiss: () => void;
}
export function RecoveryToast({ show, onDismiss }: Props): React.ReactElement | null {
if (!show) return null;
return (
<div className="banner recovery">
<span>🌱 흐름을 다시 이어갑니다</span>
<button onClick={onDismiss} aria-label="닫기">✕</button>
</div>
);
}
- Step 2: Commit
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
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
export class HealthChecker {
private last: HealthResult = { ok: true };
constructor(private provider: InferenceProvider) {}
async runOnce(): Promise<HealthResult> {
this.last = await this.provider.healthCheck();
return this.last;
}
lastStatus(): HealthResult { return this.last; }
}
- Step 2: OllamaBanner
import React from 'react';
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:e4b` 실행 후 앱을 재시작해주세요.'
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
return (
<div className="banner warn">
<span>⚠ {message}</span>
</div>
);
}
- Step 3: Commit
git add src/main/services/HealthChecker.ts src/renderer/inbox/components/OllamaBanner.tsx
git commit -m "feat(health): HealthChecker + OllamaBanner v0.2 copy"
Task 30: Tray + main wiring with NotificationService + IntentService
Files: Create src/main/tray.ts. Replace src/main/index.ts.
- Step 1: Tray with v0.2 copy
import { app, Tray, Menu, nativeImage } from 'electron';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
let tray: Tray | null = null;
const __dirname = fileURLToPath(new URL('.', import.meta.url));
export function createTray(showInbox: () => void, showCapture: () => void): Tray {
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip('Inkling');
const menu = Menu.buildFromTemplate([
{ label: '구출한 메모 보기', click: showInbox },
{ label: '기억 구출하기', click: showCapture },
{ type: 'separator' },
{ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }
]);
tray.setContextMenu(menu);
tray.on('click', showInbox);
return tray;
}
- Step 2:
src/main/index.tsfinal wiring
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 { 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 { createTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
app.whenReady().then(async () => {
initLogger();
logger.info('app.start', { platform: process.platform, version: app.getVersion() });
const paths = resolveProfilePaths('default');
const db = openDb(paths.dbFile);
const repo = new NoteRepository(db);
const store = new MediaStore(paths.profileDir);
const continuity = new ContinuityService(db);
const intent = new IntentService(repo);
const provider = new LocalOllamaProvider({
endpoint: process.env.INKLING_OLLAMA_ENDPOINT
});
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', h));
const worker = new AiWorker(repo, provider, {
onUpdate: (note) => pushNoteUpdated(getInboxWindow, note),
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),
celebrate: (id) => notify.celebrate(id)
});
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow
});
const hotkeys = new HotkeyService();
const reg = hotkeys.register({
accelerator: process.platform === 'darwin' ? 'Cmd+Shift+J' : 'Ctrl+Shift+J',
onTrigger: () => showQuickCapture()
});
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
createInboxWindow();
createQuickCaptureWindow();
createTray(
() => createInboxWindow(),
() => showQuickCapture()
);
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; });
- Step 3: Verify dev
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
git add src/main/tray.ts src/main/index.ts
git commit -m "feat(app): wire NotificationService, IntentService, ContinuityService into main"
Task 31: MediaGc
Files: Create src/main/services/MediaGc.ts
- Step 1: Implement
import type Database from 'better-sqlite3';
import type { MediaStore } from './MediaStore.js';
export class MediaGc {
constructor(private db: Database.Database, private store: MediaStore) {}
async run(): Promise<{ removed: number }> {
const dirs = await this.store.listNoteDirs();
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
const known = new Set(rows.map((r) => r.id));
let removed = 0;
for (const d of dirs) {
if (!known.has(d)) { await this.store.deleteNoteDirectory(d); removed += 1; }
}
return { removed };
}
}
- Step 2: Commit
git add src/main/services/MediaGc.ts
git commit -m "feat(media): MediaGc for orphan dir cleanup on startup"
Task 32: E2E smoke test
Files: Create playwright.config.ts, tests/e2e/smoke.spec.ts
- Step 1: Playwright config
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 60_000,
retries: 0,
workers: 1,
reporter: 'list'
});
- Step 2: Smoke test
import { test, expect, _electron as electron } from '@playwright/test';
import { resolve } from 'node:path';
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' }
});
const inbox = await app.firstWindow();
await inbox.waitForLoadState('domcontentloaded');
await expect(inbox.getByText('Inkling')).toBeVisible();
await expect(inbox.getByText('첫 기억을 구출해보세요.')).toBeVisible();
await app.close();
});
- Step 3: Build + run
npm run build
npx playwright install chromium
npm run test:e2e
- Step 4: Commit
git add playwright.config.ts tests/e2e/smoke.spec.ts
git commit -m "test(e2e): smoke test verifying v0.2 inbox empty state"
Task 33: Full verification pass
- Step 1: Run unit tests
npm test → all pass.
- Step 2: Typecheck
npm run typecheck → exit 0.
- Step 3: Manual dogfood checklist (Strategy + base flows)
Ensure gemma4:e4b is reachable at the endpoint defined by INKLING_OLLAMA_ENDPOINT (or http://localhost:11434 if unset). Then:
-
Press
Ctrl+Shift+Jfrom 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
donewith 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 with "Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요." -
Restart Ollama and app → pending jobs resume and complete.
-
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: If all green, tag
git tag v0.2.0-slice
Notes for the Engineer
- 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, oruser_intentcontent. Only IDs, lengths, and hash prefixes. - Original preservation:
NoteRepositoryhas no method to mutateraw_text. AI re-runs do not overwrite user-edited title/summary (guarded by*_edited_by_usercolumns). - 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
localStoragekeyed to KST date. Re-shown on a new recovery event (different date). - Versions: if
npm installsurfaces a newer stable major than spec §7.2, update bothpackage.jsonand the spec in the same PR.