| 1 | import path from 'path' |
| 2 | import fs from 'fs' |
| 3 | import { nanoid } from 'nanoid' |
| 4 | import log from 'electron-log/main.js' |
| 5 | import { normalizeThinkingAssistantReply, normalizeThinkingMessages } from './reply-normalizer' |
| 6 | import type { |
| 7 | ThinkingChatMessage, |
| 8 | ThinkingPageOutlineUpdate, |
| 9 | ThinkingStage, |
| 10 | ThinkingSource, |
| 11 | ThinkingWorkspace, |
| 12 | ThinkingWorkspaceListItem |
| 13 | } from '@shared/thinking' |
| 14 | |
| 15 | const THINKING_ID_RE = /^[a-zA-Z0-9_-]{6,32}$/ |
| 16 | |
| 17 | export function assertValidThinkingId(id: string): void { |
| 18 | if (!THINKING_ID_RE.test(id)) { |
| 19 | throw new Error(`Invalid thinkingId: ${id}`) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | export function resolveThinkingDir(storagePath: string, thinkingId: string): string { |
| 24 | return path.join(storagePath, 'thinking', thinkingId) |
| 25 | } |
| 26 | |
| 27 | export function buildInitialThinkingMd(): string { |
| 28 | return `# Thinking Brief |
| 29 | |
| 30 | ## Topic |
| 31 | |
| 32 | ## Audience |
| 33 | |
| 34 | ## Setting |
| 35 | |
| 36 | ## Tone |
| 37 | |
| 38 | ## Style |
| 39 | |
| 40 | ## Font |
| 41 | auto |
| 42 | |
| 43 | ## Page Count |
| 44 | 0 |
| 45 | ` |
| 46 | } |
| 47 | |
| 48 | export function buildInitialContextMd(stage: ThinkingStage = 'collect'): string { |
| 49 | return `## Stage: collect |
| 50 | |
| 51 | ## User Intent |
| 52 | |
| 53 | ## Confirmed Decisions |
| 54 | |
| 55 | ## Open Questions |
| 56 | |
| 57 | ## Created: ${new Date().toISOString()} |
| 58 | `.replace(/^## Stage:\s*collect/m, `## Stage: ${stage}`) |
| 59 | } |
| 60 | |
| 61 | export async function createWorkspace(storagePath: string): Promise<ThinkingWorkspace> { |
| 62 | const thinkingId = nanoid() |
| 63 | const dir = resolveThinkingDir(storagePath, thinkingId) |
| 64 | const sourcesDir = path.join(dir, 'sources') |
| 65 | const assetsDir = path.join(dir, 'assets') |
| 66 | |
| 67 | await fs.promises.mkdir(sourcesDir, { recursive: true }) |
| 68 | await fs.promises.mkdir(assetsDir, { recursive: true }) |
| 69 | |
| 70 | const thinkingMd = buildInitialThinkingMd() |
| 71 | const contextMd = buildInitialContextMd('collect') |
| 72 | |
| 73 | const thinkingMdPath = path.join(dir, 'thinking.md') |
| 74 | const contextMdPath = path.join(dir, 'context.md') |
| 75 | |
| 76 | await fs.promises.writeFile(thinkingMdPath, thinkingMd, 'utf-8') |
| 77 | await fs.promises.writeFile(contextMdPath, contextMd, 'utf-8') |
| 78 | |
| 79 | log.info(`[thinking] workspace created: ${thinkingId}`) |
| 80 | |
| 81 | return { |
| 82 | thinkingId, |
| 83 | thinkingMd, |
| 84 | contextMd, |
| 85 | stage: 'collect', |
| 86 | sources: [], |
| 87 | messages: [] |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | export async function readWorkspace( |
| 92 | storagePath: string, |
| 93 | thinkingId: string |
| 94 | ): Promise<ThinkingWorkspace> { |
| 95 | assertValidThinkingId(thinkingId) |
| 96 | const dir = resolveThinkingDir(storagePath, thinkingId) |
| 97 | |
| 98 | const thinkingMdPath = path.join(dir, 'thinking.md') |
| 99 | const contextMdPath = path.join(dir, 'context.md') |
| 100 | |
| 101 | if (!fs.existsSync(thinkingMdPath)) { |
| 102 | throw new Error(`Thinking workspace not found: ${thinkingId}`) |
| 103 | } |
| 104 | |
| 105 | const [thinkingMd, contextMd] = await Promise.all([ |
| 106 | fs.promises.readFile(thinkingMdPath, 'utf-8'), |
| 107 | fs.promises.readFile(contextMdPath, 'utf-8') |
| 108 | ]) |
| 109 | |
| 110 | const stage = parseStageFromContextMd(contextMd) |
| 111 | const [sources, messages] = await Promise.all([parseSourcesList(dir), readMessagesList(dir)]) |
| 112 | |
| 113 | return { thinkingId, thinkingMd, contextMd, stage, sources, messages } |
| 114 | } |
| 115 | |
| 116 | export async function deleteWorkspace(storagePath: string, thinkingId: string): Promise<void> { |
| 117 | await readWorkspace(storagePath, thinkingId) |
| 118 | const dir = resolveThinkingDir(storagePath, thinkingId) |
| 119 | await fs.promises.rm(dir, { recursive: true, force: true }) |
| 120 | log.info(`[thinking] workspace deleted: ${thinkingId}`) |
| 121 | } |
| 122 | |
| 123 | export async function writeThinkingMd(dir: string, content: string): Promise<void> { |
| 124 | const filePath = path.join(dir, 'thinking.md') |
| 125 | await fs.promises.writeFile(filePath, content, 'utf-8') |
| 126 | } |
| 127 | |
| 128 | function normalizeSingleLine(value: string): string { |
| 129 | return value.trim().replace(/\s+/g, ' ') |
| 130 | } |
| 131 | |
| 132 | export function replaceThinkingPageOutline( |
| 133 | thinkingMd: string, |
| 134 | update: ThinkingPageOutlineUpdate |
| 135 | ): string { |
| 136 | const pageNumber = Math.floor(Number(update.pageNumber)) |
| 137 | const title = normalizeSingleLine(update.title).slice(0, 200) |
| 138 | const role = normalizeSingleLine(update.role).slice(0, 80) |
| 139 | const objective = normalizeSingleLine(update.objective).slice(0, 1000) |
| 140 | const summary = update.summary.trim().slice(0, 5000) |
| 141 | const keyPoints = update.keyPoints |
| 142 | .map((point) => normalizeSingleLine(point).slice(0, 1000)) |
| 143 | .filter(Boolean) |
| 144 | .slice(0, 20) |
| 145 | |
| 146 | if (!Number.isInteger(pageNumber) || pageNumber < 1) { |
| 147 | throw new Error('Invalid page number') |
| 148 | } |
| 149 | if (!title || !role || !objective || !summary || keyPoints.length === 0) { |
| 150 | throw new Error('Page outline fields cannot be empty') |
| 151 | } |
| 152 | |
| 153 | const headingRegex = new RegExp(`^##\\s*Page\\s+${pageNumber}\\s*:.+$`, 'm') |
| 154 | const headingMatch = thinkingMd.match(headingRegex) |
| 155 | if (!headingMatch || typeof headingMatch.index !== 'number') { |
| 156 | throw new Error(`Page ${pageNumber} was not found in thinking.md`) |
| 157 | } |
| 158 | |
| 159 | const start = headingMatch.index |
| 160 | const remaining = thinkingMd.slice(start + headingMatch[0].length) |
| 161 | const nextHeadingMatch = remaining.match(/^##\s+.+$/m) |
| 162 | const end = |
| 163 | nextHeadingMatch && typeof nextHeadingMatch.index === 'number' |
| 164 | ? start + headingMatch[0].length + nextHeadingMatch.index |
| 165 | : thinkingMd.length |
| 166 | const section = [ |
| 167 | `## Page ${pageNumber}: ${title}`, |
| 168 | `- Role: ${role}`, |
| 169 | `- Objective: ${objective}`, |
| 170 | '', |
| 171 | summary, |
| 172 | '', |
| 173 | ...keyPoints.map((point) => `- ${point}`) |
| 174 | ].join('\n') |
| 175 | |
| 176 | return ( |
| 177 | `${thinkingMd.slice(0, start).trimEnd()}\n\n${section}\n\n${thinkingMd |
| 178 | .slice(end) |
| 179 | .trimStart()}`.trimEnd() + '\n' |
| 180 | ) |
| 181 | } |
| 182 | |
| 183 | export async function writeContextMd(dir: string, content: string): Promise<void> { |
| 184 | const filePath = path.join(dir, 'context.md') |
| 185 | await fs.promises.writeFile(filePath, content, 'utf-8') |
| 186 | } |
| 187 | |
| 188 | export async function writeMessagesList( |
| 189 | dir: string, |
| 190 | messages: ThinkingChatMessage[] |
| 191 | ): Promise<void> { |
| 192 | const filePath = path.join(dir, 'messages.json') |
| 193 | const normalized = normalizeThinkingMessages(messages) |
| 194 | await fs.promises.writeFile(filePath, JSON.stringify(normalized, null, 2), 'utf-8') |
| 195 | } |
| 196 | |
| 197 | export async function scanLatestWorkspace( |
| 198 | storagePath: string |
| 199 | ): Promise<{ thinkingId: string; updatedAt: number } | null> { |
| 200 | const thinkingRoot = path.join(storagePath, 'thinking') |
| 201 | if (!fs.existsSync(thinkingRoot)) return null |
| 202 | |
| 203 | const entries = await fs.promises.readdir(thinkingRoot, { withFileTypes: true }) |
| 204 | const dirs = entries |
| 205 | .filter((e) => e.isDirectory() && THINKING_ID_RE.test(e.name)) |
| 206 | .map((e) => path.join(thinkingRoot, e.name)) |
| 207 | |
| 208 | if (dirs.length === 0) return null |
| 209 | |
| 210 | let latestDir = '' |
| 211 | let latestMtime = 0 |
| 212 | |
| 213 | for (const dir of dirs) { |
| 214 | const thinkingMdPath = path.join(dir, 'thinking.md') |
| 215 | if (!fs.existsSync(thinkingMdPath)) continue |
| 216 | const stat = await fs.promises.stat(thinkingMdPath) |
| 217 | if (stat.mtimeMs > latestMtime) { |
| 218 | latestMtime = stat.mtimeMs |
| 219 | latestDir = dir |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | if (!latestDir) return null |
| 224 | |
| 225 | return { |
| 226 | thinkingId: path.basename(latestDir), |
| 227 | updatedAt: latestMtime |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | export async function scanWorkspaceList( |
| 232 | storagePath: string, |
| 233 | limit = 50 |
| 234 | ): Promise<ThinkingWorkspaceListItem[]> { |
| 235 | const thinkingRoot = path.join(storagePath, 'thinking') |
| 236 | if (!fs.existsSync(thinkingRoot)) return [] |
| 237 | |
| 238 | const entries = await fs.promises.readdir(thinkingRoot, { withFileTypes: true }) |
| 239 | const items: ThinkingWorkspaceListItem[] = [] |
| 240 | |
| 241 | for (const entry of entries) { |
| 242 | if (!entry.isDirectory() || !THINKING_ID_RE.test(entry.name)) continue |
| 243 | |
| 244 | const dir = path.join(thinkingRoot, entry.name) |
| 245 | const thinkingMdPath = path.join(dir, 'thinking.md') |
| 246 | const contextMdPath = path.join(dir, 'context.md') |
| 247 | if (!fs.existsSync(thinkingMdPath)) continue |
| 248 | |
| 249 | try { |
| 250 | const [thinkingMd, thinkingStat] = await Promise.all([ |
| 251 | fs.promises.readFile(thinkingMdPath, 'utf-8'), |
| 252 | fs.promises.stat(thinkingMdPath) |
| 253 | ]) |
| 254 | let contextMd = '' |
| 255 | let contextMtime = 0 |
| 256 | try { |
| 257 | const [content, stat] = await Promise.all([ |
| 258 | fs.promises.readFile(contextMdPath, 'utf-8'), |
| 259 | fs.promises.stat(contextMdPath) |
| 260 | ]) |
| 261 | contextMd = content |
| 262 | contextMtime = stat.mtimeMs |
| 263 | } catch { |
| 264 | contextMd = '' |
| 265 | } |
| 266 | |
| 267 | items.push({ |
| 268 | thinkingId: entry.name, |
| 269 | updatedAt: Math.max(thinkingStat.mtimeMs, contextMtime), |
| 270 | topic: parseTopicFromThinkingMd(thinkingMd), |
| 271 | stage: parseStageFromContextMd(contextMd) |
| 272 | }) |
| 273 | } catch (error) { |
| 274 | log.warn('[thinking] failed to scan workspace list item', { |
| 275 | thinkingId: entry.name, |
| 276 | message: error instanceof Error ? error.message : String(error) |
| 277 | }) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | return items.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, Math.max(1, limit)) |
| 282 | } |
| 283 | |
| 284 | function parseTopicFromThinkingMd(thinkingMd: string): string { |
| 285 | const inline = thinkingMd.match(/^##\s*Topic\s*:\s*(.+)/m) |
| 286 | if (inline) return inline[1].trim() |
| 287 | const newline = thinkingMd.match(/^##\s*Topic\s*\n\s*(.+)/m) |
| 288 | return newline ? newline[1].trim() : '' |
| 289 | } |
| 290 | |
| 291 | export function parseStageFromContextMd(content: string): ThinkingStage { |
| 292 | const match = content.match(/^## Stage:\s*(\S+)/m) |
| 293 | if (!match) return 'collect' |
| 294 | const stage = match[1] as ThinkingStage |
| 295 | const validStages: ThinkingStage[] = ['collect', 'outline', 'draft', 'refine', 'ready'] |
| 296 | return validStages.includes(stage) ? stage : 'collect' |
| 297 | } |
| 298 | |
| 299 | export async function parseSourcesList(dir: string): Promise<ThinkingSource[]> { |
| 300 | const sourcesDir = path.join(dir, 'sources') |
| 301 | if (!fs.existsSync(sourcesDir)) return [] |
| 302 | |
| 303 | const entries = await fs.promises.readdir(sourcesDir, { withFileTypes: true }) |
| 304 | const manifestByFileName = new Map<string, ThinkingSource>() |
| 305 | try { |
| 306 | const rawManifest = await fs.promises.readFile(path.join(dir, 'sources.json'), 'utf-8') |
| 307 | const parsed = JSON.parse(rawManifest) |
| 308 | if (Array.isArray(parsed)) { |
| 309 | for (const item of parsed) { |
| 310 | if (!item || typeof item !== 'object') continue |
| 311 | const record = item as Record<string, unknown> |
| 312 | const id = typeof record.id === 'string' ? record.id : '' |
| 313 | const name = typeof record.name === 'string' ? record.name : '' |
| 314 | const kind = typeof record.kind === 'string' ? record.kind : '' |
| 315 | const fileName = typeof record.fileName === 'string' ? record.fileName : '' |
| 316 | if (!id || !name || !fileName) continue |
| 317 | if (!['markdown', 'text', 'csv', 'docx', 'image'].includes(kind)) continue |
| 318 | manifestByFileName.set(fileName, { |
| 319 | id, |
| 320 | name, |
| 321 | kind: kind as ThinkingSource['kind'] |
| 322 | }) |
| 323 | } |
| 324 | } |
| 325 | } catch { |
| 326 | // Older workspaces do not have a manifest; fall back to file names. |
| 327 | } |
| 328 | const sources: ThinkingSource[] = [] |
| 329 | |
| 330 | for (const entry of entries) { |
| 331 | if (entry.isDirectory()) continue |
| 332 | const manifestSource = manifestByFileName.get(entry.name) |
| 333 | if (manifestSource) { |
| 334 | sources.push(manifestSource) |
| 335 | continue |
| 336 | } |
| 337 | const ext = path.extname(entry.name).toLowerCase() |
| 338 | let kind: ThinkingSource['kind'] = 'text' |
| 339 | if (entry.name.endsWith('.image.md')) kind = 'image' |
| 340 | else if (ext === '.md') kind = 'markdown' |
| 341 | else if (ext === '.csv') kind = 'csv' |
| 342 | else if (ext === '.docx') kind = 'docx' |
| 343 | else if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) kind = 'image' |
| 344 | |
| 345 | sources.push({ |
| 346 | id: entry.name, |
| 347 | name: entry.name, |
| 348 | kind |
| 349 | }) |
| 350 | } |
| 351 | |
| 352 | return sources |
| 353 | } |
| 354 | |
| 355 | async function readMessagesList(dir: string): Promise<ThinkingChatMessage[]> { |
| 356 | const filePath = path.join(dir, 'messages.json') |
| 357 | try { |
| 358 | const raw = await fs.promises.readFile(filePath, 'utf-8') |
| 359 | const parsed = JSON.parse(raw) |
| 360 | if (!Array.isArray(parsed)) return [] |
| 361 | return parsed.flatMap((item): ThinkingChatMessage[] => { |
| 362 | if (!item || typeof item !== 'object') return [] |
| 363 | const record = item as Record<string, unknown> |
| 364 | const role = record.role === 'user' || record.role === 'assistant' ? record.role : null |
| 365 | const rawContent = typeof record.content === 'string' ? record.content : '' |
| 366 | const content = role === 'assistant' ? normalizeThinkingAssistantReply(rawContent) : rawContent |
| 367 | const timestamp = Number(record.timestamp) |
| 368 | if (!role || !content.trim()) return [] |
| 369 | const attachments = Array.isArray(record.attachments) |
| 370 | ? record.attachments.filter((source): source is ThinkingSource => { |
| 371 | if (!source || typeof source !== 'object') return false |
| 372 | const item = source as Record<string, unknown> |
| 373 | return ( |
| 374 | typeof item.id === 'string' && |
| 375 | typeof item.name === 'string' && |
| 376 | (item.kind === 'markdown' || |
| 377 | item.kind === 'text' || |
| 378 | item.kind === 'csv' || |
| 379 | item.kind === 'docx' || |
| 380 | item.kind === 'image') |
| 381 | ) |
| 382 | }) |
| 383 | : undefined |
| 384 | return [ |
| 385 | { |
| 386 | role, |
| 387 | content, |
| 388 | timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(), |
| 389 | ...(attachments && attachments.length > 0 ? { attachments } : {}) |
| 390 | } |
| 391 | ] |
| 392 | }) |
| 393 | } catch { |
| 394 | return [] |
| 395 | } |
| 396 | } |
| 397 |