import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import { resolveTypeId, resolveLinkTypeId } from './_shared/resolve-ids' import * as fs from 'fs' import * as path from 'path' import * as crypto from 'crypto' // ─── TYPES ──────────────────────────────────────────────────────────────────── interface IngestRequest { project_id: string globs?: string[] exclude_globs?: string[] force_reprocess?: boolean file_path?: string } interface SourceSet { id: string name: string base_path: string globs?: string[] exclude_globs?: string[] layer?: string } interface DiscoveredFile { file_path: string language: string size_bytes: number stored_hash: string line_count: number has_existing_markers: boolean existing_marker_names: string[] } interface ChunkProposal { name: string line_start: number line_end: number chunk_type: string confidence: 'high' | 'low' confidence_score: number confidence_reasons: string existing_marker_match: boolean existing_marker_name: string code_preview: string token_count: number } // ─── CONSTANTS ──────────────────────────────────────────────────────────────── const LANGUAGE_MAP: Record = { '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.sql': 'sql', '.py': 'python', '.go': 'go', '.rs': 'rust', '.md': 'markdown', '.json': 'json', '.css': 'css', '.html': 'html', '.sh': 'shell', } const DEFAULT_GLOBS = ['**/*.ts', '**/*.tsx', '**/*.sql'] const DEFAULT_EXCLUDE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', '.netlify/**', '**/*.min.js', '**/*.d.ts'] // High-confidence threshold: proposals at or above this score are auto-approved const HIGH_CONFIDENCE_THRESHOLD = 0.80 // CHUNK_START / CHUNK_END marker regex (accepts legacy // markers and new /** */ markers) const MARKER_START_RE = /^(?:\/\/\s*[─\-]+|\/\*\*?\s*)\s*CHUNK_START:\s*(.+?)(?:\s*[─\-]*\s*\*\/)?\s*$/ const MARKER_END_RE = /^(?:\/\/\s*[─\-]+|\/\*\*?\s*)\s*CHUNK_END:\s*(.+?)(?:\s*[─\-]*\s*\*\/)?\s*$/ // Redundant visual section headers like // ─── RESOLUTION FUNCTIONS ─── // These are replaced by chunk boundaries and JSDoc, so they can be removed. const SECTION_HEADER_RE = /^\/\/\s*[─\-]{3,}.*[─\-]{3,}\s*$/ // New JSDoc-style marker format function chunkStartMarker(name: string): string { return `/** ─── CHUNK_START: ${name} ─── */` } function chunkEndMarker(name: string): string { return `/** ─── CHUNK_END: ${name} ─── */` } // Return the insertion index for a CHUNK_START marker given a 1-based lineStart. // If the lines immediately above lineStart belong to a /** ... */ JSDoc block, // insert above that block so the marker doesn't land inside a comment. function markerInsertIdx(lines: string[], lineStart: number): number { let idx = lineStart - 1 // convert to 0-based // Walk backward past any existing JSDoc block that closes just before idx for (let i = idx - 1; i >= 0; i--) { const trimmed = (lines[i] || '').trim() if (trimmed === '') continue if (trimmed.endsWith('*/') || trimmed === '*/') { // There's a closing */ above — find its opening /** for (let j = i - 1; j >= 0; j--) { const t = (lines[j] || '').trim() if (t.startsWith('/**') || t.startsWith('/*')) { idx = j // insert above the opening of the comment block break } } } break } return idx } // Return the insertion index for a CHUNK_END marker given a 1-based lineEnd. // lineEnd is inserted AFTER that line (i.e., at index lineEnd in 0-based). // If that landing spot is inside a JSDoc block (line starts with ' * ' or '*/'), // push past the closing '*/' of that block. function markerEndInsertIdx(lines: string[], lineEnd: number): number { let idx = lineEnd // 0-based: splice at idx inserts after line lineEnd (1-based) // Check if idx lands inside a JSDoc const atLine = (lines[idx] || '').trim() if (atLine.startsWith('* ') || atLine === '*' || atLine === '*/') { // Walk forward to find the closing */ for (let i = idx; i < lines.length; i++) { const t = (lines[i] || '').trim() if (t === '*/' || t.endsWith('*/')) { idx = i + 1 // insert after the closing */ break } } } return idx } // ─── UTILITIES ──────────────────────────────────────────────────────────────── function computeFileHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16) } function moduleNameFromPath(relPath: string): string { return path.basename(relPath, path.extname(relPath)) } /** * Normalise chunk content before hashing — must match the logic in * custom_cil-validate.ts `normaliseContent` exactly so that hashes computed * at ingest time and at validate time are always comparable. * * Rules (kept in sync with validate.ts): * 1. Strip CHUNK_START / CHUNK_END marker lines * 2. Collapse runs of blank lines to a single blank line * 3. Trim leading/trailing whitespace from the whole block * 4. Normalise line endings to \n */ function normaliseChunkContent(raw: string): string { const lines = raw.replace(/\r\n/g, '\n').split('\n') const filtered = lines.filter(line => { const trimmed = line.trim() return !trimmed.match(/^\/\/\s*[─\-]+ CHUNK_(START|END):/) }) const collapsed: string[] = [] let lastBlank = false for (const line of filtered) { const isBlank = line.trim() === '' if (isBlank && lastBlank) continue collapsed.push(line) lastBlank = isBlank } return collapsed.join('\n').trim() } function computeChunkHash(codeContent: string): string { return crypto.createHash('sha256').update(normaliseChunkContent(codeContent)).digest('hex').slice(0, 16) } function estimateTokens(text: string): number { return Math.ceil(text.length / 4) } function detectLanguage(filePath: string): string { const ext = path.extname(filePath).toLowerCase() return LANGUAGE_MAP[ext] || 'unknown' } /** * Match a file path against glob patterns. * Supports ** (any path segment), * (any filename segment), and ? (single char). */ function matchesGlob(filePath: string, pattern: string): boolean { const regexStr = pattern .replace(/\./g, '\\.') .replace(/\*\*/g, '__DOUBLESTAR__') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]') .replace(/__DOUBLESTAR__/g, '.*') const re = new RegExp('^' + regexStr + '$') return re.test(filePath) } function shouldInclude(filePath: string, globs: string[], excludeGlobs: string[]): boolean { const normalised = filePath.replace(/\\/g, '/') const included = globs.some(g => matchesGlob(normalised, g) || matchesGlob(path.basename(normalised), g)) const excluded = excludeGlobs.some(g => matchesGlob(normalised, g)) return included && !excluded } /** * Recursively walk a directory and collect file paths. */ function walkDir(dir: string, base: string, globs: string[], excludeGlobs: string[]): string[] { const results: string[] = [] let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return results } for (const entry of entries) { const full = path.join(dir, entry.name) const rel = path.relative(base, full).replace(/\\/g, '/') if (entry.isDirectory()) { // Skip if the directory itself is excluded if (excludeGlobs.some(g => matchesGlob(rel, g) || matchesGlob(rel + '/', g))) continue results.push(...walkDir(full, base, globs, excludeGlobs)) } else if (entry.isFile()) { if (shouldInclude(rel, globs, excludeGlobs)) { results.push(full) } } } return results } /** * Parse existing CHUNK_START / CHUNK_END markers from source lines. * Returns named ranges: { name, line_start, line_end }. */ function parseExistingMarkers(lines: string[]): { name: string; line_start: number; line_end: number }[] { const markers: { name: string; line_start: number; line_end: number }[] = [] const stack: { name: string; line_start: number }[] = [] for (let i = 0; i < lines.length; i++) { const line = lines[i] const startMatch = line.match(MARKER_START_RE) const endMatch = line.match(MARKER_END_RE) if (startMatch) { stack.push({ name: startMatch[1], line_start: i + 1 }) } else if (endMatch && stack.length > 0) { const opened = stack.pop()! markers.push({ name: opened.name, line_start: opened.line_start, line_end: i + 1 }) } } return markers } // ─── EXISTING JSDOC EXTRACTOR ───────────────────────────────────────────────── // Reads any /** ... */ block immediately above a given 1-based line number and // parses out standard tags so pass 2 can pre-populate chunk fields without an // LLM call, and pass 3 can skip already-documented chunks. interface ExtractedJsDoc { raw: string // full comment text, no delimiters description: string // leading text before first @tag params: string // @param lines joined returns: string // @returns / @return text throws: string // @throws text side_effects: string // @sideEffects / @side-effects text stability: string // @stability text macro: string // @macro text micro: string // @micro text input_spec: string // @inputSpec / @inputs text output_spec: string // @outputSpec / @outputs text } function extractJsDocAbove(lines: string[], lineStart: number): ExtractedJsDoc | null { // Walk backward from lineStart-1 past blank lines and CIL marker lines let endIdx = lineStart - 2 // 0-based index of line above the declaration while (endIdx >= 0) { const t = lines[endIdx].trim() if (t === '') { endIdx--; continue } // Skip CIL markers if (/CHUNK_START:|CHUNK_END:/.test(t)) { endIdx--; continue } break } if (endIdx < 0) return null const endTrimmed = lines[endIdx].trim() // Must end with */ to be a JSDoc block if (!endTrimmed.endsWith('*/')) return null // Find the opening /** let startIdx = endIdx for (let i = endIdx; i >= 0; i--) { const t = lines[i].trim() if (t.startsWith('/**') || t.startsWith('/*')) { startIdx = i; break } if (i === 0) return null } // Collect comment body lines (strip leading ' * ') const bodyLines: string[] = [] for (let i = startIdx; i <= endIdx; i++) { const raw = lines[i] const stripped = raw.trim() .replace(/^\/\*\*?/, '').replace(/\*\/$/, '') // strip /** and */ .replace(/^\*\s?/, '') // strip leading * .trim() bodyLines.push(stripped) } const raw = bodyLines.join('\n').trim() if (!raw) return null // Parse @tags const get = (tags: string[]): string => { for (const tag of tags) { const re = new RegExp(`@${tag}\\s+(.+?)(?=\\n@|$)`, 'si') const m = raw.match(re) if (m) return m[1].replace(/\n\s*\*?\s*/g, ' ').trim() } return '' } // Description = text before first @tag const descMatch = raw.match(/^([\s\S]*?)(?=@\w|$)/) const description = descMatch ? descMatch[1].trim() : '' // Collect @param lines const paramLines: string[] = [] for (const m of raw.matchAll(/@param\s+(.+?)(?=\n@|\n\n|$)/gsi)) { paramLines.push(m[1].replace(/\n\s*\*?\s*/g, ' ').trim()) } return { raw, description, params: paramLines.join('; '), returns: get(['returns', 'return']), throws: get(['throws', 'throw']), side_effects: get(['sideEffects', 'side-effects', 'sideeffects']), stability: get(['stability']), macro: get(['macro']), micro: get(['micro']), input_spec: get(['inputSpec', 'inputs', 'input_spec']), output_spec: get(['outputSpec', 'outputs', 'output_spec']), } } // Extract the file-level JSDoc (the very first /** ... */ in the file, before any code). function extractFileJsDoc(lines: string[]): string { let inBlock = false const bodyLines: string[] = [] for (const line of lines) { const t = line.trim() if (!inBlock) { if (t.startsWith('/**') || t.startsWith('/*')) { inBlock = true; bodyLines.push(t); continue } if (t !== '' && !t.startsWith('//')) break // hit code before any comment } else { bodyLines.push(t) if (t.endsWith('*/')) break } } return bodyLines.join('\n') } // ─── AST-APPROXIMATION CHUNKER ──────────────────────────────────────────────── // Uses regex patterns that closely approximate TypeScript AST node boundaries. // These patterns are designed to be conservative — low confidence for anything ambiguous. interface RawProposal { name: string line_start: number line_end: number chunk_type: string confidence_score: number confidence_reasons: string[] existing_jsdoc?: ExtractedJsDoc | null } function proposeChunksFromSource(lines: string[], language: string): RawProposal[] { const proposals: RawProposal[] = [] if (!['typescript', 'javascript'].includes(language)) return proposals // ── Module header: capture the file-level JSDoc + imports + top-level setup ── let jsDocStart = -1, jsDocEnd = -1 for (let i = 0; i < lines.length; i++) { const t = lines[i].trim() if (t === '') continue if ((t.startsWith('/**') || t.startsWith('/*')) && jsDocStart === -1) { jsDocStart = i } else if (jsDocStart !== -1 && t.endsWith('*/') && jsDocEnd === -1) { jsDocEnd = i } else if (jsDocStart === -1 && !t.startsWith('//') && !t.startsWith('/*')) { break } } const headerStart = jsDocStart >= 0 ? jsDocStart : 0 let headerEnd = jsDocEnd >= 0 ? jsDocEnd : headerStart - 1 // Extend the header to swallow imports and module-level setup (env, clients, etc.) // until the first real declaration. const DECL_RE = /^(?:export\s+)?(?:async\s+)?(?:function|class|interface)\s+\w+/ const EXPORT_DECL_RE = /^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+\w+/ const startScan = Math.max(headerEnd + 1, 0) for (let i = startScan; i < lines.length; i++) { const t = lines[i].trim() if (t === '' || t.startsWith('//')) continue if (t.startsWith('import')) continue if (EXPORT_DECL_RE.test(lines[i]) || DECL_RE.test(lines[i])) { headerEnd = i - 1 break } } // Trim trailing blank lines while (headerEnd > headerStart && lines[headerEnd].trim() === '') { headerEnd-- } if (headerEnd >= headerStart) { proposals.push({ name: '__module_header__', line_start: headerStart + 1, line_end: headerEnd + 1, chunk_type: 'module_header', confidence_score: 0.99, confidence_reasons: ['file-level JSDoc, imports, and module setup'], existing_jsdoc: null, }) } // Start scanning for declarations after the module header const scanStart = headerEnd >= 0 ? headerEnd + 1 : 0 // Patterns and their confidence scores const patterns: { re: RegExp; type: string; baseScore: number; nameGroup: number }[] = [ // Export default function / named export function { re: /^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)/m, type: 'function', baseScore: 0.92, nameGroup: 1 }, // Export const arrow function / value { re: /^export\s+const\s+(\w+)\s*[=:]/m, type: 'function', baseScore: 0.85, nameGroup: 1 }, // Export let/var { re: /^export\s+(?:let|var)\s+(\w+)\s*[=:]/m, type: 'const', baseScore: 0.82, nameGroup: 1 }, // Export default class { re: /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/m, type: 'class', baseScore: 0.90, nameGroup: 1 }, // Export interface { re: /^export\s+interface\s+(\w+)/m, type: 'interface', baseScore: 0.90, nameGroup: 1 }, // Export type alias { re: /^export\s+type\s+(\w+)/m, type: 'type', baseScore: 0.88, nameGroup: 1 }, // Non-exported function { re: /^(?:async\s+)?function\s+(\w+)/m, type: 'function', baseScore: 0.78, nameGroup: 1 }, // Non-exported class { re: /^(?:abstract\s+)?class\s+(\w+)/m, type: 'class', baseScore: 0.80, nameGroup: 1 }, // Non-exported interface { re: /^interface\s+(\w+)/m, type: 'interface', baseScore: 0.80, nameGroup: 1 }, // Non-exported type alias { re: /^type\s+(\w+)\s*=/m, type: 'type', baseScore: 0.78, nameGroup: 1 }, // Non-exported const / let / var { re: /^(?:const|let|var)\s+(\w+)\s*[=:]/m, type: 'const', baseScore: 0.70, nameGroup: 1 }, ] // Line-by-line scan for top-level declarations let lineIdx = scanStart while (lineIdx < lines.length) { const line = lines[lineIdx] let matched = false for (const { re, type, baseScore, nameGroup } of patterns) { const m = line.match(re) if (m) { const name = m[nameGroup] if (!name) { lineIdx++; break } // Find the end of this declaration by tracking brace depth let braceDepth = 0 let endLine = lineIdx let foundOpen = false let inString = false let stringChar = '' for (let j = lineIdx; j < Math.min(lines.length, lineIdx + 500); j++) { const l = lines[j] let inLineComment = false let inBlockComment = false for (let ci = 0; ci < l.length; ci++) { const c = l[ci] const next = l[ci + 1] if (inString) { if (c === stringChar && l[ci - 1] !== '\\') inString = false continue } if (inLineComment) continue if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; ci++ } continue } if (c === '/' && next === '/') { inLineComment = true; ci++; continue } if (c === '/' && next === '*') { inBlockComment = true; ci++; continue } if (c === '"' || c === "'") { inString = true; stringChar = c; continue } if (c === '`') { inString = true; stringChar = '`'; continue } if (c === '{') { braceDepth++; foundOpen = true } if (c === '}') { braceDepth-- } } if (foundOpen && braceDepth === 0) { endLine = j break } } // Must span at least 3 lines to be worth chunking const lineCount = endLine - lineIdx + 1 if (lineCount < 3) { lineIdx++; matched = true; break } const confidenceReasons: string[] = [] let score = baseScore // Extract existing JSDoc above this declaration const existingJsDoc = extractJsDocAbove(lines, lineIdx + 1) // Boost for exported symbols if (line.includes('export')) { score += 0.05; confidenceReasons.push('exported symbol') } // Strong boost for JSDoc comment above — internal helpers with docs are clear units if (existingJsDoc) { score += 0.10; confidenceReasons.push('has JSDoc') } // Reduce for very large chunks (likely file-level code, not a clean unit) if (lineCount > 200) { score -= 0.10; confidenceReasons.push('very large (>200 lines)') } // Reduce for anonymous-looking arrow functions if (!line.match(/function\s+\w+/) && !line.match(/class\s+\w+/) && !line.match(/interface\s+\w+/)) { score -= 0.05; confidenceReasons.push('arrow/assigned function') } score = Math.min(0.99, Math.max(0.1, score)) proposals.push({ name, line_start: lineIdx + 1, line_end: endLine + 1, chunk_type: type, confidence_score: score, confidence_reasons: confidenceReasons, existing_jsdoc: existingJsDoc, }) lineIdx = endLine + 1 matched = true break } } if (!matched) lineIdx++ } // De-duplicate overlapping proposals (keep highest confidence) const deduped: RawProposal[] = [] for (const p of proposals.sort((a, b) => b.confidence_score - a.confidence_score)) { const overlaps = deduped.some(d => Math.max(d.line_start, p.line_start) <= Math.min(d.line_end, p.line_end) ) if (!overlaps) deduped.push(p) } const sorted = deduped.sort((a, b) => a.line_start - b.line_start) if (sorted.length === 0) return [] // Pull each chunk's start back to include the JSDoc/blank lines immediately above it // so the documentation belongs to the declaration it describes. for (let i = sorted.length - 1; i >= 0; i--) { const curr = sorted[i] const prevEnd = i > 0 ? sorted[i - 1].line_end : 0 let newStart = curr.line_start for (let j = curr.line_start - 2; j >= prevEnd; j--) { const t = lines[j].trim() if (t === '') { newStart = j + 1 continue } if (t.startsWith('/**') || t.startsWith('/*') || t.startsWith('*')) { newStart = j + 1 continue } break } curr.line_start = newStart } // First chunk always starts at line 1 sorted[0].line_start = 1 // Close remaining gaps by expanding the previous chunk to swallow trailing // markers, blank lines, and section headers. These are non-code scaffolding // lines that will be cleaned up during marker writing. for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1] const curr = sorted[i] if (curr.line_start > prev.line_end + 1) { prev.line_end = curr.line_start - 1 } } // Last chunk extends to end of file const last = sorted[sorted.length - 1] if (last.line_end < lines.length) { last.line_end = lines.length } return sorted } // ─── PASS 1: FILE DISCOVERY ─────────────────────────────────────────────────── async function pass1Discover( projectId: string, accountId: string, repoPath: string, globs: string[], excludeGlobs: string[], forceReprocess: boolean, sourceSets?: SourceSet[] ): Promise<{ files_discovered: number; files_created: number; files_updated: number; files_skipped: number; errors: string[] }> { const errors: string[] = [] let filesCreated = 0, filesUpdated = 0, filesSkipped = 0 // Resolve type ID for cil_file const cil_file_type_id = await resolveTypeId('item', 'cil_file') // Build the list of discovery targets: either explicit source sets or one default set const discoveryTargets: { sourceSetId?: string; layer?: string; basePath: string; globs: string[]; excludeGlobs: string[] }[] = [] if (sourceSets && sourceSets.length > 0) { for (const ss of sourceSets) { const basePath = path.isAbsolute(ss.base_path) ? ss.base_path : path.join(repoPath, ss.base_path) discoveryTargets.push({ sourceSetId: ss.id, layer: ss.layer || 'app-specific', basePath, globs: ss.globs && ss.globs.length > 0 ? ss.globs : globs, excludeGlobs: ss.exclude_globs && ss.exclude_globs.length > 0 ? ss.exclude_globs : excludeGlobs, }) } } else { discoveryTargets.push({ basePath: repoPath, globs, excludeGlobs }) } // Load all existing cil_file records for this project in ONE query // instead of one query per file — eliminates N+1 round-trips const { data: existingItems } = await adminDb .from('items') .select('id, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file') const existingByPath = new Map }>() for (const item of existingItems || []) { const fp = item.data?.file_path as string if (fp) existingByPath.set(fp, item) } // Process files in memory, collect batches const now = new Date().toISOString() const toInsert: object[] = [] const toUpdate: { id: string; data: Record }[] = [] for (const target of discoveryTargets) { const absolutePaths = walkDir(target.basePath, target.basePath, target.globs, target.excludeGlobs) for (const absPath of absolutePaths) { const relPath = path.relative(repoPath, absPath).replace(/\\/g, '/') try { const content = fs.readFileSync(absPath, 'utf-8') const lines = content.split('\n') const fileHash = computeFileHash(content) const language = detectLanguage(absPath) const existingMarkers = parseExistingMarkers(lines) const hasExistingMarkers = existingMarkers.length > 0 const existing = existingByPath.get(relPath) const sourceSetData = { source_set_id: target.sourceSetId || null, layer: target.layer || null, } if (existing) { if (!forceReprocess && existing.data?.stored_hash === fileHash) { filesSkipped++ continue } toUpdate.push({ id: existing.id, data: { ...existing.data, ...sourceSetData, stored_hash: fileHash, has_existing_markers: hasExistingMarkers, existing_marker_names: existingMarkers.map(m => m.name), existing_marker_count: existingMarkers.length, line_count: lines.length, ingestion_pass: 1, }, }) filesUpdated++ } else { toInsert.push({ type_id: cil_file_type_id, account_id: accountId, title: relPath, status: 'active', is_active: true, data: { kb_type: 'cil_file', project_id: projectId, file_path: relPath, ...sourceSetData, language, stored_hash: fileHash, has_existing_markers: hasExistingMarkers, existing_marker_names: existingMarkers.map(m => m.name), existing_marker_count: existingMarkers.length, line_count: lines.length, ingestion_pass: 1, chunk_count: 0, is_dead_code: false, git_last_commit: null, existing_marker_agreement_count: 0, existing_marker_disagreement_count: 0, }, created_at: now, updated_at: now, }) filesCreated++ } } catch (err) { errors.push(`${relPath}: ${err instanceof Error ? err.message : String(err)}`) } } } // Batch insert new files (single query regardless of file count) if (toInsert.length > 0) { const { error } = await adminDb.from('items').insert(toInsert) if (error) errors.push(`batch insert failed: ${error.message}`) } // Batch update changed files (one query per changed file — unavoidable with // Supabase row-level updates, but far fewer than before since unchanged files // are skipped in-memory) for (const { id, data } of toUpdate) { const { error } = await adminDb.from('items').update({ data, updated_at: now }).eq('id', id) if (error) errors.push(`update ${id} failed: ${error.message}`) } // Update project counters await adminDb.from('items').update({ data: { ingestion_status: 'in_progress', file_count: filesCreated + filesUpdated + filesSkipped, last_ingested_at: now, }, updated_at: now, }).filter('data->>project_id', 'eq', projectId).filter('data->>kb_type', 'eq', 'cil_project') return { files_discovered: absolutePaths.length, files_created: filesCreated, files_updated: filesUpdated, files_skipped: filesSkipped, errors, } } // ─── PASS 2: AST CHUNKING ───────────────────────────────────────────────────── async function pass2Chunk( projectId: string, accountId: string, repoPath: string, globs: string[], excludeGlobs: string[], forceReprocess: boolean, filePath?: string ): Promise<{ files_processed: number chunks_auto_approved: number chunks_queued_for_review: number markers_written: number errors: string[] }> { const errors: string[] = [] let filesProcessed = 0, chunksAutoApproved = 0, chunksQueued = 0, markersWritten = 0 const [cil_chunk_type_id, cil_chunk_proposal_type_id] = await Promise.all([ resolveTypeId('item', 'cil_chunk'), resolveTypeId('item', 'cil_chunk_proposal'), ]) // Load cil_file items for this project (optionally filtered to one path) let fileItemsQuery = adminDb .from('items') .select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file') .eq('is_active', true) if (filePath) { fileItemsQuery = fileItemsQuery.filter('data->>file_path', 'eq', filePath) } const { data: fileItems } = await fileItemsQuery // Load all existing chunks (file_id + chunk_name) to deduplicate on re-ingest const { data: existingChunkRows } = await adminDb .from('items') .select('data->file_id, data->chunk_name') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_chunk') .eq('is_active', true) const alreadyChunkedFileIds = new Set( (existingChunkRows || []).map((r: any) => r.file_id).filter(Boolean) ) // Set of "file_id::chunk_name" for deduplication on re-ingest const existingChunkKeys = new Set( (existingChunkRows || []) .filter((r: any) => r.file_id && r.chunk_name) .map((r: any) => `${r.file_id}::${r.chunk_name}`) ) const now = new Date().toISOString() // Collect all rows to batch-insert at the end const chunksToInsert: object[] = [] const proposalsToInsert: object[] = [] // File updates still need to be per-row (partial data merge), but we accumulate them const fileUpdates: { id: string; data: Record }[] = [] for (const fileItem of fileItems || []) { const relPath: string = fileItem.data?.file_path || fileItem.title const absPath = path.join(repoPath, relPath) const language = fileItem.data?.language || 'unknown' if (!['typescript', 'javascript'].includes(language)) continue // Skip already-chunked files without a DB round-trip if (!forceReprocess && alreadyChunkedFileIds.has(fileItem.id)) continue try { const content = fs.readFileSync(absPath, 'utf-8') const lines = content.split('\n') // Parse existing markers (used for agreement scoring only) const existingMarkers = parseExistingMarkers(lines) // Propose chunks using AST-approximation const rawProposals = proposeChunksFromSource(lines, language) const approvedChunks: { name: string; line_start: number; line_end: number; chunk_type: string }[] = [] for (const proposal of rawProposals) { // Include any JSDoc block above the declaration in code_content so the LLM // sees the full annotated unit. Walk backward from line_start to find the // opening /** of the doc block (skipping blank lines and CIL markers). let contentStart = proposal.line_start - 1 // 0-based if (proposal.existing_jsdoc) { for (let i = proposal.line_start - 2; i >= 0; i--) { const t = lines[i].trim() if (t === '' || /CHUNK_START:|CHUNK_END:|@cil-generated/.test(t)) continue if (t.startsWith('/**') || t.startsWith('/*')) { contentStart = i; break } if (!t.startsWith('*')) break // non-comment, non-blank — stop } } const codeLines = lines.slice(contentStart, proposal.line_end) const codeContent = codeLines.join('\n') const codePreview = codeLines.slice(0, 8).join('\n') const tokenCount = estimateTokens(codeContent) const contentHash = computeChunkHash(codeContent) // Check if any existing marker overlaps with this proposal const overlappingMarker = existingMarkers.find(m => Math.abs(m.line_start - proposal.line_start) <= 3 ) const existingMarkerMatch = !!overlappingMarker const existingMarkerName = overlappingMarker?.name || '' // If proposal matches an existing marker, boost confidence let score = proposal.confidence_score let reasons = proposal.confidence_reasons if (existingMarkerMatch) { if (overlappingMarker!.name === proposal.name) { score = Math.min(0.99, score + 0.08) reasons = [...reasons, 'matches existing marker name+position'] } else { score = Math.min(0.99, score + 0.04) reasons = [...reasons, 'matches existing marker position'] } } const isHighConfidence = score >= HIGH_CONFIDENCE_THRESHOLD if (isHighConfidence) { // Dedup: skip if this chunk already exists in the DB (re-ingest protection) const chunkKey = `${fileItem.id}::${proposal.name}` if (!forceReprocess && existingChunkKeys.has(chunkKey)) { approvedChunks.push({ name: proposal.name, line_start: proposal.line_start, line_end: proposal.line_end, chunk_type: proposal.chunk_type }) chunksAutoApproved++ continue } const existingMarkerAgreement = existingMarkerMatch ? (overlappingMarker!.name === proposal.name ? 'agreed' : 'disagreed') : 'none' // Pre-populate doc fields from existing JSDoc (avoids redundant LLM calls in pass 3) const jd = proposal.existing_jsdoc const hasSourceDoc = !!(jd && (jd.description || jd.params || jd.returns)) const docConfidence = hasSourceDoc ? 'from_source' : 'unverified_legacy' // Build input_spec from @param lines; prefer explicit @inputSpec if present const inputSpec = jd?.input_spec || jd?.params || '' // Build output_spec from @returns; prefer explicit @outputSpec if present const outputSpec = jd?.output_spec || jd?.returns || '' // macro = first sentence of description; micro = second sentence (if any) const sentences = (jd?.description || '').split(/(?<=[.!?])\s+/) const macro = jd?.macro || sentences[0] || '' const micro = jd?.micro || sentences[1] || '' const layer = fileItem.data?.layer || (proposal.chunk_type === 'module_header' ? 'shared-core' : 'app-specific') const isModuleHeader = proposal.chunk_type === 'module_header' const moduleName = isModuleHeader ? moduleNameFromPath(relPath) : '' const baseChunkData: Record = { kb_type: 'cil_chunk', chunk_name: proposal.name, file_id: fileItem.id, project_id: projectId, file_path: relPath, source_set_id: fileItem.data?.source_set_id || null, layer, line_start: proposal.line_start, line_end: proposal.line_end, chunk_type: proposal.chunk_type, stored_hash: contentHash, boundary_confidence: 'high', boundary_source: 'ast_auto', review_status: 'approved', doc_confidence: docConfidence, is_dead_code: false, macro, micro, input_spec: inputSpec, output_spec: outputSpec, side_effects: jd?.side_effects || '', stability: jd?.stability || 'internal', throws: jd?.throws || '', test_unit_path: null, test_e2e_path: null, token_count: tokenCount, existing_marker_agreement: existingMarkerAgreement, code_content: codeContent, } if (isModuleHeader) { baseChunkData.module = moduleName baseChunkData.audience = 'both' baseChunkData.layer = layer baseChunkData.stability = 'stable' baseChunkData.description = jd?.description || `${moduleName} module setup and imports.` } chunksToInsert.push({ type_id: cil_chunk_type_id, account_id: accountId, title: `${relPath}::${proposal.name}`, status: 'active', is_active: true, data: baseChunkData, created_at: now, updated_at: now, }) existingChunkKeys.add(chunkKey) // prevent dupes within same pass run approvedChunks.push({ name: proposal.name, line_start: proposal.line_start, line_end: proposal.line_end, chunk_type: proposal.chunk_type }) chunksAutoApproved++ } else { proposalsToInsert.push({ type_id: cil_chunk_proposal_type_id, account_id: accountId, title: `PROPOSAL: ${relPath}::${proposal.name}`, status: 'active', is_active: true, data: { kb_type: 'cil_chunk_proposal', file_id: fileItem.id, project_id: projectId, file_path: relPath, proposed_name: proposal.name, line_start: proposal.line_start, line_end: proposal.line_end, chunk_type: proposal.chunk_type, confidence_score: score, confidence_reasons: reasons.join('; '), existing_marker_match: existingMarkerMatch, existing_marker_name: existingMarkerName, review_status: 'pending_review', reviewed_by: null, reviewed_at: null, adjusted_line_start: null, adjusted_line_end: null, token_count: tokenCount, code_preview: codePreview, }, created_at: now, updated_at: now, }) chunksQueued++ } } // Write CHUNK_START / CHUNK_END markers to the source file for auto-approved chunks // and remove redundant visual section headers that are now replaced by chunk boundaries. if (approvedChunks.length > 0) { let resultLines = [...lines] // Identify redundant visual section-header lines that sit outside any approved chunk const sectionHeaderLinesToRemove: number[] = [] for (let i = 0; i < resultLines.length; i++) { if (!SECTION_HEADER_RE.test(resultLines[i].trim())) continue const lineNum = i + 1 const insideChunk = approvedChunks.some(c => lineNum >= c.line_start && lineNum <= c.line_end ) if (!insideChunk) sectionHeaderLinesToRemove.push(i) } // Remove section headers from bottom to top so earlier indices stay valid for (const idx of sectionHeaderLinesToRemove.sort((a, b) => b - a)) { resultLines.splice(idx, 1) } // Adjust chunk line numbers to account for removed section-header lines const adjustedChunks = approvedChunks.map(c => { const removedBeforeStart = sectionHeaderLinesToRemove.filter(i => i < c.line_start - 1).length const removedBeforeEnd = sectionHeaderLinesToRemove.filter(i => i < c.line_end).length return { ...c, line_start: c.line_start - removedBeforeStart, line_end: c.line_end - removedBeforeEnd, } }) const sorted = [...adjustedChunks].sort((a, b) => b.line_start - a.line_start) for (const chunk of sorted) { const startLine = resultLines[chunk.line_start - 1] if (startLine && MARKER_START_RE.test(startLine.trim())) continue const endMarker = chunkEndMarker(chunk.name) const startMarker = chunkStartMarker(chunk.name) const startIdx = markerInsertIdx(resultLines, chunk.line_start) const endIdx = markerEndInsertIdx(resultLines, chunk.line_end) resultLines.splice(endIdx, 0, endMarker) resultLines.splice(startIdx, 0, startMarker) markersWritten++ } fs.writeFileSync(absPath, resultLines.join('\n'), 'utf-8') } // Accumulate file update (applied in batch after the loop) fileUpdates.push({ id: fileItem.id, data: { ...fileItem.data, chunk_count: approvedChunks.length, ingestion_pass: 2, existing_marker_agreement_count: approvedChunks.filter(c => { const em = existingMarkers.find(m => Math.abs(m.line_start - c.line_start) <= 3) return em && em.name === c.name }).length, existing_marker_disagreement_count: approvedChunks.filter(c => { const em = existingMarkers.find(m => Math.abs(m.line_start - c.line_start) <= 3) return em && em.name !== c.name }).length, }, }) filesProcessed++ } catch (err) { errors.push(`${relPath}: ${err instanceof Error ? err.message : String(err)}`) } } // Batch insert chunks and proposals concurrently — 500 rows per request const INSERT_BATCH = 500 const PARALLEL_UPDATE = 20 const chunkInsertPromises: Promise[] = [] for (let i = 0; i < chunksToInsert.length; i += INSERT_BATCH) { const batch = chunksToInsert.slice(i, i + INSERT_BATCH) chunkInsertPromises.push( adminDb.from('items').insert(batch).then(({ error }) => { if (error) errors.push(`chunk batch insert failed: ${error.message}`) }) ) } const proposalInsertPromises: Promise[] = [] for (let i = 0; i < proposalsToInsert.length; i += INSERT_BATCH) { const batch = proposalsToInsert.slice(i, i + INSERT_BATCH) proposalInsertPromises.push( adminDb.from('items').insert(batch).then(({ error }) => { if (error) errors.push(`proposal batch insert failed: ${error.message}`) }) ) } await Promise.all([...chunkInsertPromises, ...proposalInsertPromises]) // Update file items in parallel batches — Supabase requires per-row updates // for partial data merges, but we can run many concurrently for (let i = 0; i < fileUpdates.length; i += PARALLEL_UPDATE) { const batch = fileUpdates.slice(i, i + PARALLEL_UPDATE) await Promise.all( batch.map(({ id, data }) => adminDb.from('items').update({ data, updated_at: now }).eq('id', id) .then(({ error }) => { if (error) errors.push(`file update ${id} failed: ${error.message}`) }) ) ) } // Update project summary await adminDb.from('items').update({ data: { ingestion_status: chunksQueued > 0 ? 'in_progress' : 'complete', chunk_count: chunksAutoApproved, last_ingested_at: now, }, updated_at: now, }).filter('data->>kb_type', 'eq', 'cil_project').eq('id', projectId) return { files_processed: filesProcessed, chunks_auto_approved: chunksAutoApproved, chunks_queued_for_review: chunksQueued, markers_written: markersWritten, errors, } } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = ctx.query?.action const req = body as IngestRequest if (!req?.project_id) throw new Error('project_id is required') // Look up the project to get repo_path const { data: projectItem, error: projectError } = await adminDb .from('items') .select('id, title, data') .eq('id', req.project_id) .maybeSingle() if (projectError || !projectItem) throw new Error(`Project not found: ${req.project_id}`) const repoPath: string = projectItem.data?.repo_path // Only enforce repoPath for actions that need disk access const DISK_ACTIONS = ['pass1', 'pass2', 'reindex_chunk'] if (DISK_ACTIONS.includes(action || '')) { if (!repoPath) throw new Error('Project has no repo_path configured') if (!fs.existsSync(repoPath)) { throw new Error(`Repository path does not exist on this machine: ${repoPath}`) } } const globs = req.globs || DEFAULT_GLOBS const excludeGlobs = req.exclude_globs || DEFAULT_EXCLUDE const forceReprocess = req.force_reprocess ?? false const sourceSets: SourceSet[] | undefined = projectItem.data?.source_sets // ── PASS 1: Discover files ──────────────────────────────────────────────────── if (action === 'pass1') { const accountId = ctx.accountId if (!accountId) throw new Error('account_id required — user must be authenticated') // Single-file mode: override globs to target exactly the requested path const discoverGlobs = req.file_path ? [req.file_path] : globs const discoverExcludes = req.file_path ? [] : excludeGlobs const result = await pass1Discover(req.project_id, accountId, repoPath, discoverGlobs, discoverExcludes, forceReprocess, sourceSets) return { action: 'pass1', project_id: req.project_id, repo_path: repoPath, file_path: req.file_path || null, source_sets_used: sourceSets ? sourceSets.length : 0, ...result, } } // ── PASS 2: AST chunk + write markers ───────────────────────────────────────── if (action === 'pass2') { // Pass 1 must have run first — check we have file items let fileCountQuery = adminDb .from('items') .select('id', { count: 'exact' }) .filter('data->>project_id', 'eq', req.project_id) .filter('data->>kb_type', 'eq', 'cil_file') .eq('is_active', true) if (req.file_path) { fileCountQuery = fileCountQuery.filter('data->>file_path', 'eq', req.file_path) } const { count: fileCount } = await fileCountQuery if ((fileCount ?? 0) === 0) { throw new Error('No cil_file items found — run Pass 1 first') } const accountId = ctx.accountId if (!accountId) throw new Error('account_id required — user must be authenticated') const result = await pass2Chunk(req.project_id, accountId, repoPath, globs, excludeGlobs, forceReprocess, req.file_path) return { action: 'pass2', project_id: req.project_id, repo_path: repoPath, file_path: req.file_path || null, confidence_threshold: HIGH_CONFIDENCE_THRESHOLD, ...result, } } // ── PROMOTE PROPOSAL — approve a low-confidence proposal → cil_chunk + disk marker // // Body: { project_id, proposal_id, adjusted_name?, adjusted_line_start?, adjusted_line_end? } // // Reads the source file from disk using the proposal's file_id → cil_file → repo_path, // creates a cil_chunk item with stored_hash, writes CHUNK_START/END markers to disk, // and deactivates the cil_chunk_proposal item. if (action === 'promote_proposal') { const proposalId = body?.proposal_id if (!proposalId) throw new Error('proposal_id is required') const { data: proposal } = await adminDb .from('items') .select('id, data') .eq('id', proposalId) .maybeSingle() if (!proposal) throw new Error(`Proposal not found: ${proposalId}`) const pd = proposal.data || {} const fileId = pd.file_id if (!fileId) throw new Error('Proposal has no file_id') // Allow caller to override name/line range const chunkName = body?.adjusted_name || pd.proposed_name || 'Unknown' const lineStart = body?.adjusted_line_start || pd.line_start || 0 const lineEnd = body?.adjusted_line_end || pd.line_end || 0 const chunkType = pd.chunk_type || 'function' const projectId = pd.project_id || req.project_id if (!lineStart || !lineEnd) throw new Error('Proposal is missing line_start / line_end') // Load the cil_file to get file_path; also use its project's repo_path as fallback const { data: fileItem } = await adminDb .from('items').select('id, data').eq('id', fileId).maybeSingle() if (!fileItem) throw new Error(`File not found: ${fileId}`) const relPath: string = fileItem.data?.file_path || fileItem.title // Resolve repo_path: prefer project record, then body override, then fail with clear message const effectiveRepoPath: string = repoPath || body?.repo_path || '' if (!effectiveRepoPath) { throw new Error( `Project has no repo_path configured. ` + `Pass repo_path in the request body to override, or update the project record.` ) } const absPath = path.join(effectiveRepoPath, relPath) if (!fs.existsSync(absPath)) { throw new Error(`Source file not found on disk: ${absPath}`) } const fileContent = fs.readFileSync(absPath, 'utf-8') const lines = fileContent.split('\n') // Extract chunk content const codeLines = lines.slice(lineStart - 1, lineEnd) const codeContent = codeLines.join('\n') const tokenCount = estimateTokens(codeContent) const contentHash = computeChunkHash(codeContent) // Resolve cil_chunk type ID const chunkTypeId = await resolveTypeId('item', 'cil_chunk') // Create the cil_chunk item const accountId = ctx.accountId if (!accountId) throw new Error('account_id required — user must be authenticated') const { data: newChunk, error: insertError } = await adminDb .from('items') .insert({ type_id: chunkTypeId, account_id: accountId, title: `${relPath}::${chunkName}`, status: 'active', is_active: true, data: { kb_type: 'cil_chunk', chunk_name: chunkName, file_id: fileId, project_id: projectId, file_path: relPath, line_start: lineStart, line_end: lineEnd, chunk_type: chunkType, stored_hash: contentHash, boundary_confidence: 'human_verified', boundary_source: 'ast_approved', review_status: 'approved', doc_confidence: 'unverified_legacy', is_dead_code: false, macro: '', micro: '', input_spec: '', output_spec: '', side_effects: '', stability: 'internal', test_unit_path: null, test_e2e_path: null, token_count: tokenCount, existing_marker_agreement: 'none', code_content: codeContent, }, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .select('id') .single() if (insertError) throw new Error(`Failed to create chunk: ${insertError.message}`) // Write CHUNK_START / CHUNK_END markers to the source file const resultLines = [...lines] const startMarker = chunkStartMarker(chunkName) const endMarker = chunkEndMarker(chunkName) // Only write if markers are not already present at this position const lineBeforeStart = resultLines[lineStart - 1] || '' if (!MARKER_START_RE.test(lineBeforeStart.trim())) { const startIdx = markerInsertIdx(resultLines, lineStart) const endIdx = markerEndInsertIdx(resultLines, lineEnd) resultLines.splice(endIdx, 0, endMarker) resultLines.splice(startIdx, 0, startMarker) fs.writeFileSync(absPath, resultLines.join('\n'), 'utf-8') } // Deactivate the proposal await adminDb.from('items').update({ is_active: false, data: { ...pd, review_status: 'approved', promoted_chunk_id: newChunk?.id }, updated_at: new Date().toISOString(), }).eq('id', proposalId) return { action: 'promote_proposal', proposal_id: proposalId, chunk_id: newChunk?.id, chunk_name: chunkName, file_path: relPath, marker_written: !MARKER_START_RE.test(lineBeforeStart.trim()), } } // ── STATUS: Get ingestion state for a project ───────────────────────────────── if (action === 'status') { const { count: fileCount } = await adminDb .from('items').select('id', { count: 'exact' }) .filter('data->>project_id', 'eq', req.project_id) .filter('data->>kb_type', 'eq', 'cil_file').eq('is_active', true) const { count: chunkCount } = await adminDb .from('items').select('id', { count: 'exact' }) .filter('data->>project_id', 'eq', req.project_id) .filter('data->>kb_type', 'eq', 'cil_chunk').eq('is_active', true) const { count: proposalCount } = await adminDb .from('items').select('id', { count: 'exact' }) .filter('data->>project_id', 'eq', req.project_id) .filter('data->>kb_type', 'eq', 'cil_chunk_proposal') .filter('data->>review_status', 'eq', 'pending_review') .eq('is_active', true) return { project_id: req.project_id, file_count: fileCount || 0, chunk_count: chunkCount || 0, pending_review_count: proposalCount || 0, ingestion_status: projectItem.data?.ingestion_status || 'not_started', } } // ── REINDEX CHUNK — re-extract code_content + stored_hash from disk for one chunk, // then optionally trigger embed + doc passes ────────────────────────────── // // Body: { project_id, chunk_id, also_embed?, also_doc?, model? } // // Use after editing code inside a chunk to keep the CIL in sync without // running the full pass2 over the entire project. if (action === 'reindex_chunk') { const chunkId = body?.chunk_id if (!chunkId) throw new Error('chunk_id is required') const { data: chunk } = await adminDb .from('items').select('id, data').eq('id', chunkId).maybeSingle() if (!chunk) throw new Error(`Chunk not found: ${chunkId}`) const cd = chunk.data || {} const fileId = cd.file_id const relPath: string = cd.file_path const lineStart: number = cd.line_start || 0 const lineEnd: number = cd.line_end || 0 if (!relPath || !lineStart || !lineEnd) { throw new Error('Chunk is missing file_path / line_start / line_end — cannot reindex') } const absPath = path.join(repoPath, relPath) if (!fs.existsSync(absPath)) { throw new Error(`Source file not found on disk: ${absPath}`) } const fileContent = fs.readFileSync(absPath, 'utf-8') const lines = fileContent.split('\n') const codeLines = lines.slice(lineStart - 1, lineEnd) const codeContent = codeLines.join('\n') const tokenCount = estimateTokens(codeContent) const contentHash = computeChunkHash(codeContent) // Write updated code_content + stored_hash back to the chunk item await adminDb.from('items').update({ data: { ...cd, code_content: codeContent, stored_hash: contentHash, token_count: tokenCount, hash_updated_at: new Date().toISOString(), }, updated_at: new Date().toISOString(), }).eq('id', chunkId) // Also update the cil_file item hash if file changed if (fileId) { const fileHash = computeFileHash(fileContent) await adminDb.from('items').update({ data: { stored_hash: fileHash, updated_at: new Date().toISOString() }, updated_at: new Date().toISOString(), }).eq('id', fileId) } const result: Record = { action: 'reindex_chunk', chunk_id: chunkId, chunk_name: cd.chunk_name || '', file_path: relPath, new_hash: contentHash, token_count: tokenCount, embed_triggered: false, doc_triggered: false, } // Optionally trigger embed pass for this chunk if (body?.also_embed) { try { const base = process.env.URL || 'http://localhost:8888' const embedRes = await fetch(`${base}/.netlify/functions/custom_cil-embeddings?action=embed_chunk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk_id: chunkId, force_regenerate: true }), }) result.embed_triggered = embedRes.ok if (!embedRes.ok) { const errText = await embedRes.text() result.embed_error = errText.slice(0, 200) } } catch (err) { result.embed_error = err instanceof Error ? err.message : String(err) } } // Optionally trigger AI doc for this chunk if (body?.also_doc) { try { const base = process.env.URL || 'http://localhost:8888' const docRes = await fetch(`${base}/.netlify/functions/custom_cil-doc?action=doc_chunk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk_id: chunkId, model: body?.model || 'gpt-4o-mini', force: true }), }) result.doc_triggered = docRes.ok if (!docRes.ok) { const errText = await docRes.text() result.doc_error = errText.slice(0, 200) } } catch (err) { result.doc_error = err instanceof Error ? err.message : String(err) } } return result } throw new Error(`Unknown action: ${action}. Valid actions: pass1, pass2, status, promote_proposal, reindex_chunk`) })