| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import log from 'electron-log/main.js' |
| 4 | import { |
| 5 | deriveOutlinePageCandidates, |
| 6 | estimateOutlinePageCount, |
| 7 | scanDocumentOutline, |
| 8 | type DocumentOutlineScan |
| 9 | } from '../io/document-outline-scan' |
| 10 | import { convertCsvTextToMarkdown } from '../io/document-csv-to-markdown' |
| 11 | import type { ThinkingChatMessage } from '@shared/thinking' |
| 12 | |
| 13 | const MAX_BRIEF_ATTACHMENTS = 3 |
| 14 | const MAX_VISIBLE_CANDIDATES = 18 |
| 15 | const MAX_VISIBLE_HEADINGS = 24 |
| 16 | const MAX_SOURCE_BYTES = 1_500_000 |
| 17 | |
| 18 | type SourceManifestItem = { |
| 19 | id: string |
| 20 | name: string |
| 21 | kind: string |
| 22 | fileName: string |
| 23 | } |
| 24 | |
| 25 | const readManifest = async (thinkingDir: string): Promise<SourceManifestItem[]> => { |
| 26 | try { |
| 27 | const manifestPath = path.join(thinkingDir, 'sources.json') |
| 28 | const parsed = JSON.parse(await fs.promises.readFile(manifestPath, 'utf-8')) as unknown |
| 29 | return Array.isArray(parsed) |
| 30 | ? parsed.filter((item): item is SourceManifestItem => { |
| 31 | if (!item || typeof item !== 'object') return false |
| 32 | const record = item as Record<string, unknown> |
| 33 | return ( |
| 34 | typeof record.id === 'string' && |
| 35 | typeof record.name === 'string' && |
| 36 | typeof record.kind === 'string' && |
| 37 | typeof record.fileName === 'string' |
| 38 | ) |
| 39 | }) |
| 40 | : [] |
| 41 | } catch { |
| 42 | return [] |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | const flattenHeadingLines = ( |
| 47 | nodes: DocumentOutlineScan['sectionTree'], |
| 48 | depth = 0 |
| 49 | ): string[] => |
| 50 | nodes.flatMap((node) => [ |
| 51 | `${' '.repeat(depth)}- ${'#'.repeat(node.level)} ${node.title} (lines ${node.lineStart}-${node.lineEnd})`, |
| 52 | ...flattenHeadingLines(node.children, depth + 1) |
| 53 | ]) |
| 54 | |
| 55 | const resolveScanFormat = (kind: string): DocumentOutlineScan['format'] => { |
| 56 | if (kind === 'text') return 'text' |
| 57 | return 'markdown' |
| 58 | } |
| 59 | |
| 60 | const formatSourceBriefSection = (args: { |
| 61 | source: SourceManifestItem |
| 62 | virtualPath: string |
| 63 | scan: DocumentOutlineScan |
| 64 | }): string => { |
| 65 | const candidates = deriveOutlinePageCandidates(args.scan) |
| 66 | const estimate = estimateOutlinePageCount(args.scan, candidates) |
| 67 | const headingLines = flattenHeadingLines(args.scan.sectionTree).slice(0, MAX_VISIBLE_HEADINGS) |
| 68 | const candidateLines = candidates.slice(0, MAX_VISIBLE_CANDIDATES).map((candidate, index) => |
| 69 | [ |
| 70 | `${index + 1}. [${candidate.role}] ${candidate.sourceHeading}`, |
| 71 | `(lines ${candidate.lineStart}-${candidate.lineEnd}; ${candidate.reason})` |
| 72 | ].join(' ') |
| 73 | ) |
| 74 | |
| 75 | return [ |
| 76 | `### ${args.source.name}`, |
| 77 | `- Source file: ${args.virtualPath}`, |
| 78 | `- Detected format: ${args.scan.format}`, |
| 79 | args.scan.topLevelTitle ? `- Top-level title: ${args.scan.topLevelTitle}` : '', |
| 80 | `- Headings detected: ${args.scan.headingCount}`, |
| 81 | estimate |
| 82 | ? `- Deterministic slide-count estimate: prefer ${estimate.preferredPageCount}; range ${estimate.minPageCount}-${estimate.maxPageCount}.` |
| 83 | : '', |
| 84 | args.scan.recommendedSplitHints.length > 0 ? '- Split/merge hints:' : '', |
| 85 | ...args.scan.recommendedSplitHints.slice(0, 5).map((hint) => ` - ${hint}`), |
| 86 | candidateLines.length > 0 |
| 87 | ? `- Page candidates (${candidateLines.length} visible of ${candidates.length}):` |
| 88 | : '', |
| 89 | ...candidateLines.map((line) => ` ${line}`), |
| 90 | candidates.length > candidateLines.length |
| 91 | ? ` ... ${candidates.length - candidateLines.length} more candidates. Use grep/read_file on the source file for the rest.` |
| 92 | : '', |
| 93 | headingLines.length > 0 ? `- Heading map (${headingLines.length} visible):` : '', |
| 94 | ...headingLines.map((line) => ` ${line}`), |
| 95 | args.scan.headingCount > headingLines.length |
| 96 | ? ` ... ${args.scan.headingCount - headingLines.length} more headings. Use grep/read_file on the source file for details.` |
| 97 | : '' |
| 98 | ] |
| 99 | .filter(Boolean) |
| 100 | .join('\n') |
| 101 | } |
| 102 | |
| 103 | export const buildThinkingSourceBrief = async (args: { |
| 104 | thinkingDir: string |
| 105 | attachments?: ThinkingChatMessage['attachments'] |
| 106 | }): Promise<string> => { |
| 107 | const attachments = (args.attachments || []).filter((item) => item.kind !== 'image') |
| 108 | if (attachments.length === 0) return '' |
| 109 | |
| 110 | const startedAt = Date.now() |
| 111 | log.info('[thinking:source-brief] start', { |
| 112 | thinkingDir: args.thinkingDir, |
| 113 | attachmentCount: attachments.length, |
| 114 | attachmentIds: attachments.map((attachment) => attachment.id), |
| 115 | attachmentNames: attachments.map((attachment) => attachment.name) |
| 116 | }) |
| 117 | |
| 118 | const manifest = await readManifest(args.thinkingDir) |
| 119 | const sourcesDir = path.join(args.thinkingDir, 'sources') |
| 120 | const sections: string[] = [] |
| 121 | |
| 122 | for (const attachment of attachments.slice(0, MAX_BRIEF_ATTACHMENTS)) { |
| 123 | const source = manifest.find((item) => item.id === attachment.id) |
| 124 | if (!source) { |
| 125 | log.warn('[thinking:source-brief] attachment missing from manifest', { |
| 126 | sourceId: attachment.id, |
| 127 | sourceName: attachment.name |
| 128 | }) |
| 129 | continue |
| 130 | } |
| 131 | const sourcePath = path.join(sourcesDir, source.fileName) |
| 132 | const virtualPath = `/sources/${source.fileName}` |
| 133 | |
| 134 | try { |
| 135 | const stat = await fs.promises.stat(sourcePath) |
| 136 | if (!stat.isFile()) { |
| 137 | log.warn('[thinking:source-brief] source path is not a file', { |
| 138 | sourceId: source.id, |
| 139 | sourceName: source.name, |
| 140 | virtualPath |
| 141 | }) |
| 142 | continue |
| 143 | } |
| 144 | if (stat.size > MAX_SOURCE_BYTES) { |
| 145 | log.info('[thinking:source-brief] source skipped because it is large', { |
| 146 | sourceId: source.id, |
| 147 | sourceName: source.name, |
| 148 | virtualPath, |
| 149 | bytes: stat.size |
| 150 | }) |
| 151 | sections.push( |
| 152 | [ |
| 153 | `### ${source.name}`, |
| 154 | `- Source file: ${virtualPath}`, |
| 155 | `- File is large (${stat.size} bytes), so no inline source brief was generated.`, |
| 156 | '- Use grep/read_file on the source file to inspect headings and relevant sections.' |
| 157 | ].join('\n') |
| 158 | ) |
| 159 | continue |
| 160 | } |
| 161 | const rawContent = await fs.promises.readFile(sourcePath, 'utf-8') |
| 162 | const content = |
| 163 | source.kind === 'csv' |
| 164 | ? convertCsvTextToMarkdown(rawContent, { title: source.name }) |
| 165 | : rawContent |
| 166 | if (!content.trim()) continue |
| 167 | const scan = scanDocumentOutline(content, resolveScanFormat(source.kind)) |
| 168 | const candidates = deriveOutlinePageCandidates(scan) |
| 169 | const estimate = estimateOutlinePageCount(scan, candidates) |
| 170 | log.info('[thinking:source-brief] source scanned', { |
| 171 | sourceId: source.id, |
| 172 | sourceName: source.name, |
| 173 | kind: source.kind, |
| 174 | virtualPath, |
| 175 | bytes: stat.size, |
| 176 | headingCount: scan.headingCount, |
| 177 | pageCandidateCount: candidates.length, |
| 178 | preferredPageCount: estimate?.preferredPageCount ?? null |
| 179 | }) |
| 180 | sections.push(formatSourceBriefSection({ source, virtualPath, scan })) |
| 181 | } catch (error) { |
| 182 | log.warn('[thinking:source-brief] scan failed', { |
| 183 | sourceId: source.id, |
| 184 | sourceName: source.name, |
| 185 | message: error instanceof Error ? error.message : String(error) |
| 186 | }) |
| 187 | sections.push( |
| 188 | [ |
| 189 | `### ${source.name}`, |
| 190 | `- Source file: ${virtualPath}`, |
| 191 | '- Source brief scan failed. Use grep/read_file on the source file when details are needed.' |
| 192 | ].join('\n') |
| 193 | ) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | if (sections.length === 0) { |
| 198 | log.info('[thinking:source-brief] end', { |
| 199 | sectionCount: 0, |
| 200 | briefLength: 0, |
| 201 | durationMs: Date.now() - startedAt |
| 202 | }) |
| 203 | return '' |
| 204 | } |
| 205 | |
| 206 | const brief = [ |
| 207 | '## Source Brief', |
| 208 | 'The following lightweight source brief was built deterministically from the files attached to this message. Use it as a map only; read the source file with grep/read_file when exact details are needed.', |
| 209 | '', |
| 210 | ...sections |
| 211 | ].join('\n') |
| 212 | log.info('[thinking:source-brief] end', { |
| 213 | sectionCount: sections.length, |
| 214 | briefLength: brief.length, |
| 215 | durationMs: Date.now() - startedAt |
| 216 | }) |
| 217 | return brief |
| 218 | } |
| 219 |