224 Commits

Author SHA1 Message Date
altair823
4ee135dcd6 feat(ai): zod due_date field + prompt {{TODAY_KST}} injection
AiResponse extends with dueDate: string|null. zod regex
^\d{4}-\d{2}-\d{2}$, follow-up roundtrip check coerces invalid
dates (2026-13-99 etc.) to null. PROMPT_VERSION → 2: prompt now
takes todayKst arg, asks model to extract due_date as ISO or null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:12:45 +09:00
altair823
95ba1653d7 feat(due-date): pure rule parser for Korean date expressions
Regex + KST math, returns ISO YYYY-MM-DD or null. 14 high-confidence
rules (literal date, N월 N일, MM/DD, N일/주/개월 뒤, 모레/내일/글피/오늘,
다음/이번 주 X요일, 다음 달). Ambiguous tokens (월말, 주말, 퇴근 전,
시각) return iso=null + confidence='medium' so caller (AiWorker)
can defer to AI. 26+ golden fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:09:51 +09:00
altair823
0bb6c12bbb feat(db): migration v2 — due_date columns + pre-migration snapshot
ALTER TABLE notes adds due_date TEXT + due_date_edited_by_user INTEGER.
openDb takes <dbFile>.pre-v<N>.bak before running migrations
(F6-L1 follow-up #4 — preserves recoverable state if migration fails).
NoteRepository: updateAiResult accepts dueDate?, setDueDate +
edited-flag CASE WHEN guard mirroring title/summary pattern.
Note interface gains dueDate + dueDateEditedByUser fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:05:44 +09:00
altair823
d76cca68df feat(import): ImportService with conflict policy + media copy
Three-state outcome per note: inserted (new id), skipped (id+rawText
match), forked (id match but rawText differs → new uuidv7 to preserve
raw_text invariant from slice §1.1). Media files copied into
MediaStore convention <profileDir>/media/{noteId}/{n}.{ext} with
new media DB rows.

NoteRepository.importNote handles full provenance: ai_status='done',
ai_provider, ai_generated_at, edited flags, intent fields, tags
with source preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:55:13 +09:00
altair823
e8587c1986 feat(import): pure parser for F5 export format
parseExportNote reverses composeMarkdown — minimal YAML parser
covering only the variants F5 emits (plain, single-quoted, block
scalar, tag/image lists). Body extraction strips h1 + blockquote +
image refs to recover rawText. Round-trip tested against
exportFormat.composeMarkdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:53:29 +09:00
altair823
9fdfd6610c feat(export): ExportService writing frontmatter tree + media + manifest
ExportService composes pure exportFormat layer + reads from
NoteRepository.listAll (new, asc-ordered) + MediaStore.absolutePath
(new helper). Writes notes/{date-id8-slug.md}, media/{id8__n.ext},
index.jsonl, manifest.json, README.md to user-picked dir.
6 unit tests against tmp dirs + :memory: DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:42:43 +09:00
altair823
8e09464d5e feat(export): pure frontmatter + slug + markdown + jsonl + manifest composers
Pure compose layer for F5 (Export). slugifyTitle, composeFilename,
composeFrontmatter, composeMarkdown, composeIndexJsonl, composeManifest
+ ExportNote/ExportNoteMedia/ExportNoteTag types. No fs deps.
24 unit tests covering normal cases + edge cases (null title,
forbidden chars, multiline summary needing block scalar, colon
needing single-quote, image numbering by id8__n.ext).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:39:32 +09:00
altair823
4898e13308 feat(backup): runDaily() with .last-snapshot marker + rotate after snapshot
Skips when marker matches today's KST date. Marker written after
successful snapshot, before rotation. lastSnapshotAt() exposed for UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:08:30 +09:00
altair823
a728434b2e fix(backup): cleanup orphan .tmp on db.backup() failure + concurrency note
Code review I1: wrap snapshot's backup+rename in try/catch, unlink
orphan tmp file on failure so the next run does not encounter a
confusing 'existing file is not a database' error from sqlite.

Code review I2: JSDoc note that snapshot() is not safe for concurrent
calls — callers should serialize via runDaily()'s marker.

New unit test injects a fake db whose backup() rejects after a partial
write, asserts no .tmp / .sqlite remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:49:01 +09:00
altair823
714dd3fc9f feat(backup): atomic SQLite snapshot to inkling-YYYY-MM-DD.sqlite
KST date filename, tmp+rename atomic write, mkdir on demand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:44:28 +09:00
altair823
603588cc4f chore(backup): polish — boundary test, roundtrip lock-in, precompute today
Code reviewer minor nitpicks:
- Add test for inkling-2026-02-30.sqlite (locks roundtrip-validation contract)
- Add test for weekly window inclusive at oldest boundary
- Precompute today=startOfDayUtc(now) once outside the loop, pass to helpers

No behavior change; tests added cover existing semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:47 +09:00
altair823
5e8e652ee0 feat(backup): GFS retention policy (pure)
14 daily + 4 weekly (Mondays) + 6 monthly (1st). Future-dated files
preserved (clock skew). Unrecognized filenames ignored (no delete).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:07:09 +09:00
altair823
c7e9463adb fix(e2e): rebuild better-sqlite3 per ABI + select inbox window by title
- Vitest needs the node-ABI prebuilt sqlite binary; Electron 41 needs ABI 145.
  Add `rebuild:node` / `rebuild:electron` scripts and wire them as
  pre-hooks for `test`, `test:e2e`, `start`, `dev`, `test:integration`.
- Smoke test was racing on `firstWindow()`, which non-deterministically
  returned the quickcapture window. Strip ELECTRON_RUN_AS_NODE from the
  launch env, wait for both windows to register, then pick the inbox by
  title and assert the heading via `getByRole`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:05:15 +09:00
altair823
94fc7c72d3 test(e2e): smoke test verifying v0.2 inbox empty state
Task 32 of the slice plan. playwright.config.ts (testDir
tests/e2e, single worker, 60s default timeout, list reporter)
and a smoke spec that launches the built main bundle, waits
for the inbox renderer, and asserts the v0.2 empty-state copy
'첫 기억을 구출해보세요.' is rendered.

Build verified end-to-end (`npm run build` exits 0, produces
out/main/index.js + out/preload/index.mjs + the inbox /
quickcapture renderer chunks). Playwright's chromium bundle
installed.

Known issue (deferred to a follow-up debug session): on this
specific Windows machine, electron@41.3.0's main-process
module hook is not injecting the real `electron` API into the
launched bundle. `require('electron')` from the entry script
returns the on-disk path string (the package's index.js
default), and `process.electronBinding` / `process.type` are
undefined inside the spawned process. This reproduces with a
minimal package.json + main.js outside this repo — i.e. it's
not a slice bug. The smoke test currently fails with
'Process failed to launch!' at the
`app.whenReady().then(...)` line because `app` is undefined.

When the host issue is sorted, no test changes should be
required: build, config, and spec are correct. Likely fixes:
clean-reinstall electron + node-modules, try electron@latest,
or invoke through `npx electron` rather than the binary
directly. Captured in project_inkling_status memory under
'open issues'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:32:42 +09:00
altair823
dc88da0bee feat(intent): IntentService for set/dismiss with validation
Task 20 of the slice plan. Thin wrapper over the repo's intent
methods that adds two preconditions: setIntent rejects empty
trimmed text, both methods throw "note not found" when the
note id doesn't exist. Repo-level COALESCE on
intent_prompted_at preserves the first-prompt invariant
(spec §3.3); IntentService's job is just input guarding so
the IPC handler stays a one-liner.

Verification: `npx vitest run tests/unit/IntentService.test.ts`
4 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:14:31 +09:00
altair823
c9ccf6433f feat(notify): NotificationService with 4 rotating reward copies
Task 15 of the slice plan. Strategy §4.1 immediate-reward
toast. celebrate(noteId) deterministically picks one of the
4 reward copies via SHA-256(noteId)[0] % 4, then forwards to
the injected send() callback (which Task 30 wires to a real
electron Notification). Skips silently when isSupported() is
false (denied OS permission), and swallows send() errors so
that capture path never fails because of a notification quirk.

Verification: `npx vitest run tests/unit/NotificationService.test.ts`
3 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:11:44 +09:00
altair823
38a54a83b8 feat(capture): CaptureService with enqueue + celebrate hooks
Task 14 of the slice plan. Orchestrates a Quick Capture submit:
trims/validates input, creates the note row + pending_jobs row
in one repo.create transaction, persists each pasted PNG via
MediaStore (relPath stored in media table), then awaits the
injected enqueue() (AiWorker.enqueue at runtime) and fires
celebrate() (NotificationService.celebrate). deleteNote drops
the db row (cascading note_tags / media / pending_jobs) and
removes the on-disk media directory afterwards.

Verification: `npx vitest run tests/unit/CaptureService.test.ts`
4 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:11:43 +09:00
altair823
0c38fcaf85 feat(ai): AiWorker with sequential queue, 3-attempt backoff
Task 13 of the slice plan. Drives the pending → done/failed
transitions:
- enqueue() pushes a Job and kicks the loop; loadFromDb()
  rehydrates pending_jobs at startup so app restart resumes
  in-flight work.
- drain() exposes a Promise for tests + graceful shutdown.
- Concurrency 1: a single async loop awaits each provider call
  before the next, matching spec §2.2.
- 3-attempt backoff (default [0, 30s, 120s]; tests inject [0,0,0]).
  Each failure logs ai.retry, increments pending_jobs.attempts,
  and on the final attempt calls markAiFailed and emits onUpdate.
- emit() pushes the freshly-hydrated note to onUpdate (used by
  Task 30 to fan out IPC note:updated events).

Verification: `npx vitest run tests/unit/AiWorker.test.ts`
4 passed. Suite total 41 / 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:10:15 +09:00
altair823
095413ed92 feat(ai): LocalOllamaProvider with 120s timeout + integration harness
Task 12 of the slice plan. Implements the slice's only provider:
- generate(): POST {endpoint}/api/generate with Korean-first
  prompt; AbortController-driven 120s timeout; parses
  body.response as JSON and runs it through parseAiResponse.
- healthCheck(): GET /api/tags; returns ok when configured model
  is in the listing, otherwise reports the missing-model reason.
- Constructor takes opts.endpoint / opts.model so Task 30 main
  entry can inject INKLING_OLLAMA_ENDPOINT for LAN dogfood;
  defaults are http://localhost:11434 and gemma4:e4b.

Tests: 6 unit cases via undici MockAgent (parse, non-JSON,
timeout abort, healthCheck ok / missing / connection error).
Integration test gated by INKLING_INTEGRATION=1 hits real
Ollama for Korean / English-stack / mixed input cases with a
180s per-test budget — also reads INKLING_OLLAMA_ENDPOINT so
LAN dogfood can be exercised end-to-end.

Plan deviation: undici@8 types treat MockAgent's `.reply()`
callback as sync-return-only, so the 500ms-delayed reply used
in the timeout test is cast `as never` to bypass the overload
mismatch. Behavior is correct at runtime; the cast is local to
the test.

Verification: `npx vitest run tests/unit/LocalOllamaProvider.test.ts`
6 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:10:15 +09:00
altair823
e0713c94e6 feat(ai): zod schema validator + Korean-first prompt
Task 10 of the slice plan. parseAiResponse runs the model JSON
through a zod RawResponseSchema, then enforces slice rules:
- title MUST contain Korean characters; throw otherwise.
- summary normalized to exactly 3 lines (pad with empty when too
  few; collapse trailing lines into line 3 when too many).
- tags filtered to /^[a-z0-9]+(-[a-z0-9]+)*$/ kebab-case and
  capped at 3.
buildPrompt assembles the user-facing prompt with explicit
Korean-output / kebab-tag / no-fence rules (PROMPT_VERSION=1).

Verification: `npx vitest run tests/unit/ai-schema.test.ts`
7 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:10:14 +09:00
altair823
c0ee8c0981 feat(continuity): WeeklyContinuity service (7 notes/week, recovery detection)
Task 9 of the slice plan. Computes the data behind the Inbox
ContinuityBadge and recovery toast (Strategy §5):
- KST Mon-Sun grouping via toKstDateKey/kstMondayOf helpers
  (UTC + 9h shift, then bucket to local Monday).
- weekCount + weekTarget=7 + consecutiveCompleteWeeks (walk
  backward from current week, or previous if current incomplete,
  counting only weeks with >=7 notes).
- showRecoveryToast=true when the latest note lands on today
  (KST) and the prior note is at least 7 days earlier — this is
  the "흐름을 다시 이어갑니다" trigger.
- now() injected for deterministic tests.

Verification: `npx vitest run tests/unit/ContinuityService.test.ts`
6 passed. All 24 unit tests across migrations / NoteRepository /
MediaStore / ContinuityService green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:06:45 +09:00
altair823
9c47ff659f feat(media): MediaStore for image persistence and cleanup
Task 8 of the slice plan. Saves clipboard PNG/JPEG bytes under
{profileDir}/media/{noteId}/{uuid}.{ext} with mkdir -p semantics,
returns relPath/mime/bytes for the repository row, supports
deleteNoteDirectory (used by note delete + GC) and listNoteDirs
(used by media GC to find orphans).

Verification: `npx vitest run tests/unit/MediaStore.test.ts`
4 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:06:45 +09:00
altair823
797d97c392 feat(repo): NoteRepository with intent, edited flags, AI overwrite guard
Task 7 of the slice plan. Implements the full repository surface
backing every IPC inbox/capture path: create (UUID v7 + atomic
notes + pending_jobs insert), insertMedia, findById/list,
updateAiResult (CASE WHEN guard against title/summary
overwrite when *_edited_by_user flips), markAiFailed (truncates
ai_error to 500 chars + clears pending job), updateUserAiFields
(sets edited flags as a side effect, replaces user-source tags),
setIntent + dismissIntent (intent_prompted_at uses COALESCE so
the first stamp wins), delete, getPendingCount,
getAllPendingJobs, incrementJobAttempt, and a private hydrate
that joins notes with note_tags + media.

Plan deviation: list/list-with-cursor query gets a secondary
"id DESC" tiebreaker. Two notes created in the same millisecond
shared created_at and reordered nondeterministically; UUID v7
sorts monotonically with creation order, so id DESC restores
"newest first" within ties.

Verification: `npx vitest run tests/unit/NoteRepository.test.ts`
12 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:06:45 +09:00
altair823
114e971518 feat(db): v1 schema with user_intent + edited flags
Task 6 of the slice plan. Adds the initial database surface:
- m001_initial: notes (with v0.2 columns user_intent,
  intent_prompted_at, title_edited_by_user,
  summary_edited_by_user), tags, note_tags (with source ai/user),
  media, pending_jobs.
- migrations/index: forward-only PRAGMA user_version runner that
  applies pending migrations inside a single transaction.
- db/index: openDb() that opens better-sqlite3, enables WAL +
  foreign_keys, then runs migrations.
- migrations.test: schema columns are present at v1; runMigrations
  is idempotent.

Verification: `npx vitest run tests/unit/migrations.test.ts`
2 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:02:04 +09:00