/** * @factory/search — content chunking for the indexer (ARCHITECTURE.md §6, * ai-architecture §6 "content chunker"). * * Pure, deterministic: same input → same chunks. Rules (recorded decisions): * 1. Split on blank lines (paragraph boundaries) first; fenced code blocks * (``` / ~~~) are never split, and blank lines inside a fence do not * split. A paragraph larger than `maxChars` is hard-split on word * boundaries at ≤ `maxChars`. * 2. Adjacent over-small pieces are merged up to `maxChars` (no 3-word chunks). * 3. Consecutive chunks overlap by `overlapChars` (the tail of the previous * chunk is copied to the front of the next) so matches near boundaries * are not lost. * 4. Token estimate = ceil(words / 0.75) (≈ 4 chars/token on English text); * the estimate is for budgeting only — the real count comes from the * embedding provider's usage. */ import type { Chunk, ChunkOptions } from "./types.js"; export const DEFAULT_CHUNK_OPTIONS: Required = { maxChars: 800, overlapChars: 120, }; /** Rough token estimate for budgeting (not billed). */ export function estimateTokens(text: string): number { const words = text.split(/\s+/).filter((w) => w.length > 0); return Math.ceil(words.length / 0.75); } /** True when a line starts a fenced code block (``` or ~~~). */ function isFenceLine(line: string): boolean { return /^\s*(```|~~~)/.test(line); } interface Span { text: string; /** Offset of `text` inside the ORIGINAL source string. */ start: number; } /** * Split `text` into paragraph spans on blank lines. Fence pairs stay intact; * blank lines inside a fence are ignored. */ function splitParagraphs(text: string): Span[] { const out: Span[] = []; const lines = text.split("\n"); const lineOffsets: number[] = []; let cursor = 0; for (const line of lines) { lineOffsets.push(cursor); cursor += line.length + 1; } let paraStartLine = 0; let inFence = false; for (let i = 0; i <= lines.length; i++) { const atEnd = i === lines.length; const line = atEnd ? "" : (lines[i] ?? ""); if (!atEnd && isFenceLine(line)) { inFence = !inFence; continue; } if (!atEnd && inFence && line.trim() === "") continue; // blank inside fence const closes = atEnd || line.trim() === ""; if (closes) { const s = lineOffsets[paraStartLine]!; const e = atEnd ? text.length : lineOffsets[i]!; const seg = text.slice(s, e); const trimmed = seg.replace(/^\s+|\s+$/g, ""); if (trimmed.length > 0) { out.push({ text: trimmed, start: s + (seg.length - seg.trimStart().length) }); } paraStartLine = i + 1; } } return out; } /** * Hard-split `paragraph` (a span inside `source`) into word-bounded pieces of * ≤ maxChars, with exact offsets into `source`. */ function hardSplit(source: string, para: Span, maxChars: number): Span[] { const out: Span[] = []; let restStart = para.start; const rest = () => source.slice(restStart, para.start + para.text.length); while (rest().length > maxChars) { let cut = rest().lastIndexOf(" ", maxChars); if (cut < Math.floor(maxChars / 2)) cut = maxChars; // no space: hard cut out.push({ text: rest().slice(0, cut).trimEnd(), start: restStart }); restStart += cut; // skip the whitespace separator consumed between pieces while ( restStart < para.start + para.text.length && (source[restStart] === " " || source[restStart] === "\t" || source[restStart] === "\n") ) { restStart++; } } if (rest().length > 0) out.push({ text: rest(), start: restStart }); return out; } /** * Chunk `text` into pieces for indexing/embedding. * * Offsets (into the ORIGINAL, trimmed, CRLF-normalized text): every chunk — * single-piece or merged — satisfies `source.slice(charStart, charEnd) === * text` exactly (the merge separator is the original gap between pieces). * With overlap enabled, `text` additionally carries the previous chunk's * tail at its front; the span still bounds the chunk's own content. */ export function chunkText(text: string, options?: ChunkOptions): Chunk[] { const maxChars = options?.maxChars ?? DEFAULT_CHUNK_OPTIONS.maxChars; let overlap = options?.overlapChars ?? DEFAULT_CHUNK_OPTIONS.overlapChars; if (overlap <= 0) overlap = 0; if (overlap > Math.floor(maxChars / 4)) overlap = Math.floor(maxChars / 4); const source = text.replace(/\r\n/g, "\n").trim(); if (source.length === 0) return []; // 1) paragraphs (fences intact) → 2) hard-split the oversized ones. // When an oversized fence must be split, each chunk is re-bracketed with // the ORIGINAL opening line (```ts keeps its info string) and the last // chunk carries the closing line, so no chunk is left with an unbalanced // fence. Recorded decision: re-bracketed fence chunks may exceed // maxChars by the fence line(s) — the integrity guarantee (balanced // fences per chunk) outranks the exact size budget. const pieces: Span[] = []; for (const para of splitParagraphs(source)) { if (para.text.length <= maxChars) { pieces.push(para); continue; } const hp = hardSplit(source, para, maxChars); const fenceMark = para.text.match(/^\s*(```|~~~)/)?.[1]; if (!fenceMark) { pieces.push(...hp); continue; } const openLine = para.text.slice(0, para.text.indexOf("\n") === -1 ? para.text.length : para.text.indexOf("\n")); const trimmedEnd = para.text.replace(/\s+$/, ""); const closed = trimmedEnd.endsWith(fenceMark); const closeLine = closed ? trimmedEnd.slice(trimmedEnd.lastIndexOf("\n") + 1) : ""; let index = 0; for (const piece of hp) { const first = index === 0; const last = index === hp.length - 1; let text = piece.text; if (!first && !/^\s*(```|~~~)/.test(text)) text = openLine + "\n" + text; if (last && closed && !/(```|~~~)\s*$/.test(text)) text = text + "\n" + closeLine; pieces.push({ ...piece, text }); index++; } } // 3) merge adjacent over-small pieces up to maxChars (track exact end). // The separator is the ORIGINAL gap between the pieces in the source, so // the merged text is byte-identical to source.slice(start, end). const merged: (Span & { end: number })[] = []; for (const piece of pieces) { const last = merged[merged.length - 1]; if (last) { const gap = source.slice(last.end, piece.start); if (last.text.length + gap.length + piece.text.length <= maxChars) { last.text = last.text + gap + piece.text; last.end = piece.start + piece.text.length; continue; } } merged.push({ ...piece, end: piece.start + piece.text.length }); } // 4) overlap: copy the tail (last `overlap` chars) of the previous chunk // to the front of the next one. const chunks: Chunk[] = []; let prevTail: string | null = null; for (const piece of merged) { let body = piece.text; if (prevTail) { body = prevTail + "\n" + piece.text; if (body.length > maxChars) { // overlap overflowed the budget: shrink the copied tail const budget = maxChars - piece.text.length - 1; body = budget > 0 ? prevTail.slice(Math.max(0, prevTail.length - budget)) + "\n" + piece.text : piece.text; } } chunks.push({ chunkIndex: chunks.length, text: body, charStart: piece.start, charEnd: piece.end, tokens: estimateTokens(piece.text), }); // NOTE: slice(-0) === slice(0) === whole string, so gate on overlap > 0 prevTail = overlap > 0 ? piece.text.slice(-overlap) || null : null; } return chunks; }