diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index 40fdf7b..c7f46c5 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -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"; @@ -11,6 +13,7 @@ import { getIndexMetrics, getIndexStatus, getPrImpact, + getIndexerForProject, getCodeCommunities, implementationLookup, listKnowledgeBases, @@ -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, @@ -55,6 +60,7 @@ import { } from "../../tools/contracts.js"; const HOST = "pi" as const; +const activeWatchers = new Map(); const ChunkType = Type.Union([ Type.Literal("function"), @@ -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 { + 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, @@ -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({ diff --git a/tests/pi-conformance.test.ts b/tests/pi-conformance.test.ts index 28f29c3..c8b5ef4 100644 --- a/tests/pi-conformance.test.ts +++ b/tests/pi-conformance.test.ts @@ -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(), @@ -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(), @@ -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?: { @@ -67,7 +87,10 @@ interface RegisteredTool { interface RegisteredPiRuntime { tools: Map; - beforeAgentStartHandlers: Array<(event: { systemPrompt: string }) => Promise | unknown>; + beforeAgentStartHandlers: Array<( + event: { systemPrompt: string }, + ctx: { cwd?: string }, + ) => Promise | unknown>; sessionShutdownHandlers: Array<( event: { type: "session_shutdown" }, ctx: { cwd?: string }, @@ -76,7 +99,7 @@ interface RegisteredPiRuntime { async function registerPiTools(): Promise { const tools = new Map(); - const beforeAgentStartHandlers: Array<(event: { systemPrompt: string }) => Promise | unknown> = []; + const beforeAgentStartHandlers: RegisteredPiRuntime["beforeAgentStartHandlers"] = []; const sessionShutdownHandlers: RegisteredPiRuntime["sessionShutdownHandlers"] = []; const pi = { registerTool(tool) { @@ -84,7 +107,7 @@ async function registerPiTools(): Promise { }, on(eventName, handler) { if (eventName === "before_agent_start") { - beforeAgentStartHandlers.push(handler as (event: { systemPrompt: string }) => Promise | unknown); + beforeAgentStartHandlers.push(handler as RegisteredPiRuntime["beforeAgentStartHandlers"][number]); } if (eventName === "session_shutdown") { sessionShutdownHandlers.push(handler as RegisteredPiRuntime["sessionShutdownHandlers"][number]); @@ -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 () => {