diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b9663401..599e55171 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `skill://`, `local://`, `memory://`, and `vault://` directory paths so read lists them and search/find can walk their backing directories. ([#3116](https://github.com/can1357/oh-my-pi/issues/3116)) + ## [16.1.7] - 2026-06-20 ### Fixed diff --git a/packages/coding-agent/src/internal-urls/filesystem-resource.ts b/packages/coding-agent/src/internal-urls/filesystem-resource.ts new file mode 100644 index 000000000..f004aed70 --- /dev/null +++ b/packages/coding-agent/src/internal-urls/filesystem-resource.ts @@ -0,0 +1,27 @@ +import * as fs from "node:fs/promises"; +import type { InternalResource } from "./types"; + +/** Builds a text resource for a filesystem directory resolved by an internal URL handler. */ +export async function buildDirectoryResource( + url: string, + directoryPath: string, + notes?: string[], +): Promise { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + entries.sort((a, b) => { + const directoryOrder = Number(b.isDirectory()) - Number(a.isDirectory()); + return directoryOrder || a.name.localeCompare(b.name); + }); + const content = + entries.length === 0 + ? "(empty directory)" + : entries.map(e => `${e.name}${e.isDirectory() ? "/" : ""}`).join("\n"); + return { + url, + content, + contentType: "text/plain", + size: Buffer.byteLength(content, "utf-8"), + sourcePath: directoryPath, + ...(notes ? { notes } : {}), + }; +} diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 75eadd6d9..310ed6aaa 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { AgentRegistry } from "../registry/agent-registry"; +import { buildDirectoryResource } from "./filesystem-resource"; import { parseInternalUrl } from "./parse"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types"; @@ -279,8 +280,13 @@ export class LocalProtocolHandler implements ProtocolHandler { ensureWithinRoot(realTargetPath, resolvedRoot); const stat = await fs.stat(realTargetPath); + if (stat.isDirectory()) { + return buildDirectoryResource(url.href, realTargetPath, [ + "Use write path local:// to persist large intermediate artifacts across turns.", + ]); + } if (!stat.isFile()) { - throw new Error(`local:// URL must resolve to a file: ${url.href}`); + throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`); } const content = await Bun.file(realTargetPath).text(); diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 40d6e56ef..8e6b6e49f 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; import { AgentRegistry } from "../registry/agent-registry"; +import { buildDirectoryResource } from "./filesystem-resource"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types"; @@ -102,8 +103,11 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise - Reads SKILL.md * - skill:/// - Reads relative path within skill's baseDir */ +import type * as fsTypes from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { getActiveSkills } from "../extensibility/skills"; +import { buildDirectoryResource } from "./filesystem-resource"; import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types"; function getContentType(filePath: string): InternalResource["contentType"] { @@ -71,12 +75,24 @@ export class SkillProtocolHandler implements ProtocolHandler { targetPath = skill.filePath; } - const file = Bun.file(targetPath); - if (!(await file.exists())) { - throw new Error(`File not found: ${targetPath}`); + let stats: fsTypes.Stats; + try { + stats = await fs.stat(targetPath); + } catch (error) { + if (isEnoent(error)) { + throw new Error(`File not found: ${targetPath}`); + } + throw error; } - const content = await file.text(); + if (stats.isDirectory()) { + return buildDirectoryResource(url.href, targetPath); + } + if (!stats.isFile()) { + throw new Error(`skill:// URL must resolve to a file or directory: ${url.href}`); + } + + const content = await Bun.file(targetPath).text(); return { url: url.href, content, diff --git a/packages/coding-agent/src/internal-urls/vault-protocol.ts b/packages/coding-agent/src/internal-urls/vault-protocol.ts index f80f734b7..082a4950c 100644 --- a/packages/coding-agent/src/internal-urls/vault-protocol.ts +++ b/packages/coding-agent/src/internal-urls/vault-protocol.ts @@ -822,7 +822,7 @@ export class VaultProtocolHandler implements ProtocolHandler { } async #listDir( - parsed: Extract, + parsed: Extract, context?: ResolveContext, ): Promise { const { root, targetPath } = await this.#resolveFsTarget(parsed, context); @@ -878,8 +878,11 @@ export class VaultProtocolHandler implements ProtocolHandler { } ensureWithinRoot(realTargetPath, root); const stat = await fs.promises.stat(realTargetPath); + if (stat.isDirectory()) { + return this.#listDir(parsed, context); + } if (!stat.isFile()) { - throw new Error(`vault:// URL must resolve to a file: ${parsed.url}`); + throw new Error(`vault:// URL must resolve to a file or directory: ${parsed.url}`); } const content = await Bun.file(realTargetPath).text(); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 30791e8f1..a9469f6d3 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -2746,6 +2746,20 @@ export class ReadTool implements AgentTool { return toolResult(details).text(resource.content).sourceInternal(url).done(); } + if (resource.sourcePath) { + const stat = await fs.stat(resource.sourcePath).catch(error => { + if (isNotFoundError(error)) return undefined; + throw error; + }); + if (stat?.isDirectory()) { + if (isMultiRange(parsedSel)) { + throw new ToolError("Multi-range line selectors are not supported for directory listings."); + } + const { offset, limit } = selToOffsetLimit(parsedSel); + return this.#readDirectory(resource.sourcePath, offset, limit, undefined); + } + } + const raw = isRawSelector(parsedSel); if (isMultiRange(parsedSel) && parsedSel.kind === "lines") { return this.#buildInMemoryMultiRangeResult(resource.content, parsedSel.ranges, { diff --git a/packages/coding-agent/test/internal-urls/vault-protocol.test.ts b/packages/coding-agent/test/internal-urls/vault-protocol.test.ts index 848069912..c601a55a1 100644 --- a/packages/coding-agent/test/internal-urls/vault-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/vault-protocol.test.ts @@ -223,6 +223,23 @@ describe("VaultProtocolHandler", () => { }); }); + it("lists existing folder paths even without a trailing slash", async () => { + await withTempDir(async tempDir => { + const root = path.join(tempDir, "vault"); + await fs.mkdir(path.join(root, "Folder", "Sub"), { recursive: true }); + await Bun.write(path.join(root, "Folder", "note.md"), "note"); + VaultProtocolHandler.setVaultDirectoryForTests({ Work: root }); + const handler = new VaultProtocolHandler({ resolveObsidianBinary: () => null }); + + const resource = await handler.resolve(resourceUrl("vault://Work/Folder")); + + expect(resource.contentType).toBe("text/markdown"); + expect(resource.sourcePath).toBe(await fs.realpath(path.join(root, "Folder"))); + expect(resource.content).toContain("[note.md](vault://Work/Folder/note.md)"); + expect(resource.content).toContain("[Sub/](vault://Work/Folder/Sub/)"); + }); + }); + it("reports the documented binary-missing error for CLI-backed operations", async () => { const handler = new VaultProtocolHandler({ resolveObsidianBinary: () => null }); diff --git a/packages/coding-agent/test/tools/search-internal-urls.test.ts b/packages/coding-agent/test/tools/search-internal-urls.test.ts index f909bae9b..e635e677c 100644 --- a/packages/coding-agent/test/tools/search-internal-urls.test.ts +++ b/packages/coding-agent/test/tools/search-internal-urls.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetActiveSkillsForTests, setActiveSkills } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; import { type InternalResource, type InternalUrl, @@ -13,6 +14,7 @@ import { import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find"; +import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search"; function getResultText(result: { content: Array<{ type: string; text?: string }> }): string { @@ -89,6 +91,7 @@ describe("SearchTool internal URL resolution", () => { AgentRegistry.resetGlobalForTests(); LocalProtocolHandler.resetOverrideForTests(); InternalUrlRouter.resetForTests(); + resetActiveSkillsForTests(); }); function createSession(overrides: Partial = {}): ToolSession { @@ -102,6 +105,56 @@ describe("SearchTool internal URL resolution", () => { }; } + async function registerSkillDirectory(): Promise { + const skillDir = path.join(tmpDir, "skills", "demo"); + await fs.mkdir(path.join(skillDir, "references", "docs"), { recursive: true }); + await Bun.write(path.join(skillDir, "SKILL.md"), "# Demo\n"); + await Bun.write(path.join(skillDir, "references", "index.md"), "install needle\n"); + await Bun.write(path.join(skillDir, "references", "docs", "guide.md"), "deep needle\n"); + setActiveSkills([ + { + name: "demo", + description: "demo skill", + filePath: path.join(skillDir, "SKILL.md"), + baseDir: skillDir, + source: "test", + }, + ]); + return skillDir; + } + + it("lists skill:// directory subpaths through the read tool", async () => { + const skillDir = await registerSkillDirectory(); + const session = createSession(); + const tool = new ReadTool(session); + + const result = await tool.execute("test-call", { path: "skill://demo/references" }); + + const text = getResultText(result); + expect(text).toContain("index.md"); + expect(text).toContain("docs/"); + expect(result.details?.isDirectory).toBe(true); + expect(result.details?.resolvedPath).toBe(path.join(skillDir, "references")); + }); + + it("walks skill:// directory subpaths for search and find", async () => { + await registerSkillDirectory(); + const session = createSession(); + const searchTool = new SearchTool(session); + const findTool = new FindTool(session); + + const searchResult = await searchTool.execute("test-search", { + pattern: "deep needle", + paths: ["skill://demo/references"], + }); + const findResult = await findTool.execute("test-find", { + paths: ["skill://demo/references"], + }); + + expect(getResultText(searchResult)).toContain("deep needle"); + expect(getResultText(findResult)).toContain("guide.md"); + }); + it("resolves artifact:// URL to backing file and greps it", async () => { const content = "line one\nfound the needle here\nline three\n"; await Bun.write(path.join(artifactsDir, "5.bash.log"), content); @@ -273,6 +326,24 @@ describe("SearchTool internal URL resolution", () => { expect(text).toContain("PLAN.md"); }); + it("walks local:// directory subpaths for read and find", async () => { + const localRoot = path.join(artifactsDir, "local"); + await fs.mkdir(path.join(localRoot, "notes"), { recursive: true }); + await Bun.write(path.join(localRoot, "notes", "PLAN.md"), "# Plan\n"); + + LocalProtocolHandler.setOverride({ getArtifactsDir: () => artifactsDir, getSessionId: () => "session" }); + + const session = createSession(); + const readResult = await new ReadTool(session).execute("test-read", { path: "local://notes" }); + const findResult = await new FindTool(session).execute("test-find", { + paths: ["local://notes"], + }); + + expect(getResultText(readResult)).toContain("PLAN.md"); + expect(readResult.details?.isDirectory).toBe(true); + expect(getResultText(findResult)).toContain("PLAN.md"); + }); + it("keeps hashline anchors when searching mutable local:// sources", async () => { const localRoot = path.join(artifactsDir, "local"); await fs.mkdir(localRoot, { recursive: true });