'use strict'; // ═══════════════════════════════════════════════════════════════════════════════════════════════ // Chunker — turns repo files into retrieval units. // // The whole point of the header audit is that chunk boundaries are deterministic here. Bash // scripts split on their six section names, markdown on its headings, conf templates on their // ━━━ section rules. Nothing is split on a fixed token window, so no chunk ever contains half // of one idea and half of another. // // Every chunk carries its section name as its own field, because that is the metadata that // lets retrieval filter by question shape before it ever computes similarity. // ═══════════════════════════════════════════════════════════════════════════════════════════════ const fs = require('fs'); const path = require('path'); const BASH_SECTIONS = [ 'PURPOSE', 'OPERATIONAL MODEL', 'DESIGN PRINCIPLES', 'OPERATIONAL SAFEGUARDS', 'CONFIGURATION', 'RUNTIME MODES', ]; // PHP headers reuse the first three names deliberately, then diverge per layer. const PHP_SECTIONS = [ 'PURPOSE', 'OPERATIONAL MODEL', 'DESIGN PRINCIPLES', 'OPERATIONAL SAFEGUARDS', 'STATUS', 'EXPORTS', 'REQUEST CONTRACT', 'SIDE EFFECTS', 'RENDERS', 'DEPENDS ON', 'CONFIGURATION', ]; const MIN_CHARS = 40; // below this a chunk carries no retrievable meaning const MAX_CHARS = 6000; // above this, split on blank lines — protects the embed window // Banner rules and box-drawing art are everywhere in this repo's headers. They carry no // meaning to embed, and a chunk that is mostly rule characters is pure noise in the index. // Measure a chunk by what is left after the decoration is removed, not by raw length. function meaningful(s) { return s.replace(/[═─━=_#\/*\s|+.-]/g, '').length; } const MIN_MEANINGFUL = 30; function isRealHeading(h) { return !!h && /[A-Za-z0-9]/.test(h.replace(/[═─━=_]/g, '')); } function stripPrefix(line, prefix) { // '# text' -> 'text' '// text' -> 'text' const re = new RegExp('^\\s*' + prefix + '\\s?'); return line.replace(re, ''); } // ── Comment-header sectioning, shared by bash (#) and PHP (//) ──────────────────────────────── function sectionsFromCommentHeader(text, prefix, names) { const lines = text.split('\n'); const nameSet = new Set(names); const found = []; // headerEnd matters as much as the section starts. The last section (RUNTIME MODES in bash, // DEPENDS ON in a page) would otherwise run to EOF and sweep up every unrelated comment in // the file — scheduler.php alone contributed an 11k-char chunk of unrelated inline comments. let headerEnd = lines.length; for (let i = 0; i < lines.length; i++) { const raw = lines[i]; if (!new RegExp('^\\s*' + prefix).test(raw)) { // Header block ends at the first non-comment, non-blank line past the shebang. // '' bracket a PHP header block and are not the end of it. const t = raw.trim(); if (t !== '' && !/^#!/.test(t) && t !== '' && found.length) { headerEnd = i; break; } continue; } const inner = stripPrefix(raw, prefix).trim(); if (nameSet.has(inner)) found.push({ name: inner, start: i }); } const out = []; for (let k = 0; k < found.length; k++) { const start = found[k].start + 1; const end = k + 1 < found.length ? found[k + 1].start : headerEnd; const body = lines.slice(start, end) .filter(l => new RegExp('^\\s*' + prefix).test(l)) .map(l => stripPrefix(l, prefix)) // drop pure separator rules (════, ────, ━━━) — they carry no meaning .filter(l => !/^[\s═─━=_-]*$/.test(l) || l.trim() === '') .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); if (meaningful(body) >= MIN_MEANINGFUL) { for (const p of splitNamedParagraphs(body)) out.push({ section: found[k].name, title: p.title, content: p.content }); } } return out; } // ── Named-paragraph sub-chunking ────────────────────────────────────────────────────────────── // The header convention writes safeguards and principles as named paragraphs: an unindented // title line followed by an indented body. Embedding a whole section as one unit dilutes them — // rsync.sh's OPERATIONAL SAFEGUARDS holds eight distinct guarantees in 2.8k chars, and a query // about one of them scored below unrelated chunks because the other seven dominated the vector. // Splitting on the title lines is what makes a specific question find a specific answer. // // The section name is carried onto every sub-chunk, so section routing still works; the // paragraph title becomes the chunk's heading. function splitNamedParagraphs(body) { const lines = body.split('\n'); const marks = []; for (let i = 0; i < lines.length; i++) { const l = lines[i]; if (!l.trim()) continue; if (/^\s/.test(l)) continue; // indented => body, not a title if (/^[-*•]/.test(l.trim())) continue; // list item, not a title if (l.trim().length > 80) continue; // a long line is prose, not a heading if (/[.:;,]$/.test(l.trim())) continue; // ends like a sentence // The decisive signal: a real title is followed by an indented body. Wrapped prose is // followed by more unindented prose. Without this check, any short line in a paragraph // that happened to wrap became a spurious chunk boundary mid-sentence. let j = i + 1; while (j < lines.length && !lines[j].trim()) j++; if (j >= lines.length || !/^\s+\S/.test(lines[j])) continue; marks.push(i); } // Fewer than two titles means this section is not written as named paragraphs — keep it whole. if (marks.length < 2) return [{ title: null, content: body }]; const out = []; if (marks[0] > 0) { const pre = lines.slice(0, marks[0]).join('\n').trim(); if (meaningful(pre) >= MIN_MEANINGFUL) out.push({ title: null, content: pre }); } for (let k = 0; k < marks.length; k++) { const start = marks[k]; const end = k + 1 < marks.length ? marks[k + 1] : lines.length; const title = lines[start].trim(); const content = lines.slice(start, end).join('\n').trim(); if (meaningful(content) >= MIN_MEANINGFUL) out.push({ title, content }); } return out; } // ── Markdown: split on ## headings, keep the heading with its body ───────────────────────────── function sectionsFromMarkdown(text) { const lines = text.split('\n'); const marks = []; let fence = false; for (let i = 0; i < lines.length; i++) { if (/^\s*```/.test(lines[i])) { fence = !fence; continue; } if (fence) continue; if (/^#{1,3}\s+\S/.test(lines[i])) marks.push(i); } if (!marks.length) return [{ heading: null, content: text.trim() }]; const out = []; // preamble before the first heading if (marks[0] > 0) { const pre = lines.slice(0, marks[0]).join('\n').trim(); if (meaningful(pre) >= MIN_MEANINGFUL) out.push({ heading: null, content: pre }); } for (let k = 0; k < marks.length; k++) { const start = marks[k]; const end = k + 1 < marks.length ? marks[k + 1] : lines.length; const heading = lines[start].replace(/^#+\s*/, '').replace(/[━─═]+/g, '').trim(); const content = lines.slice(start, end).join('\n').trim(); if (meaningful(content) >= MIN_MEANINGFUL) out.push({ heading: isRealHeading(heading) ? heading : null, content }); } return out; } // ── Conf templates: split on the ━━━ / ── section rules ─────────────────────────────────────── function sectionsFromConfTemplate(text) { const lines = text.split('\n'); const marks = []; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^#\s*[━─]{2,}\s*(.+?)\s*[━─]{2,}\s*$/); if (m && isRealHeading(m[1])) marks.push({ i, name: m[1].trim() }); } if (!marks.length) return []; const out = []; for (let k = 0; k < marks.length; k++) { const start = marks[k].i; const end = k + 1 < marks.length ? marks[k + 1].i : lines.length; const content = lines.slice(start, end).join('\n').replace(/\n{3,}/g, '\n\n').trim(); if (meaningful(content) >= MIN_MEANINGFUL) out.push({ heading: marks[k].name, content }); } return out; } // Oversized chunks split on blank lines rather than mid-sentence. function capSize(chunks) { const out = []; for (const c of chunks) { if (c.content.length <= MAX_CHARS) { out.push(c); continue; } const paras = c.content.split(/\n\s*\n/); let buf = [], len = 0, part = 1; const flush = () => { if (!buf.length) return; out.push({ ...c, content: buf.join('\n\n'), part: part++ }); buf = []; len = 0; }; for (const p of paras) { if (len + p.length > MAX_CHARS && buf.length) flush(); buf.push(p); len += p.length + 2; } flush(); } return out; } function classify(rel) { const base = path.basename(rel); if (rel.startsWith('Deployment/') && rel.endsWith('.template')) return 'template'; // WebGUI page docs, written for whoever is using the tab rather than maintaining it. Their // own kind because every other kind here answers a maintainer's question: an operator asking // "how do I stop this" needs the click path, and a corpus that is three-quarters script // headers will otherwise always answer in conf edits. Matched on the folder, not the // filename, so these can be named whatever reads best. if (rel.startsWith('Plugin/unraid/pages/readme/') && base.endsWith('.md')) return 'ui'; if (base.endsWith('.md')) { if (base.startsWith('Manual')) return 'manual'; if (base.startsWith('README') || base === 'README.md') return 'readme'; return 'doc'; } if (base.endsWith('.sh')) return 'header'; if (base.endsWith('.php')) return 'header'; return 'other'; } function chunkFile(absPath, rel) { const text = fs.readFileSync(absPath, 'utf8'); const kind = classify(rel); let raw = []; if (kind === 'header' && rel.endsWith('.sh')) { raw = sectionsFromCommentHeader(text, '#', BASH_SECTIONS) .map(s => ({ section: s.section, heading: s.title || null, content: s.content })); } else if (kind === 'header' && rel.endsWith('.php')) { raw = sectionsFromCommentHeader(text, '//', PHP_SECTIONS) .map(s => ({ section: s.section, heading: s.title || null, content: s.content })); } else if (kind === 'template') { raw = sectionsFromConfTemplate(text) .map(s => ({ section: null, heading: s.heading, content: s.content })); } else if (kind === 'readme' || kind === 'manual' || kind === 'doc' || kind === 'ui') { raw = sectionsFromMarkdown(text) .map(s => ({ section: null, heading: s.heading, content: s.content })); } return capSize(raw).map(c => ({ path: rel, kind, section: c.section || null, heading: c.heading || null, part: c.part || null, content: c.content, })); } module.exports = { chunkFile, classify, BASH_SECTIONS, PHP_SECTIONS };