contents) {
+ /**
+ * Builds the source text passed to the AI model for a package summary.
+ *
+ * The text starts with the package's own deterministic header block, then embeds the
+ * already-generated AI body of every child {@code .ai.md} (per-file summaries and
+ * sub-package summaries), each under a {@link #CHILD_SUMMARY_HEADING_PREFIX} heading that
+ * names the child. This is what the {@code package-body} prompt expects: it synthesises a
+ * package summary from the child summaries, not from a bare name listing. Only the child
+ * bodies are inlined; the single-letter child headers (metadata) are deliberately omitted
+ * to save context and avoid distracting the model.
+ *
+ * When no child contributes a usable body (all blank or unreadable), the method falls
+ * back to the plain {@link #CONTENTS_HEADING} name listing so the model still receives the
+ * package contents.
+ *
+ * No explicit length cap is applied here: the combined text is subject to the existing
+ * trim logic in {@link net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptPreparationSupport#preparePrompt}, which truncates at a line
+ * boundary to the computed {@code maxInputChars} budget and triggers the {@code warnOnTrim}
+ * warning — so large packages degrade gracefully without a bespoke truncation scheme.
+ *
+ * @param header the package's own header (its title and metadata)
+ * @param directory the package directory whose child {@code .ai.md} bodies are embedded
+ * @param contents pre-collected child entry names, used only for the no-body fallback
+ * @return the rendered source text for the AI prompt
+ * @throws IOException if a child {@code .ai.md} file cannot be read
+ */
+ private String buildPackageSourceText(
+ final AiMdHeader header, final Path directory, final Collection contents) throws IOException {
final StringBuilder builder = new StringBuilder();
+ appendPackageHeaderLines(builder, header);
+
+ final StringBuilder summaries = new StringBuilder();
+ final int bodyCount = appendChildSummaries(summaries, directory);
+
+ if (bodyCount > 0) {
+ builder.append(summaries);
+ } else {
+ // No child bodies were available — fall back to the plain name listing so the
+ // model still receives the package contents rather than an empty source.
+ appendContentsSection(builder, contents, true);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Appends the package's own deterministic header block (title plus single-letter fields)
+ * to {@code builder}, mirroring the on-disk {@code .ai.md} header layout.
+ *
+ * @param builder target string builder
+ * @param header the package header to render
+ */
+ private void appendPackageHeaderLines(final StringBuilder builder, final AiMdHeader header) {
builder.append(AiMdHeaderCodec.HEADER_TITLE_PREFIX)
.append(header.title())
.append('\n');
@@ -330,8 +403,101 @@ private String buildPackageSourceText(final AiMdHeader header, final Collection<
.append("X: ")
.append(header.x())
.append('\n');
- appendContentsSection(builder, contents, true);
- return builder.toString();
+ }
+
+ /**
+ * Appends one labelled child-summary block per child {@code .ai.md} under {@code directory}
+ * to {@code builder}, in deterministic ascending name order. Each block is a
+ * {@link #CHILD_SUMMARY_HEADING_PREFIX} heading naming the child followed by that child's
+ * already-generated AI body (or {@link #CHILD_SUMMARY_MISSING_NOTE} when the body is blank).
+ * Sub-package children reuse their aggregated {@code package.ai.md} body; the trailing
+ * {@link #CHILD_DIRECTORY_SUFFIX} marks them as directories.
+ *
+ * @param builder target string builder
+ * @param directory the package directory whose children are scanned
+ * @return the number of children that contributed a non-blank body
+ * @throws IOException if a child {@code .ai.md} file cannot be read
+ */
+ private int appendChildSummaries(final StringBuilder builder, final Path directory) throws IOException {
+ int bodyCount = 0;
+
+ try (Stream stream = Files.list(directory)) {
+ for (Path path : compatibilityHelper.toList(stream.sorted(BY_FILE_NAME))) {
+ final Path fileNamePath = path.getFileName();
+ if (fileNamePath == null) {
+ continue;
+ }
+ final String name = fileNamePath.toString();
+
+ if (Files.isDirectory(path)) {
+ if (hasPackageAiMdFile(path)) {
+ final String body = readChildBody(path.resolve(AiMdHeaderCodec.PACKAGE_AI_MD_FILENAME));
+ if (appendChildSummary(builder, name + CHILD_DIRECTORY_SUFFIX, body)) {
+ bodyCount++;
+ }
+ }
+ continue;
+ }
+
+ if (isAiMdContentFile(name)) {
+ final String body = readChildBody(path);
+ if (appendChildSummary(builder, toChildDisplayName(name), body)) {
+ bodyCount++;
+ }
+ }
+ }
+ }
+
+ return bodyCount;
+ }
+
+ /**
+ * Appends a single child-summary block (heading plus body or missing-note) to {@code builder}.
+ *
+ * @param builder target string builder
+ * @param label child display label (file or sub-package name)
+ * @param body the child's AI body; may be blank
+ * @return {@code true} if a non-blank body was appended, {@code false} for the missing-note case
+ */
+ private boolean appendChildSummary(final StringBuilder builder, final String label, final String body) {
+ builder.append('\n').append(CHILD_SUMMARY_HEADING_PREFIX).append(label).append('\n');
+
+ if (compatibilityHelper.isBlank(body)) {
+ builder.append(CHILD_SUMMARY_MISSING_NOTE).append('\n');
+ return false;
+ }
+
+ builder.append(body);
+ if (!body.endsWith("\n")) {
+ builder.append('\n');
+ }
+ return true;
+ }
+
+ /**
+ * Reads and returns the AI body of a child {@code .ai.md} file, discarding its header.
+ *
+ * @param aiMdFile path to the child {@code .ai.md} file
+ * @return the document body (possibly blank); never {@code null}
+ * @throws IOException if the file cannot be read
+ */
+ private String readChildBody(final Path aiMdFile) throws IOException {
+ return documentCodec.read(aiMdFile).body();
+ }
+
+ /**
+ * Strips the {@link AiMdHeaderCodec#AI_MD_EXTENSION} suffix from a child file name so the
+ * embedded heading reads as the original source name (e.g. {@code "Foo.java.ai.md"} →
+ * {@code "Foo.java"}). Names without the extension are returned unchanged.
+ *
+ * @param fileName the child {@code .ai.md} file name
+ * @return the display name with the {@code .ai.md} extension removed
+ */
+ private String toChildDisplayName(final String fileName) {
+ if (fileName.endsWith(AiMdHeaderCodec.AI_MD_EXTENSION)) {
+ return fileName.substring(0, fileName.length() - AiMdHeaderCodec.AI_MD_EXTENSION.length());
+ }
+ return fileName;
}
private String buildDefaultPackageBody(final Collection contents) {
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java
index 22b3300..ff8ff98 100644
--- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java
@@ -8,15 +8,20 @@
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
+import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
import net.ladenthin.maven.llamacpp.aiindex.CommonTestFixtures;
+import net.ladenthin.maven.llamacpp.aiindex.document.AiGenerationRequest;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocumentCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeader;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderCodec;
import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport;
+import net.ladenthin.maven.llamacpp.aiindex.provider.AiGenerationProvider;
import net.ladenthin.maven.llamacpp.aiindex.provider.MockAiGenerationProvider;
import org.apache.maven.plugin.logging.SystemStreamLog;
import org.junit.jupiter.api.Test;
@@ -89,4 +94,118 @@ public void aggregate_singleChildFile_createsPackageAiMdFile() throws Exception
assertThat(document.body().trim().isEmpty(), is(false));
}
//
+
+ //
+ @Test
+ public void aggregate_childFileBodies_areEmbeddedInPackageSourceText() throws Exception {
+ // arrange
+ final Path temp = Files.createTempDirectory("ai-index-test");
+ final Path outputRoot = temp.resolve("ai");
+ final Path packageDirectory = outputRoot.resolve("main/java/com/example");
+ Files.createDirectories(packageDirectory);
+
+ writeChildFile(packageDirectory.resolve("Foo.java.ai.md"), "Foo.java", "FOO_BODY_MARKER summary of Foo.");
+ writeChildFile(packageDirectory.resolve("Bar.java.ai.md"), "Bar.java", "BAR_BODY_MARKER summary of Bar.");
+
+ final CapturingProvider provider = new CapturingProvider();
+ final AiPromptSupport promptSupport = new AiPromptSupport(CommonTestFixtures.createPackagePromptDefinitions());
+ final PackageIndexer indexer = new PackageIndexer(
+ new SystemStreamLog(),
+ temp,
+ outputRoot,
+ "1.0.0",
+ "0.0.0",
+ Collections.emptyList(),
+ false,
+ provider,
+ CommonTestFixtures.createPackageFieldGenerations(),
+ promptSupport,
+ CommonTestFixtures.createDefaultAiModelDefinitionSupport());
+
+ // act
+ indexer.aggregate(outputRoot);
+
+ // assert: the example package's AI source text contains both child bodies, each under a
+ // per-file heading — not merely the file names.
+ final String exampleSource = provider.sourceTextFor(packageDirectory.resolve("package.ai.md"));
+ assertThat(exampleSource, is(notNullValue()));
+ assertThat(exampleSource.contains("### Foo.java"), is(true));
+ assertThat(exampleSource.contains("FOO_BODY_MARKER summary of Foo."), is(true));
+ assertThat(exampleSource.contains("### Bar.java"), is(true));
+ assertThat(exampleSource.contains("BAR_BODY_MARKER summary of Bar."), is(true));
+ // the raw .ai.md file-name form must NOT leak into the labelled headings
+ assertThat(exampleSource.contains("### Foo.java.ai.md"), is(false));
+ }
+
+ @Test
+ public void aggregate_subPackageBody_isEmbeddedInParentSourceText() throws Exception {
+ // arrange: a child file in the leaf package, whose generated body bubbles up into the parent.
+ final Path temp = Files.createTempDirectory("ai-index-test");
+ final Path outputRoot = temp.resolve("ai");
+ final Path leafDirectory = outputRoot.resolve("main/java/com/example");
+ Files.createDirectories(leafDirectory);
+
+ writeChildFile(leafDirectory.resolve("Foo.java.ai.md"), "Foo.java", "FOO_BODY_MARKER summary of Foo.");
+
+ final CapturingProvider provider = new CapturingProvider();
+ final AiPromptSupport promptSupport = new AiPromptSupport(CommonTestFixtures.createPackagePromptDefinitions());
+ final PackageIndexer indexer = new PackageIndexer(
+ new SystemStreamLog(),
+ temp,
+ outputRoot,
+ "1.0.0",
+ "0.0.0",
+ Collections.emptyList(),
+ false,
+ provider,
+ CommonTestFixtures.createPackageFieldGenerations(),
+ promptSupport,
+ CommonTestFixtures.createDefaultAiModelDefinitionSupport());
+
+ // act
+ indexer.aggregate(outputRoot);
+
+ // assert: the parent ("com") package source embeds the leaf sub-package's generated body
+ // under a directory-suffixed heading.
+ final Path comPackageFile = outputRoot.resolve("main/java/com").resolve("package.ai.md");
+ final String comSource = provider.sourceTextFor(comPackageFile);
+ assertThat(comSource, is(notNullValue()));
+ assertThat(comSource.contains("### example/"), is(true));
+ assertThat(comSource.contains(CapturingProvider.GENERATED_BODY), is(true));
+ }
+ //
+
+ private void writeChildFile(final Path file, final String title, final String body) throws IOException {
+ final AiMdHeader header = new AiMdHeader(
+ title,
+ AiMdHeaderCodec.HEADER_VERSION_1_0,
+ "AAAAAAAA",
+ "2026-03-16T00:00:00Z",
+ "2026-03-16T00:00:10Z",
+ "1.0.0",
+ "0.0.0",
+ AiMdHeaderCodec.NODE_TYPE_FILE);
+ documentCodec.write(file, new AiMdDocument(header, body));
+ }
+
+ /**
+ * Test {@link AiGenerationProvider} that records the source text passed for each context file
+ * and returns a fixed non-blank body, so a generated {@code package.ai.md} body is never blank.
+ */
+ private static final class CapturingProvider implements AiGenerationProvider {
+
+ static final String GENERATED_BODY = "CAPTURED_PACKAGE_SUMMARY";
+
+ private final Map sourceTextByFile = new HashMap<>();
+
+ @Override
+ public String generate(final AiGenerationRequest request) {
+ sourceTextByFile.put(request.sourceFile(), request.sourceText());
+ return GENERATED_BODY;
+ }
+
+ String sourceTextFor(final Path file) {
+ return sourceTextByFile.get(file);
+ }
+ }
}