feat: M7 proper LLM integration with dual providers, caching, and parallel batching

- Add LLM client abstraction (src/llm-client.ts) with factory pattern
- Add OpenAI-compatible external client (src/external-llm-client.ts)
- Add Kimi.com client using Anthropic-based API (src/kimi-llm-client.ts)
- Add Pi native LLM stub (src/pi-llm-client.ts) for future ExtensionAPI wiring
- Add SHA-256 disk cache at ~/.cache/pi-project-map/ (src/llm-cache.ts)
- Add parallel batching with p-limit, retry + exponential backoff (src/llm-batch.ts)
- Rewrite llm-extract.ts to use real LLM calls with structured prompts
  - File-level: PURPOSE, DEPS, CONCEPTS
  - Package-level: ROLE, ARCH
  - Context truncation, 50KB skip, cache before LLM call
- Wire CLI with --llm-provider, --llm-model, --llm-base-url flags
- Update config.ts with llmProvider, llmBaseUrl fields
- Update init.ts and patch.ts to accept optional LLMClient
- Add sample project fixture for manual testing
- Add tests: llm-cache (3), llm-batch (5), llm-integration (8 with real Kimi API),
  pi-extension (14 mocked)
- All 56 tests pass
This commit is contained in:
2026-06-09 22:49:34 +02:00
parent 7b67205d43
commit 69d3acda5d
32 changed files with 1565 additions and 264 deletions
+64 -10
View File
@@ -4,6 +4,8 @@ import { patchFile } from "./patch.js";
import { validateMaps } from "./validate.js";
import { reinitPath } from "./init.js";
import { discoverProject } from "./discover.js";
import { createLLMClient, LLMError } from "./llm-client.js";
import { loadConfig } from "./config.js";
import pc from "picocolors";
const args = process.argv.slice(2);
@@ -31,11 +33,22 @@ function printUsage() {
console.log(
` project-map ${pc.cyan("--version")} Show version\n`,
);
console.log(`${pc.bold("Options:")}`);
console.log(
` --llm-provider=openai|kimi LLM provider (default: config or openai)`,
);
console.log(
` --llm-model=<model> LLM model name (or set LLM_MODEL env var)`,
);
console.log(` --llm-base-url=<url> Custom base URL for LLM API\n`);
console.log(`${pc.bold("Examples:")}`);
console.log(` project-map init`);
console.log(` project-map patch src/components/Button.tsx`);
console.log(` project-map validate --fix`);
console.log(` project-map reinit`);
console.log(
` project-map init --llm-provider=kimi --llm-model=kimi-k2-6`,
);
}
function printVersion() {
@@ -50,17 +63,49 @@ function formatCount(count: number, label: string): string {
return `${pc.bold(String(count))} ${count === 1 ? label : plural}`;
}
function parseValidateArgs(args: string[]): { path: string; fix: boolean } {
function parseArgs(args: string[]): {
path: string;
fix: boolean;
llmProvider?: string;
llmModel?: string;
llmBaseUrl?: string;
positional: string[];
} {
let path = ".";
let fix = false;
let llmProvider: string | undefined;
let llmModel: string | undefined;
let llmBaseUrl: string | undefined;
const positional: string[] = [];
for (const arg of args.slice(1)) {
if (arg === "--fix") {
fix = true;
} else if (arg.startsWith("--llm-provider=")) {
llmProvider = arg.slice("--llm-provider=".length);
} else if (arg.startsWith("--llm-model=")) {
llmModel = arg.slice("--llm-model=".length);
} else if (arg.startsWith("--llm-base-url=")) {
llmBaseUrl = arg.slice("--llm-base-url=".length);
} else if (!arg.startsWith("-")) {
positional.push(arg);
path = arg;
}
}
return { path, fix };
return { path, fix, llmProvider, llmModel, llmBaseUrl, positional };
}
function createClientFromArgs(args: ReturnType<typeof parseArgs>) {
const config = loadConfig();
const provider = (args.llmProvider || config.llmProvider) as
| "openai"
| "kimi"
| "pi";
return createLLMClient(provider, {
model: args.llmModel || config.llmModel || process.env.LLM_MODEL,
baseUrl: args.llmBaseUrl || config.llmBaseUrl,
});
}
async function main() {
@@ -74,13 +119,16 @@ async function main() {
process.exit(0);
}
const parsed = parseArgs(args);
switch (command) {
case "init": {
const targetPath = args[1] || ".";
const targetPath = parsed.positional[0] || ".";
const start = Date.now();
const entries = discoverProject(targetPath);
console.log(`Scanning ${formatCount(entries.length, "directory")}...`);
await initProject(targetPath, { verbose: false });
const client = createClientFromArgs(parsed);
await initProject(targetPath, { verbose: false, llmClient: client });
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`${pc.green("✓")} Generated ${formatCount(entries.length, ".pi-map.md file")} in ${elapsed}s`,
@@ -88,18 +136,19 @@ async function main() {
break;
}
case "patch": {
if (!args[1]) {
if (!parsed.positional[0]) {
console.error(
`${pc.red("Error:")} Missing file path. Usage: project-map patch <file>`,
);
process.exit(1);
}
await patchFile(args[1]);
const client = createClientFromArgs(parsed);
await patchFile(parsed.positional[0], client);
console.log(`${pc.green("✓")} Patched`);
break;
}
case "validate": {
const { path, fix } = parseValidateArgs(args);
const { path, fix } = parsed;
const result = await validateMaps(path, { fix, verbose: true });
if (result.clean) {
console.log(`${pc.green("✓")} All .pi-map.md files are clean.`);
@@ -123,13 +172,14 @@ async function main() {
break;
}
case "reinit": {
const targetPath = args[1] || ".";
const targetPath = parsed.positional[0] || ".";
const start = Date.now();
const entries = discoverProject(targetPath);
console.log(
`Regenerating ${formatCount(entries.length, ".pi-map.md file")}...`,
);
await reinitPath(targetPath, { verbose: false });
const client = createClientFromArgs(parsed);
await reinitPath(targetPath, { verbose: false, llmClient: client });
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`${pc.green("✓")} Regenerated in ${elapsed}s`);
break;
@@ -142,6 +192,10 @@ async function main() {
}
main().catch((err) => {
console.error(`${pc.red("Error:")} ${err.message}`);
if (err instanceof LLMError) {
console.error(`${pc.red("LLM Error:")} ${err.message}`);
} else {
console.error(`${pc.red("Error:")} ${err.message}`);
}
process.exit(1);
});
+3
View File
@@ -4,7 +4,9 @@ import { join } from "path";
export interface SkillConfig {
ignorePatterns: string[];
smallPackageThreshold: number;
llmProvider: "openai" | "kimi" | "pi";
llmModel: string;
llmBaseUrl?: string;
contextBudget: number;
autoInjectPrompt: boolean;
}
@@ -32,6 +34,7 @@ export const DEFAULT_CONFIG: SkillConfig = {
".prettiercache",
],
smallPackageThreshold: 10,
llmProvider: "openai",
llmModel: "gpt-4o-mini",
contextBudget: 4000,
autoInjectPrompt: true,
+50
View File
@@ -0,0 +1,50 @@
import OpenAI from "openai";
import { LLMError } from "./llm-error.js";
import type { LLMClient, LLMClientOptions } from "./llm-client.js";
export class ExternalLLMClient implements LLMClient {
private client: OpenAI;
private model: string;
constructor(options: LLMClientOptions = {}) {
const apiKey = options.apiKey || process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new LLMError(
"No OpenAI API key provided. Set OPENAI_API_KEY environment variable or pass apiKey in options.",
);
}
this.client = new OpenAI({
apiKey,
baseURL: options.baseUrl,
});
this.model =
options.model ||
process.env.OPENAI_MODEL ||
process.env.LLM_MODEL ||
"gpt-4o-mini";
}
async complete(prompt: string): Promise<string> {
try {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [
{
role: "system",
content:
"You are a code analysis assistant. Analyze the provided file and respond with concise, structured information.",
},
{ role: "user", content: prompt },
],
temperature: 0.1,
max_tokens: 256,
});
return response.choices[0]?.message?.content?.trim() || "";
} catch (err: any) {
throw new LLMError(
`External LLM request failed: ${err.message || String(err)}`,
err,
);
}
}
}
+17 -6
View File
@@ -9,34 +9,45 @@ import { extractFileAST } from "./ast-extract.js";
import { mergeFileData } from "./merge.js";
import { writeFileSync } from "fs";
import { join } from "path";
import type { LLMClient } from "./llm-client.js";
export interface InitOptions {
verbose?: boolean;
llmClient?: LLMClient;
}
export async function initProject(
rootPath: string,
options?: { verbose?: boolean },
options: InitOptions = {},
): Promise<void> {
const entries = discoverProject(rootPath);
for (const entry of entries) {
await generateDirectoryMap(entry);
await generateDirectoryMap(entry, options.llmClient);
}
if (options?.verbose !== false) {
if (options.verbose !== false) {
console.log(`Generated ${entries.length} .pi-map.md files`);
}
}
export async function generateDirectoryMap(
entry: DirectoryEntry,
llmClient?: LLMClient,
): Promise<FileEntry[]> {
const fileData: FileEntry[] = [];
for (const file of entry.files) {
const filePath = join(entry.dirPath, file);
const llmData = await extractFileLLM(filePath);
const llmData = await extractFileLLM(filePath, llmClient);
const astData = await extractFileAST(filePath);
fileData.push(mergeFileData(file, llmData, astData));
}
const packageData = await extractPackageLLM(entry.relativePath, fileData);
const packageData = await extractPackageLLM(
entry.relativePath,
fileData,
llmClient,
);
const mapData: PackageMapData = {
path: entry.relativePath,
@@ -53,7 +64,7 @@ export async function generateDirectoryMap(
export async function reinitPath(
path: string,
options?: { verbose?: boolean },
options: InitOptions = {},
): Promise<void> {
// Full regeneration clears all dirty markers by overwriting every .pi-map.md
await initProject(path, options);
+79
View File
@@ -0,0 +1,79 @@
import { LLMError } from "./llm-error.js";
import type { LLMClient, LLMClientOptions } from "./llm-client.js";
/**
* Kimi.com LLM Client.
*
* Uses the Anthropic-based API at https://api.kimi.com/coding/
* Set KIMI_API_KEY environment variable or pass apiKey in options.
*/
export class KimiLLMClient implements LLMClient {
private apiKey: string;
private model: string;
private baseUrl: string;
constructor(options: LLMClientOptions = {}) {
const apiKey =
options.apiKey ||
process.env.KIMI_API_KEY ||
process.env.KIMI_COM_API_KEY;
if (!apiKey) {
throw new LLMError(
"No Kimi API key provided. Set KIMI_API_KEY environment variable or pass apiKey in options.",
);
}
this.apiKey = apiKey;
this.model =
options.model ||
process.env.KIMI_MODEL ||
process.env.LLM_MODEL ||
"kimi-k2-6";
this.baseUrl = options.baseUrl || "https://api.kimi.com/coding";
}
async complete(prompt: string): Promise<string> {
try {
const response = await fetch(`${this.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: this.model,
max_tokens: 256,
messages: [
{
role: "user",
content: prompt,
},
],
}),
});
if (!response.ok) {
const text = await response.text();
throw new LLMError(`Kimi API error ${response.status}: ${text}`);
}
const data = (await response.json()) as {
content?: Array<{ type: string; text?: string }>;
error?: { message: string };
};
if (data.error) {
throw new LLMError(`Kimi error: ${data.error.message}`);
}
const text = data.content?.[0]?.text?.trim() || "";
return text;
} catch (err: any) {
if (err instanceof LLMError) throw err;
throw new LLMError(
`Kimi request failed: ${err.message || String(err)}`,
err,
);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import pLimit from "p-limit";
import { LLMError } from "./llm-error.js";
export interface BatchOptions {
concurrency?: number;
batchDelayMs?: number;
maxRetries?: number;
retryDelaysMs?: number[];
}
const DEFAULT_OPTIONS: Required<BatchOptions> = {
concurrency: 4,
batchDelayMs: 100,
maxRetries: 3,
retryDelaysMs: [1000, 2000, 4000],
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: Pick<BatchOptions, "maxRetries" | "retryDelaysMs"> = {},
): Promise<T> {
const maxRetries = options.maxRetries ?? DEFAULT_OPTIONS.maxRetries;
const delays = options.retryDelaysMs ?? DEFAULT_OPTIONS.retryDelaysMs;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
const delay = delays[attempt] ?? delays[delays.length - 1] ?? 1000;
await sleep(delay);
}
}
}
throw lastError;
}
export async function processFiles<T, R>(
files: T[],
processor: (file: T) => Promise<R>,
options: BatchOptions = {},
): Promise<R[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const limit = pLimit(opts.concurrency);
const results: R[] = [];
let batchCount = 0;
const tasks = files.map((file, index) =>
limit(async () => {
// Small delay between batches based on index
if (index > 0 && index % opts.concurrency === 0) {
batchCount++;
await sleep(opts.batchDelayMs);
}
return withRetry(() => processor(file), opts);
}),
);
const settled = await Promise.all(tasks);
return settled;
}
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
import { join } from "path";
import { homedir } from "os";
const CACHE_DIR = join(homedir(), ".cache", "pi-project-map");
const CACHE_FILE = join(CACHE_DIR, "llm-cache.json");
interface CacheEntry {
result: string;
ts: number;
}
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true });
}
}
function loadCache(): Record<string, CacheEntry> {
if (!existsSync(CACHE_FILE)) return {};
try {
const raw = readFileSync(CACHE_FILE, "utf8");
return JSON.parse(raw) as Record<string, CacheEntry>;
} catch {
// Corrupted cache — start fresh
return {};
}
}
function saveCache(cache: Record<string, CacheEntry>): void {
ensureCacheDir();
const tmp = CACHE_FILE + ".tmp";
writeFileSync(tmp, JSON.stringify(cache, null, 2));
renameSync(tmp, CACHE_FILE);
}
export function getCached(hash: string): string | undefined {
const cache = loadCache();
return cache[hash]?.result;
}
export function setCached(hash: string, result: string): void {
const cache = loadCache();
cache[hash] = { result, ts: Date.now() };
saveCache(cache);
}
+31
View File
@@ -0,0 +1,31 @@
import { LLMError } from "./llm-error.js";
import { ExternalLLMClient } from "./external-llm-client.js";
import { KimiLLMClient } from "./kimi-llm-client.js";
import { PiLLMClient } from "./pi-llm-client.js";
export interface LLMClient {
complete(prompt: string): Promise<string>;
}
export interface LLMClientOptions {
apiKey?: string;
model?: string;
baseUrl?: string;
// Pi-specific
extensionContext?: unknown;
}
export function createLLMClient(
mode: "pi" | "openai" | "kimi",
options: LLMClientOptions = {},
): LLMClient {
if (mode === "pi") {
return new PiLLMClient(options.extensionContext);
}
if (mode === "kimi") {
return new KimiLLMClient(options);
}
return new ExternalLLMClient(options);
}
export { LLMError };
+9
View File
@@ -0,0 +1,9 @@
export class LLMError extends Error {
constructor(
message: string,
public readonly cause?: unknown,
) {
super(message);
this.name = "LLMError";
}
}
+300 -215
View File
@@ -1,11 +1,15 @@
import { readFileSync } from "fs";
import { readFileSync, statSync } from "fs";
import { createHash } from "crypto";
import { extname, basename } from "path";
import type { LLMClient } from "./llm-client.js";
import { getCached, setCached } from "./llm-cache.js";
import { LLMError } from "./llm-error.js";
interface LLMFileData {
purpose: string;
exports: string[];
deps: string[];
concepts: string[];
}
interface LLMPackageData {
@@ -13,9 +17,11 @@ interface LLMPackageData {
arch: string;
}
const cache = new Map<string, LLMFileData>();
const MAX_FILE_SIZE = 50 * 1024; // 50KB
const CONTEXT_BUDGET = 4000; // tokens
const CHARS_PER_TOKEN = 4; // approximate for ASCII
// Heuristic patterns for common file types
// Heuristic patterns for common file types (used as fallback + for tests)
const FILE_TYPE_PURPOSES: Record<string, string> = {
".ts": "TypeScript module",
".tsx": "React component",
@@ -51,236 +57,178 @@ const FILE_TYPE_PURPOSES: Record<string, string> = {
".svelte": "Svelte component",
};
export async function extractFileLLM(filePath: string): Promise<LLMFileData> {
function truncateForContext(content: string, promptLength: number): string {
const maxChars = CONTEXT_BUDGET * CHARS_PER_TOKEN - promptLength;
if (content.length <= maxChars) return content;
return content.slice(0, maxChars - 20) + "\n[...truncated]";
}
function buildFilePrompt(filePath: string, content: string): string {
const name = basename(filePath);
const ext = extname(filePath).toLowerCase();
const typeHint = FILE_TYPE_PURPOSES[ext] || FILE_TYPE_PURPOSES[name.toLowerCase()] || ext || "file";
return `Analyze this ${typeHint} file. Respond in this exact format (one line each):
PURPOSE: <concise one-sentence description of what this file does>
DEPS: <comma-separated list of key dependencies/modules it relies on, or "none">
CONCEPTS: <comma-separated list of key concepts/patterns used, or "none">
File: ${name}
\`\`\`
${content}
\`\`\`
`;
}
function parseFileResponse(response: string): { purpose: string; deps: string[]; concepts: string[] } {
const lines = response.split("\n");
let purpose = "";
let deps: string[] = [];
let concepts: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("PURPOSE:")) {
purpose = trimmed.slice("PURPOSE:".length).trim();
} else if (trimmed.startsWith("DEPS:")) {
const depsStr = trimmed.slice("DEPS:".length).trim();
deps = depsStr === "none" ? [] : depsStr.split(",").map((s) => s.trim()).filter(Boolean);
} else if (trimmed.startsWith("CONCEPTS:")) {
const conceptsStr = trimmed.slice("CONCEPTS:".length).trim();
concepts = conceptsStr === "none" ? [] : conceptsStr.split(",").map((s) => s.trim()).filter(Boolean);
}
}
return { purpose, deps, concepts };
}
function buildPackagePrompt(relativePath: string, fileSummaries: { name: string; purpose: string }[]): string {
const filesList = fileSummaries.map((f) => `- ${f.name}: ${f.purpose}`).join("\n");
return `Analyze this code package/directory. Respond in this exact format (one line each):
ROLE: <concise one-sentence description of this package's role in the project>
ARCH: <concise description of architecture/patterns used in this package>
Directory: ${relativePath}
Files:
${filesList}
`;
}
function parsePackageResponse(response: string): { role: string; arch: string } {
const lines = response.split("\n");
let role = "";
let arch = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("ROLE:")) {
role = trimmed.slice("ROLE:".length).trim();
} else if (trimmed.startsWith("ARCH:")) {
arch = trimmed.slice("ARCH:".length).trim();
}
}
return { role, arch };
}
// ============================================================================
// PRODUCTION: Real LLM calls
// ============================================================================
export async function extractFileLLM(
filePath: string,
client?: LLMClient,
): Promise<LLMFileData> {
const content = readFileSync(filePath, "utf8");
const hash = createHash("sha256").update(content).digest("hex");
if (cache.has(hash)) {
return cache.get(hash)!;
// Check disk cache
const cached = getCached(hash);
if (cached) {
const parsed = parseFileResponse(cached);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
const ext = extname(filePath).toLowerCase();
const name = basename(filePath);
const baseName = basename(filePath, ext);
// Extract exports via heuristics
const exports = extractExports(content, ext, name);
// Extract dependencies via heuristics
const deps = extractDeps(content, ext);
// Generate purpose from filename + content heuristics
const purpose = generatePurpose(name, ext, baseName, content, exports);
const result: LLMFileData = { purpose, exports, deps };
cache.set(hash, result);
return result;
}
function extractExports(
content: string,
ext: string,
_filename: string,
): string[] {
const exports: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
// ES module exports — only match at start of line (after optional whitespace)
// Handles: export function foo, export async function foo, export class Foo,
// export const foo, export { foo, bar }, export default foo
const exportRegex =
/(?:^|\n)\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\s+|class\s+|const\s+|let\s+|var\s+|interface\s+|type\s+|enum\s+)?([A-Za-z_$][A-Za-z0-9_$]*)/g;
let match: RegExpExecArray | null;
match = exportRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = exportRegex.exec(content);
}
// Named export destructuring: export { foo, bar }
const namedExportRegex = /(?:^|\n)\s*export\s*\{\s*([^}]+)\s*\}/g;
match = namedExportRegex.exec(content);
while (match !== null) {
const names = match[1].split(",").map((s) =>
s
.trim()
.split(/\s+as\s+/)[0]
.trim(),
);
exports.push(...names);
match = namedExportRegex.exec(content);
}
} else if (ext === ".py") {
// Python exports (top-level functions/classes)
const pyRegex =
/^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)|class\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = pyRegex.exec(content);
while (match !== null) {
exports.push(match[1] || match[2]);
match = pyRegex.exec(content);
}
} else if (ext === ".go") {
// Go exports (capitalized functions/types)
const goRegex = /^(?:func|type|var|const)\s+([A-Z][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = goRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = goRegex.exec(content);
}
} else if (ext === ".rs") {
// Rust exports (pub items)
const rsRegex =
/pub\s+(?:fn|struct|enum|trait|type|const|static|use)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
let match: RegExpExecArray | null = rsRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = rsRegex.exec(content);
}
// Skip very large files
const size = statSync(filePath).size;
if (size > MAX_FILE_SIZE) {
return {
purpose: "Large/generated file",
exports: [],
deps: [],
concepts: [],
};
}
// Deduplicate while preserving order
return [...new Set(exports)];
}
function extractDeps(content: string, ext: string): string[] {
const deps: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
// ES imports
const importRegex =
/import\s+(?:(?:type\s+)?\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"]([^'"]+)['"]/g;
let match: RegExpExecArray | null = importRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = importRegex.exec(content);
}
// CommonJS requires
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
match = requireRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = requireRegex.exec(content);
}
} else if (ext === ".py") {
// Python imports
const pyImportRegex = /^(?:from|import)\s+([A-Za-z_][A-Za-z0-9_.]*)/gm;
let match: RegExpExecArray | null = pyImportRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = pyImportRegex.exec(content);
}
} else if (ext === ".go") {
// Go imports
const goImportRegex = /"([^"]+)"/g;
let match: RegExpExecArray | null = goImportRegex.exec(content);
while (match !== null) {
if (match[1].includes("/")) deps.push(match[1]);
match = goImportRegex.exec(content);
}
} else if (ext === ".rs") {
// Rust use statements
const rsUseRegex = /use\s+([A-Za-z_][A-Za-z0-9_:]*)/g;
let match: RegExpExecArray | null = rsUseRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = rsUseRegex.exec(content);
}
// If no LLM client provided, fall back to heuristics (for backward compat / tests)
if (!client) {
return extractFileHeuristic(filePath, content);
}
// Deduplicate
return [...new Set(deps)];
}
const prompt = buildFilePrompt(filePath, truncateForContext(content, 200));
const response = await client.complete(prompt);
setCached(hash, response);
function generatePurpose(
name: string,
ext: string,
_baseName: string,
_content: string,
exports: string[],
): string {
// Check for specific file patterns
if (/test|spec/i.test(name) && exports.length === 0) {
return "Test suite";
}
if (/config|settings/i.test(name)) return "Configuration";
if (/util|helper/i.test(name)) return "Utility functions";
if (/types?\.d?\.ts$/.test(name)) return "Type definitions";
if (/index\./.test(name)) return "Module entry point";
if (/middleware/.test(name)) return "Middleware";
if (/route/.test(name)) return "Route handlers";
if (/controller/.test(name)) return "Controller";
if (/service/.test(name)) return "Service layer";
if (/model/.test(name)) return "Data model";
if (/schema/.test(name)) return "Data schema";
if (
/component/.test(name) ||
/\.tsx$/.test(name) ||
/\.vue$/.test(name) ||
/\.svelte$/.test(name)
) {
return "UI component";
}
if (/hook|use[A-Z]/.test(name)) return "React hook";
if (/style|\.css|\.scss|\.less/.test(name)) return "Styling";
if (/docker/i.test(name)) return "Container definition";
if (/\.env/.test(name)) return "Environment variables";
if (/readme/i.test(name)) return "Project documentation";
// Use exports to infer purpose
if (exports.length > 0) {
const firstFew = exports.slice(0, 3).join(", ");
if (exports.length <= 3) return `Exports: ${firstFew}`;
return `Exports ${exports.length} symbols: ${firstFew}...`;
}
// Fallback to file type
return (
FILE_TYPE_PURPOSES[ext] ||
(ext ? `${ext.slice(1).toUpperCase()} file` : `${name} file`)
);
const parsed = parseFileResponse(response);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
export async function extractPackageLLM(
relativePath: string,
fileData: { name: string; purpose: string }[],
client?: LLMClient,
): Promise<LLMPackageData> {
const dirName = basename(relativePath);
// Infer role from directory name
let role = dirName === "." ? "Project root" : `Package ${dirName}`;
if (dirName === "src" || dirName === "lib" || dirName === "source") {
role = "Source code";
} else if (dirName === "test" || dirName === "tests" || dirName === "spec") {
role = "Test suite";
} else if (dirName === "docs" || dirName === "doc") {
role = "Documentation";
} else if (dirName === "config" || dirName === "configuration") {
role = "Configuration";
} else if (
dirName === "utils" ||
dirName === "helpers" ||
dirName === "util"
) {
role = "Utility functions";
} else if (dirName === "types" || dirName === "type") {
role = "Type definitions";
} else if (dirName === "components" || dirName === "component") {
role = "UI components";
} else if (dirName === "hooks" || dirName === "hook") {
role = "Custom hooks";
} else if (dirName === "api" || dirName === "apis") {
role = "API endpoints/handlers";
} else if (
dirName === "db" ||
dirName === "database" ||
dirName === "models"
) {
role = "Database layer";
} else if (dirName === "auth" || dirName === "authentication") {
role = "Authentication layer";
if (!client) {
return extractPackageHeuristic(relativePath, fileData);
}
// Infer architecture from file patterns
const prompt = buildPackagePrompt(relativePath, fileData);
const response = await client.complete(prompt);
const parsed = parsePackageResponse(response);
return {
role: parsed.role || dirNameToRole(relativePath),
arch: parsed.arch || `Contains ${fileData.length} files.`,
};
}
// ============================================================================
// HEURISTIC FALLBACK (for tests / no-LLM mode)
// ============================================================================
export async function extractFileHeuristic(
filePath: string,
content?: string,
): Promise<LLMFileData> {
const fileContent = content ?? readFileSync(filePath, "utf8");
const ext = extname(filePath).toLowerCase();
const name = basename(filePath);
const baseName = basename(filePath, ext);
const exports = extractExportsHeuristic(fileContent, ext, name);
const deps = extractDepsHeuristic(fileContent, ext);
const purpose = generatePurpose(name, ext, baseName, exports);
return { purpose, exports, deps, concepts: [] };
}
export async function extractPackageHeuristic(
relativePath: string,
fileData: { name: string; purpose: string }[],
): Promise<LLMPackageData> {
const dirName = basename(relativePath);
const role = dirNameToRole(dirName);
const purposes = fileData.map((f) => f.purpose);
const hasTests = purposes.some((p) => p.includes("Test"));
const hasTypes = purposes.some((p) => p.includes("Type"));
@@ -298,3 +246,140 @@ export async function extractPackageLLM(
return { role, arch: arch.trim() };
}
// ============================================================================
// INTERNAL HEURISTIC HELPERS
// ============================================================================
function dirNameToRole(dirName: string): string {
if (dirName === ".") return "Project root";
if (dirName === "src" || dirName === "lib" || dirName === "source") return "Source code";
if (dirName === "test" || dirName === "tests" || dirName === "spec") return "Test suite";
if (dirName === "docs" || dirName === "doc") return "Documentation";
if (dirName === "config" || dirName === "configuration") return "Configuration";
if (dirName === "utils" || dirName === "helpers" || dirName === "util") return "Utility functions";
if (dirName === "types" || dirName === "type") return "Type definitions";
if (dirName === "components" || dirName === "component") return "UI components";
if (dirName === "hooks" || dirName === "hook") return "Custom hooks";
if (dirName === "api" || dirName === "apis") return "API endpoints/handlers";
if (dirName === "db" || dirName === "database" || dirName === "models") return "Database layer";
if (dirName === "auth" || dirName === "authentication") return "Authentication layer";
return `Package ${dirName}`;
}
function extractExportsHeuristic(content: string, ext: string, _filename: string): string[] {
const exports: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
const exportRegex = /(?:^|\n)\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\s+|class\s+|const\s+|let\s+|var\s+|interface\s+|type\s+|enum\s+)?([A-Za-z_$][A-Za-z0-9_$]*)/g;
let match: RegExpExecArray | null;
match = exportRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = exportRegex.exec(content);
}
const namedExportRegex = /(?:^|\n)\s*export\s*\{\s*([^}]+)\s*\}/g;
match = namedExportRegex.exec(content);
while (match !== null) {
const names = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim());
exports.push(...names);
match = namedExportRegex.exec(content);
}
} else if (ext === ".py") {
const pyRegex = /^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)|class\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = pyRegex.exec(content);
while (match !== null) {
exports.push(match[1] || match[2]);
match = pyRegex.exec(content);
}
} else if (ext === ".go") {
const goRegex = /^(?:func|type|var|const)\s+([A-Z][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = goRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = goRegex.exec(content);
}
} else if (ext === ".rs") {
const rsRegex = /pub\s+(?:fn|struct|enum|trait|type|const|static|use)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
let match: RegExpExecArray | null = rsRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = rsRegex.exec(content);
}
}
return [...new Set(exports)];
}
function extractDepsHeuristic(content: string, ext: string): string[] {
const deps: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
const importRegex = /import\s+(?:(?:type\s+)?\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"]([^'"]+)['"]/g;
let match: RegExpExecArray | null = importRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = importRegex.exec(content);
}
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
match = requireRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = requireRegex.exec(content);
}
} else if (ext === ".py") {
const pyImportRegex = /^(?:from|import)\s+([A-Za-z_][A-Za-z0-9_.]*)/gm;
let match: RegExpExecArray | null = pyImportRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = pyImportRegex.exec(content);
}
} else if (ext === ".go") {
const goImportRegex = /"([^"]+)"/g;
let match: RegExpExecArray | null = goImportRegex.exec(content);
while (match !== null) {
if (match[1].includes("/")) deps.push(match[1]);
match = goImportRegex.exec(content);
}
} else if (ext === ".rs") {
const rsUseRegex = /use\s+([A-Za-z_][A-Za-z0-9_:]*)/g;
let match: RegExpExecArray | null = rsUseRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = rsUseRegex.exec(content);
}
}
return [...new Set(deps)];
}
function generatePurpose(name: string, ext: string, _baseName: string, exports: string[]): string {
if (/test|spec/i.test(name) && exports.length === 0) return "Test suite";
if (/config|settings/i.test(name)) return "Configuration";
if (/util|helper/i.test(name)) return "Utility functions";
if (/types?\.d?\.ts$/.test(name)) return "Type definitions";
if (/index\./.test(name)) return "Module entry point";
if (/middleware/.test(name)) return "Middleware";
if (/route/.test(name)) return "Route handlers";
if (/controller/.test(name)) return "Controller";
if (/service/.test(name)) return "Service layer";
if (/model/.test(name)) return "Data model";
if (/schema/.test(name)) return "Data schema";
if (/component/.test(name) || /\.tsx$/.test(name) || /\.vue$/.test(name) || /\.svelte$/.test(name)) {
return "UI component";
}
if (/hook|use[A-Z]/.test(name)) return "React hook";
if (/style|\.css|\.scss|\.less/.test(name)) return "Styling";
if (/docker/i.test(name)) return "Container definition";
if (/\.env/.test(name)) return "Environment variables";
if (/readme/i.test(name)) return "Project documentation";
if (exports.length > 0) {
const firstFew = exports.slice(0, 3).join(", ");
if (exports.length <= 3) return `Exports: ${firstFew}`;
return `Exports ${exports.length} symbols: ${firstFew}...`;
}
return FILE_TYPE_PURPOSES[ext] || (ext ? `${ext.slice(1).toUpperCase()} file` : `${name} file`);
}
+14 -7
View File
@@ -6,10 +6,14 @@ import { extractFileAST } from "./ast-extract.js";
import { mergeFileData } from "./merge.js";
import { generateDirectoryMap } from "./init.js";
import { readdirSync, statSync } from "fs";
import type { LLMClient } from "./llm-client.js";
const SMALL_PACKAGE_THRESHOLD = 10;
export async function patchFile(filePath: string): Promise<void> {
export async function patchFile(
filePath: string,
llmClient?: LLMClient,
): Promise<void> {
const dirPath = dirname(filePath);
const mapPath = join(dirPath, ".pi-map.md");
@@ -31,18 +35,21 @@ export async function patchFile(filePath: string): Promise<void> {
return st.isFile();
});
const relDir = relative(process.cwd(), dirPath) || ".";
await generateDirectoryMap({
dirPath,
relativePath: relDir,
files,
});
await generateDirectoryMap(
{
dirPath,
relativePath: relDir,
files,
},
llmClient,
);
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);
const llmData = await extractFileLLM(filePath, llmClient);
const astData = await extractFileAST(filePath);
const fileName = basename(filePath);
const updatedFile = mergeFileData(fileName, llmData, astData);
+21
View File
@@ -0,0 +1,21 @@
import { LLMError } from "./llm-error.js";
import type { LLMClient } from "./llm-client.js";
/**
* Pi LLM Client — calls Pi's built-in LLM via ExtensionAPI.
*
* TODO: This is a stub. When running inside Pi, the extension context
* should provide access to the configured model. The exact API shape
* depends on the Pi runtime version. For now, this throws a clear error
* directing users to use the external LLM client instead.
*/
export class PiLLMClient implements LLMClient {
constructor(private _extensionContext?: unknown) {}
async complete(_prompt: string): Promise<string> {
throw new LLMError(
"Pi native LLM client is not yet implemented. " +
"Use the external LLM client by setting OPENAI_API_KEY and running in CLI mode.",
);
}
}