feat: ship v0.8.0 with writing logs and AI knowledge extraction

Deliver P5.2 writing day logs, cockpit heatmap, streak tracking, and chapter knowledge auto-extraction with merge review.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 18:26:29 +08:00
parent f331ddae86
commit a39e06ff07
51 changed files with 1436 additions and 89 deletions
+11 -1
View File
@@ -5,8 +5,9 @@ import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw'
const CURRENT_VERSION = 6
const CURRENT_VERSION = 7
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -71,5 +72,14 @@ export function migrate(db: SqliteDb): void {
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
current = 6
}
if (current < 7) {
db.exec(schemaV7)
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_confidence REAL')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_id TEXT')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(7, 'P5.2 writing logs extraction')
}
}
@@ -0,0 +1,35 @@
import type { SqliteDb } from '../types'
export interface ChapterWritingSnapshot {
loggedWordCount: number
finishSupplemented: boolean
}
export class ChapterWritingSnapshotRepository {
constructor(private db: SqliteDb) {}
get(chapterId: string): ChapterWritingSnapshot | null {
const row = this.db
.prepare(
'SELECT logged_word_count, finish_supplemented FROM chapter_writing_snapshots WHERE chapter_id = ?'
)
.get(chapterId) as { logged_word_count: number; finish_supplemented: number } | undefined
if (!row) return null
return {
loggedWordCount: row.logged_word_count,
finishSupplemented: row.finish_supplemented === 1
}
}
upsert(chapterId: string, loggedWordCount: number, finishSupplemented = false): void {
this.db
.prepare(
`INSERT INTO chapter_writing_snapshots (chapter_id, logged_word_count, finish_supplemented)
VALUES (?, ?, ?)
ON CONFLICT(chapter_id) DO UPDATE SET
logged_word_count = excluded.logged_word_count,
finish_supplemented = excluded.finish_supplemented`
)
.run(chapterId, loggedWordCount, finishSupplemented ? 1 : 0)
}
}
@@ -20,6 +20,9 @@ function mapRow(row: Record<string, unknown>): KnowledgeEntry {
sourceChapterId: (row.source_chapter_id as string) || undefined,
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
linkedBookmarkId: (row.linked_bookmark_id as string) || undefined,
extractionConfidence: (row.extraction_confidence as number) ?? undefined,
mergeTargetId: (row.merge_target_id as string) || undefined,
extractionBatchId: (row.extraction_batch_id as string) || undefined,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
@@ -53,6 +56,41 @@ export class KnowledgeRepository {
return this.get(id)!
}
createExtracted(
input: CreateKnowledgeInput & {
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
}
): KnowledgeEntry {
const id = randomUUID()
const status = input.status ?? 'pending'
this.db
.prepare(
`INSERT INTO knowledge_entries (
id, type, title, content, importance, status, foreshadow_status,
source_chapter_id, last_mention_chapter_id, linked_bookmark_id,
extraction_confidence, merge_target_id, extraction_batch_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.type,
input.title,
input.content ?? '',
input.importance ?? 3,
status,
input.foreshadowStatus ?? null,
input.sourceChapterId ?? null,
input.lastMentionChapterId ?? null,
input.linkedBookmarkId ?? null,
input.extractionConfidence ?? null,
input.mergeTargetId ?? null,
input.extractionBatchId ?? null
)
return this.get(id)!
}
get(id: string): KnowledgeEntry | null {
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
| Record<string, unknown>
@@ -101,6 +139,15 @@ export class KnowledgeRepository {
return this.get(id)!
}
clearMergeTarget(id: string): KnowledgeEntry {
this.db
.prepare(
`UPDATE knowledge_entries SET merge_target_id = NULL, updated_at = datetime('now') WHERE id = ?`
)
.run(id)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
}
@@ -111,4 +158,20 @@ export class KnowledgeRepository {
.get(status) as { c: number }
return row.c
}
batchApproveHighConfidence(threshold: number): number {
const rows = this.db
.prepare(
`SELECT id FROM knowledge_entries
WHERE status = 'pending' AND merge_target_id IS NULL
AND extraction_confidence IS NOT NULL AND extraction_confidence >= ?`
)
.all(threshold) as { id: string }[]
let count = 0
for (const { id } of rows) {
this.update(id, { status: 'approved' })
count++
}
return count
}
}
@@ -0,0 +1,78 @@
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { DatabaseSync } from 'node:sqlite'
import type { WritingDayLog } from '../../../shared/types'
import { addDays, localDateString } from '../../services/writing-date.util'
export class WritingLogRepository {
private db: DatabaseSync
constructor(userDataDir: string) {
if (!existsSync(userDataDir)) mkdirSync(userDataDir, { recursive: true })
this.db = new DatabaseSync(join(userDataDir, 'writing_logs.sqlite'))
this.migrate()
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS writing_logs (
date TEXT NOT NULL,
book_id TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
)
`)
}
addToday(bookId: string, delta: number, date = localDateString()): number {
const row = this.db
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
.get(date, bookId) as { word_count: number } | undefined
const next = Math.max(0, (row?.word_count ?? 0) + delta)
this.db
.prepare(
`INSERT INTO writing_logs (date, book_id, word_count) VALUES (?, ?, ?)
ON CONFLICT(date, book_id) DO UPDATE SET word_count = excluded.word_count`
)
.run(date, bookId, next)
return next
}
getToday(bookId: string, date = localDateString()): number {
const row = this.db
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
.get(date, bookId) as { word_count: number } | undefined
return row?.word_count ?? 0
}
getDay(bookId: string, date: string): number {
return this.getToday(bookId, date)
}
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[] {
const rows = this.db
.prepare(
`SELECT date, book_id, word_count FROM writing_logs
WHERE book_id = ? AND date >= ? AND date <= ?
ORDER BY date ASC`
)
.all(bookId, fromDate, toDate) as Array<{ date: string; book_id: string; word_count: number }>
return rows.map((r) => ({ date: r.date, bookId: r.book_id, wordCount: r.word_count }))
}
buildHeatmap(bookId: string, days: number): WritingDayLog[] {
const today = localDateString()
const from = addDays(today, -(days - 1))
const existing = new Map(this.listRange(bookId, from, today).map((l) => [l.date, l.wordCount]))
const result: WritingDayLog[] = []
for (let i = 0; i < days; i++) {
const date = addDays(from, i)
result.push({ date, bookId, wordCount: existing.get(date) ?? 0 })
}
return result
}
close(): void {
this.db.close()
}
}
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS chapter_writing_snapshots (
chapter_id TEXT PRIMARY KEY,
logged_word_count INTEGER NOT NULL DEFAULT 0,
finish_supplemented INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);