Implement layered maps and context retrieval

This commit is contained in:
2026-06-11 12:56:18 +02:00
parent 010e4b83eb
commit c6064f8d94
35 changed files with 4410 additions and 383 deletions
+143 -52
View File
@@ -1,71 +1,162 @@
import { dirname, join, basename, relative } from "path";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { parsePackageMap, renderPackageMap } from "./format.js";
import { extractFileLLM } from "./llm/llm-extract.js";
import { extractFileAST } from "./ast/ast-extract.js";
import { mergeFileData } from "./merge.js";
import { generateDirectoryMap } from "./init.js";
import { readdirSync, statSync } from "fs";
import { basename, dirname, relative, resolve } from "path";
import { existsSync, readFileSync } from "fs";
import { parsePackageMap } from "./format.js";
import {
discoverProject,
generateDirectoryArtifacts,
buildDirectoryContext,
} from "./init.js";
import type { DirectoryEntry } from "./discover.js";
import type { LLMClient } from "./llm/llm-client.js";
import { loadConfig } from "./config.js";
const SMALL_PACKAGE_THRESHOLD = 10;
export type PatchMode = "auto" | "small" | "structural";
export interface PatchOptions {
patchMode?: PatchMode;
rootPath?: string;
}
const SMALL_CHANGE_FILE_WINDOW = 3;
const STRUCTURAL_FILE_HINT =
/(^|\b)(index|config|cli|command|init|format|discover|patch|validate)(\b|\.)/i;
export async function patchFile(
filePath: string,
llmClient?: LLMClient,
cacheDir?: string,
options: PatchOptions = {},
): Promise<void> {
const dirPath = dirname(filePath);
const mapPath = join(dirPath, ".pi-map.md");
const rootPath = resolve(options.rootPath ?? cacheDir ?? process.cwd());
const absFilePath = resolve(rootPath, filePath);
const dirPath = dirname(absFilePath);
const relDir = normalizeRelativePath(relative(rootPath, dirPath));
const entries = discoverProject(rootPath);
const entry = entries.find((candidate) => candidate.relativePath === relDir);
if (!existsSync(mapPath)) {
// No map exists yet — would need to generate from scratch
console.warn(`No .pi-map.md found in ${dirPath}`);
if (!entry) {
console.warn(`No discovered directory entry found for ${relDir}`);
return;
}
const allFiles = readdirSync(dirPath).filter(
(f: string) => !f.startsWith(".") && !f.endsWith(".md"),
const config = loadConfig(rootPath);
const routingOpts = {
tagCap: config.tagCap,
workflowHintCap: config.workflowHintCap,
};
const mode = determinePatchMode(
entry,
absFilePath,
rootPath,
options.patchMode,
);
const changedCtx = buildDirectoryContext(entries, entry);
await generateDirectoryArtifacts(
entry,
changedCtx,
llmClient,
cacheDir,
undefined,
routingOpts,
"both",
);
const isSmallPackage = allFiles.length < SMALL_PACKAGE_THRESHOLD;
if (isSmallPackage) {
// Full rewrite for small packages
const files = allFiles.filter((f) => {
const st = statSync(join(dirPath, f));
return st.isFile();
});
const relDir = relative(process.cwd(), dirPath) || ".";
await generateDirectoryMap(
{
dirPath,
relativePath: relDir,
files,
},
for (const ancestor of getAncestorEntries(entries, changedCtx, entry)) {
const ancestorCtx = buildDirectoryContext(entries, ancestor);
await generateDirectoryArtifacts(
ancestor,
ancestorCtx,
llmClient,
cacheDir,
undefined,
routingOpts,
mode === "small" ? "index" : "both",
);
console.log(
`Full rewrite of ${mapPath} (small package: ${allFiles.length} files)`,
);
} else {
// Section-level patch
const existing = parsePackageMap(readFileSync(mapPath, "utf8"));
const llmData = await extractFileLLM(filePath, llmClient, cacheDir);
const astData = await extractFileAST(filePath);
const fileName = basename(filePath);
const updatedFile = mergeFileData(fileName, llmData, astData);
// Replace the matching file entry
const idx = existing.files.findIndex((f) => f.name === updatedFile.name);
if (idx >= 0) {
existing.files[idx] = updatedFile;
} else {
existing.files.push(updatedFile);
}
existing.dirty = `${new Date().toISOString()}: ${fileName} patched (section-level)`;
writeFileSync(mapPath, renderPackageMap(existing));
console.log(`Patched ${mapPath}`);
}
console.log(
`Patched ${joinArtifactPath(relDir, ".pi-map.md")} (patchMode: ${mode})`,
);
}
function determinePatchMode(
entry: DirectoryEntry,
absFilePath: string,
rootPath: string,
explicitMode: PatchMode = "auto",
): Exclude<PatchMode, "auto"> {
if (explicitMode !== "auto") {
return explicitMode;
}
const existingMapPath = resolve(entry.dirPath, ".pi-map.md");
const fileName = basename(absFilePath);
const childCount =
entry.relativePath === "."
? 0
: countDirectChildren(rootPath, entry.relativePath);
if (childCount > 0) {
return "structural";
}
if (entry.files.length > SMALL_CHANGE_FILE_WINDOW) {
return "structural";
}
if (STRUCTURAL_FILE_HINT.test(fileName)) {
return "structural";
}
if (existsSync(existingMapPath)) {
const existing = parsePackageMap(readFileSync(existingMapPath, "utf8"));
const existingFile = existing.files.find(
(candidate) => candidate.name === fileName,
);
if (!existingFile) {
return "structural";
}
}
return "small";
}
function countDirectChildren(rootPath: string, relDir: string): number {
const entries = discoverProject(rootPath);
return entries.filter((entry) => {
if (entry.relativePath === "." || entry.relativePath === relDir)
return false;
const parent = normalizeRelativePath(
entry.relativePath.split("/").slice(0, -1).join("/"),
);
return parent === relDir;
}).length;
}
function getAncestorEntries(
entries: DirectoryEntry[],
ctx: ReturnType<typeof buildDirectoryContext>,
entry: DirectoryEntry,
): DirectoryEntry[] {
const result: DirectoryEntry[] = [];
let parent = ctx.parentMap.get(entry.relativePath);
while (parent) {
const ancestor = entries.find(
(candidate) => candidate.relativePath === parent,
);
if (ancestor) {
result.push(ancestor);
}
parent = ctx.parentMap.get(parent);
}
return result;
}
function normalizeRelativePath(relPath: string): string {
if (!relPath || relPath === ".") return ".";
return relPath.replace(/\\/g, "/");
}
function joinArtifactPath(relDir: string, artifactName: string): string {
return relDir === "." ? artifactName : `${relDir}/${artifactName}`;
}