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
+265
View File
@@ -0,0 +1,265 @@
import type {
DirectoryArtifactModel,
FileEntry,
RoutingMetadataOptions,
WorkflowHint,
} from "./directory-model.js";
const DEFAULT_TAG_CAP = 8;
const DEFAULT_WORKFLOW_HINT_CAP = 5;
const STOP_WORDS = new Set([
"the",
"and",
"for",
"with",
"from",
"into",
"onto",
"this",
"that",
"then",
"than",
"when",
"where",
"what",
"how",
"why",
"who",
"which",
"while",
"during",
"before",
"after",
"above",
"below",
"between",
"among",
"through",
"over",
"under",
"again",
"further",
"once",
"here",
"there",
"all",
"any",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"only",
"own",
"same",
"so",
"too",
"very",
"can",
"will",
"just",
"should",
"now",
"use",
"using",
"used",
"via",
"based",
"build",
"built",
"used",
"file",
"files",
"module",
"modules",
"function",
"functions",
"class",
"classes",
"export",
"exports",
"import",
"imports",
]);
export function populateRoutingMetadata(
model: DirectoryArtifactModel,
opts: RoutingMetadataOptions = {},
): void {
const tagCap = opts.tagCap ?? DEFAULT_TAG_CAP;
const workflowHintCap = opts.workflowHintCap ?? DEFAULT_WORKFLOW_HINT_CAP;
model.tags = generateTags(model.files, tagCap);
model.symbols = generateSymbols(model.files, tagCap);
model.workflows = generateWorkflows(model, workflowHintCap);
}
function generateTags(files: FileEntry[], cap: number): string[] {
const scores = new Map<string, number>();
for (const file of files) {
// Score words from file purpose
for (const word of extractWords(file.purpose)) {
scores.set(word, (scores.get(word) ?? 0) + 1);
}
// Score words from export names (camelCase split)
for (const exp of file.exports) {
const cleanExp = exp.replace(/^(class|func|method):/, "").split("(")[0];
for (const word of splitCamelCase(cleanExp)) {
if (word.length > 1) {
scores.set(word, (scores.get(word) ?? 0) + 2);
}
}
}
// Score dep path segments
for (const dep of file.deps) {
for (const segment of dep.split(/[/\-.]/)) {
const word = segment.toLowerCase();
if (word.length > 1 && !STOP_WORDS.has(word)) {
scores.set(word, (scores.get(word) ?? 0) + 1);
}
}
}
// Score from file name (stem, no extension)
const stem = file.name.replace(/\.[^.]+$/, "");
for (const word of splitCamelCase(stem)) {
if (word.length > 1) {
scores.set(word, (scores.get(word) ?? 0) + 2);
}
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([word]) => word)
.slice(0, cap);
}
function generateSymbols(files: FileEntry[], cap: number): string[] {
const scores = new Map<string, number>();
const seen = new Set<string>();
for (const file of files) {
for (const exp of file.exports) {
// Prefer clean identifiers over encoded DSL entries when possible
let symbol = exp;
let score = 1;
if (exp.startsWith("class:")) {
symbol = exp.slice(6).split(" ")[0];
score = 3;
} else if (exp.startsWith("func:")) {
symbol = exp.slice(5).split("(")[0];
score = 2;
} else if (exp.startsWith("method:")) {
symbol = exp.slice(7).split("(")[0];
score = 2;
}
if (!seen.has(symbol)) {
seen.add(symbol);
scores.set(symbol, (scores.get(symbol) ?? 0) + score);
}
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([name]) => name)
.slice(0, cap);
}
function generateWorkflows(
model: DirectoryArtifactModel,
cap: number,
): WorkflowHint[] {
const workflows: WorkflowHint[] = [];
const dirName = model.dir.split("/").pop() || model.dir;
const baseName = dirName === "." ? "project" : dirName;
// Only generate workflows when we have reasonable structural confidence
const hasSourceFiles = model.files.some((f) =>
/\.(ts|tsx|js|jsx|py|go|rs|java)$/.test(f.name),
);
if (!hasSourceFiles || model.files.length === 0) {
return workflows;
}
const sourceFiles = model.files.filter(
(f) =>
!/\.(test|spec)\./.test(f.name) && !/\.(md|json|yaml|yml)$/.test(f.name),
);
const testFiles = model.files.filter((f) => /\.(test|spec)\./.test(f.name));
const configFiles = model.files.filter(
(f) =>
f.name.includes("config") ||
/\.(json|yaml|yml|toml)$/.test(f.name) ||
f.name === ".env",
);
const cliFiles = model.files.filter(
(f) => f.name.includes("cli") || f.name.includes("command"),
);
if (sourceFiles.length > 0) {
workflows.push({
task: `change ${baseName} behavior`,
read: sourceFiles.slice(0, 3).map((f) => f.name),
});
}
if (testFiles.length > 0) {
workflows.push({
task: `update ${baseName} tests`,
read: testFiles.slice(0, 3).map((f) => f.name),
});
}
if (cliFiles.length > 0) {
workflows.push({
task: `change ${baseName} CLI`,
read: cliFiles.slice(0, 3).map((f) => f.name),
});
}
if (configFiles.length > 0) {
workflows.push({
task: `change ${baseName} config`,
read: configFiles.slice(0, 3).map((f) => f.name),
});
}
// Add a directory-navigation workflow for non-leaf directories
if (model.children.length > 0) {
workflows.push({
task: `explore ${baseName} subdirectories`,
index: model.children
.slice(0, 3)
.map((child) => `${child}/.pi-map.index.md`),
});
}
return workflows.slice(0, cap);
}
function extractWords(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
}
function splitCamelCase(str: string): string[] {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
}