Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions src/adapters/pi/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

import { parseConfig } from "../../config/schema.js";
import { loadMergedConfig } from "../../config/merger.js";
import { formatCostEstimate } from "../../utils/cost.js";
import { formatPrImpact } from "../../tools/format-pr-impact.js";
import { formatCodeCommunities } from "../../tools/format-communities.js";
Expand All @@ -11,6 +13,7 @@ import {
getIndexMetrics,
getIndexStatus,
getPrImpact,
getIndexerForProject,
getCodeCommunities,
implementationLookup,
listKnowledgeBases,
Expand Down Expand Up @@ -39,7 +42,9 @@ import {
} from "../../tools/context.js";
import { resolveCodebaseEditContext } from "../../tools/edit-context.js";
import { registerPiCallGraphTools } from "./call-graph.js";
import { stopAutoIndex } from "../../utils/auto-index.js";
import { isHomeDirectory, stopAutoIndex } from "../../utils/auto-index.js";
import { hasProjectMarker } from "../../utils/files.js";
import { createWatcherWithIndexer, type CombinedWatcher } from "../../watcher/index.js";
import { TOOL_NAME } from "../../tools/tool-names.js";
import {
CODE_COMMUNITIES_DEFAULT_HUB_THRESHOLD,
Expand All @@ -55,6 +60,7 @@ import {
} from "../../tools/contracts.js";

const HOST = "pi" as const;
const activeWatchers = new Map<string, CombinedWatcher>();

const ChunkType = Type.Union([
Type.Literal("function"),
Expand All @@ -74,6 +80,34 @@ function projectRoot(ctx: { cwd?: string } | undefined): string {
return ctx?.cwd ?? process.cwd();
}

function isValidProject(projectRoot: string, requireProjectMarker: boolean): boolean {
return !isHomeDirectory(projectRoot) && (!requireProjectMarker || hasProjectMarker(projectRoot));
}

function ensureWatcher(projectRoot: string): void {
if (activeWatchers.has(projectRoot)) return;

const config = parseConfig(loadMergedConfig(projectRoot, HOST));
if (!config.indexing.watchFiles || !isValidProject(projectRoot, config.indexing.requireProjectMarker)) {
return;
}

activeWatchers.set(projectRoot, createWatcherWithIndexer(
() => getIndexerForProject(projectRoot, HOST),
projectRoot,
config,
HOST,
));
}

async function stopWatcher(projectRoot: string): Promise<void> {
const watcher = activeWatchers.get(projectRoot);
if (!watcher) return;

activeWatchers.delete(projectRoot);
await watcher.stop();
}

export default function codebaseIndexPiExtension(pi: ExtensionAPI): void {
pi.registerTool({
name: TOOL_NAME.CODEBASE_CONTEXT,
Expand Down Expand Up @@ -316,19 +350,23 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void {

registerPiCallGraphTools(pi);

pi.on("before_agent_start", (event) => ({
systemPrompt:
`${event.systemPrompt}\n\n` +
"Check index_status first when index readiness is unknown. " +
"Use codebase_context only when repository orientation is needed (for layout, key symbols, or cross-file dependency intent), " +
"not mechanically for every task. " +
"When using codebase_context for orientation, request a compact first pass (for example: tokenBudget: 600, limit: 5) and inspect returned evidence before broad search/grep/bash/read-style reads. " +
"Avoid repeating broad reads when the compact evidence already answers the question. " +
"Use implementation_lookup for known symbols and call_graph/call_graph_path after endpoints are identified for dependency flow.",
}));
pi.on("before_agent_start", async (event, ctx) => {
ensureWatcher(projectRoot(ctx));
return {
systemPrompt:
`${event.systemPrompt}\n\n` +
"Check index_status first when index readiness is unknown. " +
"Use codebase_context only when repository orientation is needed (for layout, key symbols, or cross-file dependency intent), " +
"not mechanically for every task. " +
"When using codebase_context for orientation, request a compact first pass (for example: tokenBudget: 600, limit: 5) and inspect returned evidence before broad search/grep/bash/read-style reads. " +
"Avoid repeating broad reads when the compact evidence already answers the question. " +
"Use implementation_lookup for known symbols and call_graph/call_graph_path after endpoints are identified for dependency flow.",
};
});

pi.on("session_shutdown", async (_event, ctx) => {
await stopAutoIndex(projectRoot(ctx), HOST);
const root = projectRoot(ctx);
await Promise.all([stopWatcher(root), stopAutoIndex(root, HOST)]);
});

pi.registerTool({
Expand Down
74 changes: 71 additions & 3 deletions tests/pi-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const operationMocks = vi.hoisted(() => ({
getCallGraphData: vi.fn(),
getCallGraphPath: vi.fn(),
getIndexHealthCheck: vi.fn(),
getIndexerForProject: vi.fn(() => ({})),
runIndexCodebase: vi.fn(),
runIndexHealthCheck: vi.fn(),
searchCodebase: vi.fn(),
Expand All @@ -18,15 +19,25 @@ const operationMocks = vi.hoisted(() => ({
}));

const autoIndexMocks = vi.hoisted(() => ({
isHomeDirectory: vi.fn(() => false),
stopAutoIndex: vi.fn(async () => {}),
}));

const configMocks = vi.hoisted(() => ({
loadMergedConfig: vi.fn(() => ({ indexing: { watchFiles: false, requireProjectMarker: false } })),
}));

const watcherMocks = vi.hoisted(() => ({
createWatcherWithIndexer: vi.fn(),
}));

vi.mock("../src/tools/operations.js", () => ({
addKnowledgeBase: vi.fn(() => "Added knowledge base"),
findSimilarCode: vi.fn(() => []),
getCallGraphData: operationMocks.getCallGraphData,
getCallGraphPath: operationMocks.getCallGraphPath,
getIndexHealthCheck: operationMocks.getIndexHealthCheck,
getIndexerForProject: operationMocks.getIndexerForProject,
runIndexCodebase: operationMocks.runIndexCodebase,
getIndexMetrics: operationMocks.getIndexMetrics,
getIndexStatus: vi.fn(),
Expand All @@ -43,9 +54,18 @@ vi.mock("../src/tools/operations.js", () => ({
}));

vi.mock("../src/utils/auto-index.js", () => ({
isHomeDirectory: autoIndexMocks.isHomeDirectory,
stopAutoIndex: autoIndexMocks.stopAutoIndex,
}));

vi.mock("../src/config/merger.js", () => ({
loadMergedConfig: configMocks.loadMergedConfig,
}));

vi.mock("../src/watcher/index.js", () => ({
createWatcherWithIndexer: watcherMocks.createWatcherWithIndexer,
}));

interface RegisteredTool {
readonly name: string;
readonly parameters?: {
Expand All @@ -67,7 +87,10 @@ interface RegisteredTool {

interface RegisteredPiRuntime {
tools: Map<string, RegisteredTool>;
beforeAgentStartHandlers: Array<(event: { systemPrompt: string }) => Promise<unknown> | unknown>;
beforeAgentStartHandlers: Array<(
event: { systemPrompt: string },
ctx: { cwd?: string },
) => Promise<unknown> | unknown>;
sessionShutdownHandlers: Array<(
event: { type: "session_shutdown" },
ctx: { cwd?: string },
Expand All @@ -76,15 +99,15 @@ interface RegisteredPiRuntime {

async function registerPiTools(): Promise<RegisteredPiRuntime> {
const tools = new Map<string, RegisteredTool>();
const beforeAgentStartHandlers: Array<(event: { systemPrompt: string }) => Promise<unknown> | unknown> = [];
const beforeAgentStartHandlers: RegisteredPiRuntime["beforeAgentStartHandlers"] = [];
const sessionShutdownHandlers: RegisteredPiRuntime["sessionShutdownHandlers"] = [];
const pi = {
registerTool(tool) {
tools.set(tool.name, tool);
},
on(eventName, handler) {
if (eventName === "before_agent_start") {
beforeAgentStartHandlers.push(handler as (event: { systemPrompt: string }) => Promise<unknown> | unknown);
beforeAgentStartHandlers.push(handler as RegisteredPiRuntime["beforeAgentStartHandlers"][number]);
}
if (eventName === "session_shutdown") {
sessionShutdownHandlers.push(handler as RegisteredPiRuntime["sessionShutdownHandlers"][number]);
Expand Down Expand Up @@ -140,6 +163,51 @@ describe("Pi adapter conformance", () => {
operationMocks.getIndexMetrics.mockClear();
autoIndexMocks.stopAutoIndex.mockReset();
autoIndexMocks.stopAutoIndex.mockResolvedValue(undefined);
autoIndexMocks.isHomeDirectory.mockReset();
autoIndexMocks.isHomeDirectory.mockReturnValue(false);
configMocks.loadMergedConfig.mockReset();
configMocks.loadMergedConfig.mockReturnValue({ indexing: { watchFiles: false, requireProjectMarker: false } });
watcherMocks.createWatcherWithIndexer.mockReset();
operationMocks.getIndexerForProject.mockReset();
operationMocks.getIndexerForProject.mockReturnValue({});
});

it("starts one filesystem watcher for an eligible Pi project and stops it at session shutdown", async () => {
const watcher = { stop: vi.fn(async () => {}) };
configMocks.loadMergedConfig.mockReturnValue({
indexing: { watchFiles: true, requireProjectMarker: false },
});
watcherMocks.createWatcherWithIndexer.mockReturnValue(watcher);

const { beforeAgentStartHandlers, sessionShutdownHandlers } = await registerPiTools();
const event = { systemPrompt: "base prompt" };
const ctx = { cwd: "/watched-project" };

await beforeAgentStartHandlers[0]?.(event, ctx);
await beforeAgentStartHandlers[0]?.(event, ctx);

expect(watcherMocks.createWatcherWithIndexer).toHaveBeenCalledOnce();
expect(watcherMocks.createWatcherWithIndexer).toHaveBeenCalledWith(
expect.any(Function),
"/watched-project",
expect.objectContaining({ indexing: expect.objectContaining({ watchFiles: true }) }),
"pi",
);

await sessionShutdownHandlers[0]?.({ type: "session_shutdown" }, ctx);
expect(watcher.stop).toHaveBeenCalledOnce();
expect(autoIndexMocks.stopAutoIndex).toHaveBeenCalledWith("/watched-project", "pi");
});

it("does not start a Pi watcher when watchFiles is disabled", async () => {
const { beforeAgentStartHandlers } = await registerPiTools();

await beforeAgentStartHandlers[0]?.(
{ systemPrompt: "base prompt" },
{ cwd: "/unwatched-project" },
);

expect(watcherMocks.createWatcherWithIndexer).not.toHaveBeenCalled();
});

it("awaits automatic indexing shutdown when the Pi session closes", async () => {
Expand Down
Loading