Files
pi-map/pi-extension.ts
T
alex 57c0f08210 refactor: Pi extension uses heuristics+AST instead of direct LLM calls
Inside Pi, the extension no longer attempts to call the LLM directly
(which Pi's ExtensionAPI doesn't support). Instead:

- project_map_init: generates maps using AST + heuristics (fast, free)
- project_map_patch: rewrites the file's directory with heuristics
- project_map_reinit: full heuristic regeneration
- project_map_validate: unchanged (no LLM needed)

The CLI still supports real LLM calls via --llm-provider=kimi|openai.
This separates concerns:
- Pi extension: deterministic structural analysis
- CLI: rich semantic analysis with configurable LLM
2026-06-10 10:28:15 +02:00

271 lines
7.8 KiB
TypeScript

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
import { join, relative, dirname } from "path";
import { validateMaps } from "./src/index.js";
import { discoverProject } from "./src/discover.js";
import { extractFileAST } from "./src/ast-extract.js";
import { extractFileHeuristic, extractPackageHeuristic } from "./src/llm-extract.js";
import { mergeFileData } from "./src/merge.js";
import { renderPackageMap, type FileEntry } from "./src/format.js";
/**
* Generate maps using heuristics + AST only (no LLM).
* Inside Pi, we let Pi's agent handle semantic analysis.
* The tool does structural work; the agent can refine later.
*/
async function generateMapHeuristic(rootPath: string): Promise<void> {
const entries = discoverProject(rootPath);
for (const entry of entries) {
const fileData: FileEntry[] = [];
for (const file of entry.files) {
const filePath = join(entry.dirPath, file);
const astData = await extractFileAST(filePath);
const heuristicData = await extractFileHeuristic(filePath);
fileData.push(mergeFileData(file, heuristicData, astData));
}
const packageData = await extractPackageHeuristic(entry.relativePath, fileData);
const mapData = {
path: entry.relativePath,
role: packageData.role,
files: fileData,
arch: packageData.arch,
dirty: "-",
};
writeFileSync(join(entry.dirPath, ".pi-map.md"), renderPackageMap(mapData));
}
}
function findPiMapFiles(cwd: string): string[] {
const results: string[] = [];
function walk(dir: string) {
let entries: import("fs").Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (
entry.isDirectory() &&
!entry.name.startsWith(".") &&
entry.name !== "node_modules"
) {
walk(join(dir, entry.name));
}
}
try {
statSync(join(dir, ".pi-map.md"));
results.push(relative(cwd, join(dir, ".pi-map.md")));
} catch {
// no map in this dir
}
}
walk(cwd);
return results;
}
function isDirty(content: string): boolean {
return content.includes("## dirty") && !content.includes("## dirty\n-");
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "project_map_init",
label: "Project Map Init",
description:
"Generate .pi-map.md analysis files for the entire project or a subdirectory",
promptSnippet:
"Initialize project analysis files for codebase understanding",
promptGuidelines: [
"Use project_map_init when starting work on a new project or after significant restructuring",
"Run project_map_init when .pi-map.md files are missing or severely outdated",
],
parameters: Type.Object({
path: Type.Optional(
Type.String({
description: "Project root path (default: current directory)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const targetPath = params.path || ctx.cwd;
await generateMapHeuristic(targetPath);
return {
content: [
{
type: "text",
text: `Generated heuristic .pi-map.md files for ${targetPath}.`,
},
],
details: { success: true, cwd: ctx.cwd },
};
} catch (err: any) {
const msg = String(err);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
details: { success: false, error: msg },
};
}
},
});
pi.registerTool({
name: "project_map_patch",
label: "Project Map Patch",
description:
"Update .pi-map.md for the directory containing a changed file",
promptSnippet: "Update project analysis after editing a source file",
promptGuidelines: [
"Use project_map_patch immediately after editing any source file",
"Pass the absolute or relative path of the modified file",
],
parameters: Type.Object({
file_path: Type.String({
description: "Path to the modified file",
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const dirPath = dirname(params.file_path);
await generateMapHeuristic(dirPath);
return {
content: [
{
type: "text",
text: `Patched map for ${params.file_path}`,
},
],
details: { success: true },
};
} catch (err: any) {
const msg = String(err);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
details: { success: false, error: msg },
};
}
},
});
pi.registerTool({
name: "project_map_validate",
label: "Project Map Validate",
description: "Check all .pi-map.md files for staleness and discrepancies",
promptSnippet: "Validate project analysis files for accuracy",
promptGuidelines: [
"Use project_map_validate before making architectural decisions if you suspect stale data",
"Use project_map_validate to detect files that were deleted or added outside the agent",
],
parameters: Type.Object({
path: Type.Optional(
Type.String({
description: "Project root path (default: current directory)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const targetPath = params.path || ctx.cwd;
const result = await validateMaps(targetPath, {
fix: false,
verbose: false,
});
const text = result.clean
? "All .pi-map.md files are clean."
: `Found ${result.discrepancies.length} discrepancies:\n` +
result.discrepancies
.map((d) => ` [${d.type}] ${d.path}: ${d.message}`)
.join("\n");
return {
content: [{ type: "text", text }],
details: { success: true, clean: result.clean },
};
} catch (err: any) {
const msg = String(err);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
details: { success: false, error: msg },
};
}
},
});
pi.registerTool({
name: "project_map_reinit",
label: "Project Map Reinit",
description: "Force full regeneration of all .pi-map.md files",
promptSnippet: "Force full regeneration of project analysis files",
promptGuidelines: [
"Use project_map_reinit when validation shows widespread staleness",
"Use project_map_reinit after pulling major changes from version control",
],
parameters: Type.Object({
path: Type.Optional(
Type.String({
description: "Path to regenerate (default: entire project)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const targetPath = params.path || ctx.cwd;
await generateMapHeuristic(targetPath);
return {
content: [
{
type: "text",
text: `Regenerated heuristic maps for ${targetPath}`,
},
],
details: { success: true },
};
} catch (err: any) {
const msg = String(err);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
details: { success: false, error: msg },
};
}
},
});
// Auto-load .pi-map.md files on session start
pi.on("session_start", async (_event, ctx) => {
const mapFiles = findPiMapFiles(ctx.cwd);
if (mapFiles.length === 0) return;
const dirtyFiles = mapFiles.filter((f) => {
try {
const content = readFileSync(join(ctx.cwd, f), "utf8");
return isDirty(content);
} catch {
return false;
}
});
if (dirtyFiles.length > 0) {
ctx.ui.notify(
`pi-project-map: ${dirtyFiles.length} dirty packages detected. Run project_map_validate or project_map_reinit.`,
"warning",
);
}
});
// Inject maintenance instructions before agent starts
pi.on("before_agent_start", async (_event, _ctx) => {
const mapFiles = findPiMapFiles(_ctx.cwd);
if (mapFiles.length === 0) return {};
return {
message: {
customType: "pi-project-map-hint",
content:
"📋 Project map active: If you modify any source file, run `project_map_patch` with the file path. If you suspect staleness, run `project_map_validate`.",
display: false,
},
};
});
}