@better-fs-tools/pi 0.0.0-tegami-trusted-publish-setup → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth Andrew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,142 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # @better-fs-tools/pi
2
+
3
+ Better FS Tools for the [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent). It replaces Pi's built-in `read`, `edit`, and `write` tools, and can add `apply_patch`. Every call is confined to the working directory of that call. A read store, so that `edit` and `write` need a read first, is opt-in.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @better-fs-tools/pi @better-fs-tools/read @better-fs-tools/write @earendil-works/pi-coding-agent typebox
9
+ ```
10
+
11
+ `@earendil-works/pi-coding-agent` (`^0.84.2`) and `typebox` (`^1.3.16`) are required peers. The package needs Node 24 or later.
12
+
13
+ The package has a `pi.extensions` entry. When Pi loads the package, the entry registers `read`, `edit`, and `write` from `createPiFsTools()`, as Pi's own tools are. `read` returns images, as Pi's own `read` does. There is no read store, so `edit` and `write` do not check for a read first. Writes stay inside `ctx.cwd`. The entry writes no settings or session file.
14
+
15
+ ## Example
16
+
17
+ To choose your own options, register the tools from your own extension:
18
+
19
+ ```ts
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { createPiFsTools } from "@better-fs-tools/pi";
22
+ import { denyPaths, memoryStore, unicodeRepair } from "@better-fs-tools/read";
23
+ import { hashlineFormat } from "@better-fs-tools/read/formats";
24
+ import { protectPaths } from "@better-fs-tools/write";
25
+
26
+ export default function fsToolsExtension(pi: ExtensionAPI): void {
27
+ // Four tools with one read store, so edit and write need a read first. The
28
+ // store and apply_patch are opt-in.
29
+ const tools = createPiFsTools({
30
+ state: memoryStore(),
31
+ read: {
32
+ resolve: unicodeRepair({ note: false }),
33
+ authorize: denyPaths(["**/.env", "**/.env.*"]),
34
+ formatter: hashlineFormat(),
35
+ },
36
+ edit: { authorize: protectPaths() },
37
+ write: { authorize: protectPaths() },
38
+ applyPatch: { authorize: protectPaths() },
39
+ });
40
+ pi.registerTool(tools.read);
41
+ pi.registerTool(tools.edit);
42
+ pi.registerTool(tools.write);
43
+ pi.registerTool(tools.applyPatch);
44
+ }
45
+ ```
46
+
47
+ To register only the read tool:
48
+
49
+ ```ts
50
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
51
+ import { createPiReadTool } from "@better-fs-tools/pi";
52
+ import { denyPaths, unicodeRepair } from "@better-fs-tools/read";
53
+ import { hashlineFormat } from "@better-fs-tools/read/formats";
54
+ import { defaultReadSignature } from "@better-fs-tools/read/signature";
55
+
56
+ export default function readExtension(pi: ExtensionAPI): void {
57
+ pi.registerTool(
58
+ createPiReadTool({
59
+ signature: defaultReadSignature({ name: "read", names: { path: "file_path" } }),
60
+ resolve: unicodeRepair({ note: false }),
61
+ authorize: denyPaths(["**/.env", "**/.env.*"]),
62
+ formatter: hashlineFormat(),
63
+ }),
64
+ );
65
+ }
66
+ ```
67
+
68
+ ## What the read tool does
69
+
70
+ - `createPiReadTool(options?)` takes every `createReadTool()` option except `fs`. It also takes `signature`, `promptSnippet`, `promptGuidelines`, `denyRoots`, and `symlinks`.
71
+ - `fs`, `cwd`, and `allowedRoots` throw `TypeError`. Each call reads through `nodeFileSystem` with Pi's `ctx.cwd` as the only allowed root. When the working directory changes between calls, the root changes too. The tool keeps the filesystems for the last 8 working directories.
72
+ - Pi's `ExtensionContext` is the host type. Host functions get it as `ctx.call.host`, for example `ctx.call.host.ui.confirm(...)` in `askUser`, or `ctx.call.host.sessionManager.getSessionId()` in a `state` factory.
73
+ - The default signature is `defaultReadSignature({ name: "read" })`. Its schema has integer `offset` and `limit`, so a model that sends `2.5` gets Pi's validation error. `digest` defaults to `nodeDigest()`.
74
+ - `converters` defaults to `[imageConverter()]`, as Pi's own `read` returns images. This holds for `createPiReadTool()`, `createPiFsTools()`, and the extension entry. A list you pass replaces the default, so `converters: []` turns images off, and a list without `imageConverter()` refuses images. In `createPiFsTools()` the option goes under `read`, for example `read: { converters: [] }`.
75
+ - Image parts become Pi `image` parts. Other media becomes a text part that says what was left out.
76
+ - `details.truncation` follows Pi's own read tool for line and byte stops. Its `content` comes from the formatter in `"view"` mode. For other results, or a formatter that returns parts, `details` is `{}`. `toPiReadDetails()` builds the details for your own tool.
77
+
78
+ ## What the write tools do
79
+
80
+ - `createPiFsTools(options?)` wraps `createFsTools()` from `@better-fs-tools/write`. It builds `read`, `edit`, and `write` with one `nodeDigest()`, one lock manager, one clock, and one cache of filesystems over `ctx.cwd`. `applyPatch: true`, or an options object, adds `apply_patch`. `state` defaults to `null`, so read-before-write is off; `state: memoryStore()` turns it on. `digest` (default `nodeDigest()`), `locks`, `clock`, and the root options (`denyRoots`, `symlinks`, `hardLinks`, `newFileMode`, `newDirectoryMode`) are set once for all the tools. Each tool's other options go under `read`, `edit`, `write`, and `applyPatch`. The result also has `applyPatch` (`null` when off), `state`, `digest`, `locks`, `clock`, and `invalidate(path, call?)`, which returns an `InvalidateOutcome`. Pass the call, so the path resolves under its `ctx.cwd`: in a bash `afterRun` hook it is `ctx.call`. Without it the outcome is `{ ok: false }` with reason `unsupported`. An unknown top-level key, or `state`, `digest`, `locks`, `clock`, or a root option inside a tool's options, throws `TypeError`.
81
+ - `createPiEditTool()`, `createPiWriteTool()`, and `createPiApplyPatchTool()` build one tool each. They default to `state: null`, so read-before-write is off. Pass the same `state` as your read tool, or use `createPiFsTools()`.
82
+ - `fs`, `cwd`, and `allowedRoots` throw `TypeError`, as for the read tool. Writes go through `nodeFileSystem` with `ctx.cwd` as the only allowed root, and `hardLinks: "in-place"` by default: a file with more than one hard link is written through the link, not atomically. `hardLinks: "refuse"` fails that write with `DENIED`. `newFileMode` and `newDirectoryMode` set the modes of new files and directories exactly. Without them, a new file is `0o666` and a new directory `0o777`, less the process umask.
83
+ - The default signatures follow Pi's own tools: `edit` takes `path` and `edits: [{ oldText, newText }]` (`multiEditSignature({ name: "edit" })`), and `write` takes `path` and `content`. Their prompt snippets and guidelines are Pi's own, so the system prompt stays the same. `apply_patch` uses `freeformPatchSignature()`: models with grammar-tool support get the Codex patch grammar, and other models get the JSON schema.
84
+ - `edit` and `apply_patch` return `details` in the shape of Pi's `EditToolDetails` (`diff`, `patch`, `firstChangedLine`), so Pi's edit renderer draws the diff. `toPiMutationDetails()` builds them for your own tool. `write` returns no details, as Pi's own `write` does.
85
+ - A tool error becomes Pi content text. The tool does not throw for a tool error.
86
+ - Pi's `edit` guideline says the old text must match exactly. The tool also accepts close matches, and the result says when it used one.
87
+
88
+ ## Bash tool
89
+
90
+ `createPiBashTool()` is a `bash` tool from [`@better-fs-tools/shell`](https://www.npmjs.com/package/@better-fs-tools/shell) with Pi's own shape: `{ command, timeout }` with the timeout in seconds, and Pi's prompt snippet. It runs in `ctx.cwd` on every call, also with a `runner` you pass: the runner gets `ctx.cwd` as the request's `cwd`, not its own. A relative `ctx.cwd` throws `TypeError`. The extension entry does not register it, so Pi's own `bash` stays unless you register this one:
91
+
92
+ ```ts
93
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
94
+ import { createPiBashTool } from "@better-fs-tools/pi";
95
+
96
+ // Replaces Pi's own bash: same name, { command, timeout } in seconds, run in ctx.cwd.
97
+ export default function bashExtension(pi: ExtensionAPI): void {
98
+ pi.registerTool(createPiBashTool());
99
+ }
100
+ ```
101
+
102
+ The options take every `createBashTool()` dependency except `cwd`, plus `runner`, `signature`, `promptSnippet`, and `promptGuidelines`. `runner` and `env` are optional here. The default description names the timeouts and output limits from `limits`, and the id of a `runner` object when you pass one.
103
+
104
+ Pi's own `bash` also sets `PI_*` session variables. This tool does not. Add them with the `env` option when you need them. `env` defaults to `shellEnv(() => process.env)`.
105
+
106
+ With the file tools, a bash `afterRun` hook can make the next edit of a file need a read:
107
+
108
+ ```ts
109
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
110
+ import { createPiBashTool, createPiFsTools } from "@better-fs-tools/pi";
111
+ import { memoryStore } from "@better-fs-tools/read";
112
+
113
+ // The file tools with a read store, and Pi-shaped bash. After each command,
114
+ // the next edit of package.json needs a read, since the command may have
115
+ // changed it.
116
+ export default function fsAndBashExtension(pi: ExtensionAPI): void {
117
+ const tools = createPiFsTools({ state: memoryStore() });
118
+ const bash = createPiBashTool({
119
+ afterRun: [
120
+ {
121
+ id: "invalidate-package-json",
122
+ afterRun: async (_outcome, ctx) => {
123
+ // ctx.call carries Pi's ctx, so the path resolves under its cwd.
124
+ await tools.invalidate("package.json", ctx.call);
125
+ return {};
126
+ },
127
+ },
128
+ ],
129
+ });
130
+ pi.registerTool(tools.read);
131
+ pi.registerTool(tools.edit);
132
+ pi.registerTool(tools.write);
133
+ pi.registerTool(bash);
134
+ }
135
+ ```
136
+
137
+ ## Links
138
+
139
+ - `docs/hosts.md` in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): the defaults of every host and bundle, and what each backend can do
140
+ - [`@better-fs-tools/read`](https://www.npmjs.com/package/@better-fs-tools/read): every read option, the signature builders, and the format presets. Its README has a full Pi host example.
141
+ - [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): every write option, the guards, and the authorizers
142
+ - [`@better-fs-tools/node`](https://www.npmjs.com/package/@better-fs-tools/node): the filesystem that each call uses
@@ -0,0 +1,48 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TSchema } from "typebox";
3
+ import type { JsonObject } from "@better-fs-tools/read";
4
+ import type { ShellToolDeps } from "@better-fs-tools/shell";
5
+ import type { BashSignature } from "@better-fs-tools/shell/signature";
6
+ import type { PiContentPart } from "./parts.ts";
7
+ /** Options for createPiBashTool. Commands always run in ctx.cwd. */
8
+ export interface CreatePiBashToolOptions extends Omit<ShellToolDeps<ExtensionContext>, "runner" | "cwd" | "env"> {
9
+ /** Default a nodeCommandRunner at ctx.cwd. */
10
+ readonly runner?: ShellToolDeps<ExtensionContext>["runner"];
11
+ /** Default process.env with defaultShellEnv over it. */
12
+ readonly env?: ShellToolDeps<ExtensionContext>["env"];
13
+ /**
14
+ * Default Pi's own bash input, { command, timeout } with the timeout in
15
+ * seconds, so Pi's bash renderer and prompt fit. Its description names the
16
+ * configured timeouts, output limits, and runner id.
17
+ */
18
+ readonly signature?: BashSignature;
19
+ readonly promptSnippet?: string;
20
+ readonly promptGuidelines?: readonly string[];
21
+ }
22
+ /** Pi's BashToolDetails shape, so Pi's bash renderer can show the saved output. */
23
+ export interface PiBashDetails {
24
+ fullOutputPath?: string;
25
+ }
26
+ export interface PiBashToolResult {
27
+ content: PiContentPart[];
28
+ details: PiBashDetails | undefined;
29
+ }
30
+ /** Assignable to ToolDefinition<TSchema, PiBashDetails | undefined> from Pi 0.84.2. */
31
+ export interface PiBashTool {
32
+ readonly name: string;
33
+ readonly label: string;
34
+ readonly description: string;
35
+ readonly promptSnippet: string;
36
+ readonly promptGuidelines: string[];
37
+ /** The JSON Schema of the tool input. */
38
+ readonly parameters: TSchema;
39
+ execute(toolCallId: string, input: JsonObject, signal: AbortSignal | undefined, onUpdate: unknown, ctx: ExtensionContext): Promise<PiBashToolResult>;
40
+ }
41
+ /**
42
+ * Creates a Pi bash tool that runs commands in ctx.cwd, also with your own
43
+ * runner. Throws TypeError when options set fs, cwd, or allowedRoots, and
44
+ * execute throws TypeError when ctx.cwd is not an absolute path. The Pi
45
+ * extension does not register this tool, so Pi's own bash stays in place
46
+ * unless you register it.
47
+ */
48
+ export declare function createPiBashTool(options?: CreatePiBashToolOptions): PiBashTool;
@@ -0,0 +1,76 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { Type } from "typebox";
3
+ import { nodeCommandRunner } from "@better-fs-tools/node";
4
+ import { createBashTool, shellEnv } from "@better-fs-tools/shell";
5
+ import { bashSignatureMessages, defaultBashSignature } from "@better-fs-tools/shell/signature";
6
+ import { toPiPart } from "./parts.js";
7
+ import { checkPiContext, checkPiOptions } from "./roots.js";
8
+ const PI_BUILTIN_BASH_PROMPT_SNIPPET = "Execute bash commands (ls, grep, find, etc.)";
9
+ const MAX_CACHED_RUNNERS = 8;
10
+ /**
11
+ * Creates a Pi bash tool that runs commands in ctx.cwd, also with your own
12
+ * runner. Throws TypeError when options set fs, cwd, or allowedRoots, and
13
+ * execute throws TypeError when ctx.cwd is not an absolute path. The Pi
14
+ * extension does not register this tool, so Pi's own bash stays in place
15
+ * unless you register it.
16
+ */
17
+ export function createPiBashTool(options = {}) {
18
+ checkPiOptions(options, "bash");
19
+ const { signature: given, promptSnippet = PI_BUILTIN_BASH_PROMPT_SNIPPET, promptGuidelines = [], runner = recentRunnerPerCwd(), ...deps } = options;
20
+ const runnerId = typeof runner === "function" ? undefined : runner.id;
21
+ const signature = given ??
22
+ defaultBashSignature({
23
+ timeoutUnit: "s",
24
+ cwd: false,
25
+ ...(runnerId === undefined ? {} : { runner: runnerId }),
26
+ ...(deps.limits === undefined ? {} : { limits: deps.limits }),
27
+ });
28
+ const bash = createBashTool({
29
+ ...deps,
30
+ runner,
31
+ cwd: (call) => call.host.cwd,
32
+ env: deps.env ?? shellEnv(() => process.env),
33
+ messages: { ...bashSignatureMessages(signature), ...deps.messages },
34
+ });
35
+ return Object.freeze({
36
+ name: signature.name,
37
+ label: signature.name,
38
+ description: signature.description,
39
+ promptSnippet,
40
+ promptGuidelines: [...promptGuidelines],
41
+ parameters: Type.Unsafe(signature.schema),
42
+ async execute(toolCallId, input, signal, _onUpdate, ctx) {
43
+ checkPiContext(ctx, "bash");
44
+ if (!isAbsolute(ctx.cwd))
45
+ throw new TypeError("Pi bash execution requires an absolute ctx.cwd");
46
+ const call = {
47
+ ...(signal === undefined ? {} : { signal }),
48
+ callId: toolCallId,
49
+ host: ctx,
50
+ };
51
+ const result = await bash(signature.toInput(input), call);
52
+ const spill = result.output?.spill ?? null;
53
+ return {
54
+ content: result.content.map((part) => toPiPart(part, "bash")),
55
+ details: spill === null ? undefined : { fullOutputPath: spill },
56
+ };
57
+ },
58
+ });
59
+ }
60
+ function recentRunnerPerCwd() {
61
+ const cache = new Map();
62
+ return (call) => {
63
+ const cwd = call.host.cwd;
64
+ const cached = cache.get(cwd);
65
+ if (cached !== undefined) {
66
+ cache.delete(cwd);
67
+ cache.set(cwd, cached);
68
+ return cached;
69
+ }
70
+ const made = nodeCommandRunner({ cwd });
71
+ cache.set(cwd, made);
72
+ if (cache.size > MAX_CACHED_RUNNERS)
73
+ cache.delete(cache.keys().next().value);
74
+ return made;
75
+ };
76
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReadResult } from "@better-fs-tools/read";
2
+ /** Pi's published TruncationResult. Fields and meanings are Pi's. */
3
+ export interface PiTruncationResult {
4
+ content: string;
5
+ truncated: boolean;
6
+ truncatedBy: "lines" | "bytes" | null;
7
+ totalLines: number;
8
+ totalBytes: number;
9
+ outputLines: number;
10
+ outputBytes: number;
11
+ lastLinePartial: boolean;
12
+ firstLineExceedsLimit: boolean;
13
+ maxLines: number;
14
+ maxBytes: number;
15
+ }
16
+ /** Empty unless the read stopped at exactly a line or byte limit. */
17
+ export interface PiReadDetails {
18
+ truncation?: PiTruncationResult;
19
+ }
20
+ /**
21
+ * Converts a read result to Pi's read details, from which Pi prints "showing
22
+ * N of M lines". `view` is the formatted text in "view" mode, or null. Returns
23
+ * {} unless the read stopped exactly at a line or byte limit with an exact
24
+ * total, because an approximate count is worse than none.
25
+ */
26
+ export declare function toPiReadDetails(result: ReadResult, view: string | null, maxViewBytes: number): PiReadDetails;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Converts a read result to Pi's read details, from which Pi prints "showing
3
+ * N of M lines". `view` is the formatted text in "view" mode, or null. Returns
4
+ * {} unless the read stopped exactly at a line or byte limit with an exact
5
+ * total, because an approximate count is worse than none.
6
+ */
7
+ export function toPiReadDetails(result, view, maxViewBytes) {
8
+ if (view === null)
9
+ return {};
10
+ if (result.status !== "ok" || !result.totals.exact || result.totals.lines === null)
11
+ return {};
12
+ const primary = result.truncation.primary;
13
+ if (!result.truncation.truncated || (primary !== "lines" && primary !== "bytes"))
14
+ return {};
15
+ return {
16
+ truncation: {
17
+ content: view,
18
+ truncated: true,
19
+ truncatedBy: primary,
20
+ totalLines: result.totals.lines,
21
+ totalBytes: result.totals.bytes,
22
+ outputLines: result.view.lines.length,
23
+ outputBytes: result.view.bytes,
24
+ lastLinePartial: false,
25
+ firstLineExceedsLimit: primary === "bytes" && result.view.lines.length === 0,
26
+ maxLines: result.request.limit,
27
+ maxBytes: maxViewBytes,
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ import type { PiMutationTool } from "./mutation-tools.ts";
2
+ import type { PiReadTool } from "./tool.ts";
3
+ /** The part of Pi's ExtensionAPI this extension uses. Pi's full API satisfies it. */
4
+ export interface PiExtensionApi {
5
+ registerTool(tool: PiReadTool): void;
6
+ registerTool(tool: PiMutationTool): void;
7
+ }
8
+ export default function fsToolsExtension(pi: PiExtensionApi): void;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pi package extension entry. Installing the package into a Pi project
3
+ * registers read, edit, and write, as Pi's own tools are, and nothing else.
4
+ * There is no read store, so edit and write do not check for a read first.
5
+ * read returns images, as Pi's own read does.
6
+ * Nothing is written outside the process: no settings file, no session file.
7
+ */
8
+ import { createPiFsTools } from "./fs-tools.js";
9
+ export default function fsToolsExtension(pi) {
10
+ const tools = createPiFsTools();
11
+ pi.registerTool(tools.read);
12
+ pi.registerTool(tools.edit);
13
+ pi.registerTool(tools.write);
14
+ }
@@ -0,0 +1,59 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Clock, Digest, ReadStateStore, ToolCallContext } from "@better-fs-tools/read";
3
+ import type { FsToolsSharedKey, InvalidateOutcome, LockManager } from "@better-fs-tools/write";
4
+ import type { CreatePiApplyPatchToolOptions, CreatePiEditToolOptions, CreatePiWriteToolOptions, PiMutationTool } from "./mutation-tools.ts";
5
+ import type { PiRootOptions } from "./roots.ts";
6
+ import type { CreatePiReadToolOptions, PiReadTool } from "./tool.ts";
7
+ type BundleSharedKey = FsToolsSharedKey | keyof PiRootOptions;
8
+ export interface CreatePiFsToolsOptions extends PiRootOptions {
9
+ /** Default null: no read-before-write. A store, for example memoryStore(), turns it on. */
10
+ readonly state?: ReadStateStore | null;
11
+ /** Default nodeDigest(). */
12
+ readonly digest?: Digest;
13
+ /** Default memoryLocks(). */
14
+ readonly locks?: LockManager;
15
+ /** Default () => new Date(). */
16
+ readonly clock?: Clock;
17
+ readonly read?: Omit<CreatePiReadToolOptions, BundleSharedKey>;
18
+ readonly edit?: Omit<CreatePiEditToolOptions, BundleSharedKey>;
19
+ readonly write?: Omit<CreatePiWriteToolOptions, BundleSharedKey>;
20
+ /** Off by default. `true` or an options object adds an apply_patch tool. */
21
+ readonly applyPatch?: boolean | Omit<CreatePiApplyPatchToolOptions, BundleSharedKey>;
22
+ }
23
+ export interface PiFsTools {
24
+ readonly read: PiReadTool;
25
+ readonly edit: PiMutationTool;
26
+ readonly write: PiMutationTool;
27
+ /** null unless options.applyPatch is true or an object. */
28
+ readonly applyPatch: PiMutationTool | null;
29
+ readonly state: ReadStateStore | null;
30
+ /** options.digest, or nodeDigest(). */
31
+ readonly digest: Digest;
32
+ readonly locks: LockManager;
33
+ readonly clock: Clock;
34
+ /**
35
+ * Forgets the read record for a path under the call's ctx.cwd, so the next
36
+ * edit needs a read. Pass the call; in a bash afterRun hook it is ctx.call.
37
+ * Without it the outcome is { ok: false } with reason "unsupported". Never
38
+ * throws. With state null it reports recorded: false.
39
+ */
40
+ invalidate(path: string, call?: ToolCallContext<ExtensionContext>): Promise<InvalidateOutcome>;
41
+ }
42
+ /** The createPiFsTools result when `applyPatch` is true or an object: it is never null. */
43
+ export interface PiFsToolsWithApplyPatch extends PiFsTools {
44
+ readonly applyPatch: PiMutationTool;
45
+ }
46
+ /**
47
+ * Creates Pi read, edit, and write tools rooted at each call's ctx.cwd, and
48
+ * an apply_patch tool when `applyPatch` is true or an object. They share one
49
+ * digest, lock manager, and clock, and the read store when `state` is given.
50
+ * Throws TypeError on fs, cwd, or allowedRoots anywhere in the options, on an
51
+ * unknown key, and on a shared option (state, digest, locks, clock, or a root
52
+ * option) inside one tool's options. There is no bash: register
53
+ * createPiBashTool() and call invalidate from its afterRun hook.
54
+ */
55
+ export declare function createPiFsTools(options: CreatePiFsToolsOptions & {
56
+ readonly applyPatch: true | Omit<CreatePiApplyPatchToolOptions, BundleSharedKey>;
57
+ }): PiFsToolsWithApplyPatch;
58
+ export declare function createPiFsTools(options?: CreatePiFsToolsOptions): PiFsTools;
59
+ export {};
@@ -0,0 +1,93 @@
1
+ import { nodeDigest } from "@better-fs-tools/node";
2
+ import { createFsTools } from "@better-fs-tools/write";
3
+ import { adaptPiMutationTool, piApplyPatchParts, piEditParts, piWriteParts, } from "./mutation-tools.js";
4
+ import { checkPiOptions, PI_ROOT_KEYS, piFileSystems } from "./roots.js";
5
+ import { adaptPiReadTool, piReadDeps, piReadParts } from "./tool.js";
6
+ const KNOWN_OPTIONS = new Set([
7
+ "state",
8
+ "digest",
9
+ "locks",
10
+ "clock",
11
+ ...PI_ROOT_KEYS,
12
+ "read",
13
+ "edit",
14
+ "write",
15
+ "applyPatch",
16
+ ]);
17
+ const BUNDLE_SHARED_KEYS = ["state", "digest", "locks", "clock", ...PI_ROOT_KEYS];
18
+ export function createPiFsTools(options = {}) {
19
+ checkPiOptions(options, "fs");
20
+ for (const key of Object.keys(options)) {
21
+ if (!KNOWN_OPTIONS.has(key))
22
+ throw new TypeError(`Unknown createPiFsTools option: ${key}`);
23
+ }
24
+ const state = options.state;
25
+ if (state !== undefined && state !== null && !isReadStateStore(state)) {
26
+ throw new TypeError(`createPiFsTools state must be a read state store or null: a bundle takes one store, not a per-call factory`);
27
+ }
28
+ const given = {
29
+ read: options.read ?? {},
30
+ edit: options.edit ?? {},
31
+ write: options.write ?? {},
32
+ applyPatch: options.applyPatch === true
33
+ ? {}
34
+ : options.applyPatch === false || options.applyPatch === undefined
35
+ ? null
36
+ : options.applyPatch,
37
+ };
38
+ for (const [key, value] of Object.entries(given)) {
39
+ if (value === null)
40
+ continue;
41
+ checkPiOptions(value, key);
42
+ for (const shared of BUNDLE_SHARED_KEYS) {
43
+ if (Object.hasOwn(value, shared)) {
44
+ throw new TypeError(`createPiFsTools ${key} options cannot set ${shared}: set it once at the top level`);
45
+ }
46
+ }
47
+ }
48
+ const { denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode } = options;
49
+ const fs = piFileSystems({ denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode });
50
+ const { signature: _readSignature, promptSnippet: _readSnippet, promptGuidelines: _readLines, ...read } = given.read;
51
+ const { signature: _editSignature, promptSnippet: _editSnippet, promptGuidelines: _editLines, ...edit } = given.edit;
52
+ const { signature: _writeSignature, promptSnippet: _writeSnippet, promptGuidelines: _writeLines, ...write } = given.write;
53
+ const parts = {
54
+ read: piReadParts(given.read),
55
+ edit: piEditParts(given.edit),
56
+ write: piWriteParts(given.write),
57
+ };
58
+ let patch = null;
59
+ if (given.applyPatch !== null) {
60
+ const { signature: _patchSignature, promptSnippet: _patchSnippet, promptGuidelines: _patchLines, ...deps } = given.applyPatch;
61
+ patch = { deps, parts: piApplyPatchParts(given.applyPatch) };
62
+ }
63
+ const core = createFsTools({
64
+ fs,
65
+ digest: options.digest ?? nodeDigest(),
66
+ ...(options.state === undefined ? {} : { state: options.state }),
67
+ ...(options.locks === undefined ? {} : { locks: options.locks }),
68
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
69
+ read: { ...read, ...piReadDeps(parts.read) },
70
+ edit: { ...edit, messages: parts.edit.messages },
71
+ write: { ...write, messages: parts.write.messages },
72
+ ...(patch === null ? {} : { applyPatch: { ...patch.deps, messages: patch.parts.messages } }),
73
+ });
74
+ return Object.freeze({
75
+ read: adaptPiReadTool(parts.read, core.read, core.digest),
76
+ edit: adaptPiMutationTool(parts.edit, core.edit),
77
+ write: adaptPiMutationTool(parts.write, core.write),
78
+ applyPatch: patch === null || core.applyPatch === null
79
+ ? null
80
+ : adaptPiMutationTool(patch.parts, core.applyPatch),
81
+ state: core.state,
82
+ digest: core.digest,
83
+ locks: core.locks,
84
+ clock: core.clock,
85
+ invalidate: (path, call) => core.invalidate(path, call),
86
+ });
87
+ }
88
+ function isReadStateStore(value) {
89
+ if (value === null || typeof value !== "object")
90
+ return false;
91
+ const store = value;
92
+ return ["get", "put", "delete"].every((key) => typeof store[key] === "function");
93
+ }
@@ -0,0 +1,12 @@
1
+ export { createPiBashTool } from "./bash-tool.ts";
2
+ export type { CreatePiBashToolOptions, PiBashDetails, PiBashTool, PiBashToolResult, } from "./bash-tool.ts";
3
+ export { toPiReadDetails } from "./details.ts";
4
+ export type { PiReadDetails, PiTruncationResult } from "./details.ts";
5
+ export { createPiFsTools } from "./fs-tools.ts";
6
+ export type { CreatePiFsToolsOptions, PiFsTools, PiFsToolsWithApplyPatch } from "./fs-tools.ts";
7
+ export { toPiMutationDetails } from "./mutation-details.ts";
8
+ export type { PiMutationDetails } from "./mutation-details.ts";
9
+ export { createPiApplyPatchTool, createPiEditTool, createPiWriteTool } from "./mutation-tools.ts";
10
+ export type { CreatePiApplyPatchToolOptions, CreatePiEditToolOptions, CreatePiWriteToolOptions, PiMutationTool, PiMutationToolResult, } from "./mutation-tools.ts";
11
+ export { createPiReadTool } from "./tool.ts";
12
+ export type { CreatePiReadToolOptions, PiContentPart, PiReadTool, PiReadToolResult, } from "./tool.ts";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createPiBashTool } from "./bash-tool.js";
2
+ export { toPiReadDetails } from "./details.js";
3
+ export { createPiFsTools } from "./fs-tools.js";
4
+ export { toPiMutationDetails } from "./mutation-details.js";
5
+ export { createPiApplyPatchTool, createPiEditTool, createPiWriteTool } from "./mutation-tools.js";
6
+ export { createPiReadTool } from "./tool.js";
@@ -0,0 +1,15 @@
1
+ import type { MutationResult } from "@better-fs-tools/write";
2
+ /** Pi's EditToolDetails shape, so Pi's built-in edit renderer draws the diff. */
3
+ export interface PiMutationDetails {
4
+ /** Pi's display diff, with lines like "+NN text", "-NN text", " NN text", and "...". */
5
+ diff: string;
6
+ /** Unified diff of every change. */
7
+ patch: string;
8
+ firstChangedLine?: number;
9
+ }
10
+ /**
11
+ * Converts an edit or apply_patch result to Pi's edit details, so Pi draws the
12
+ * diff. Returns undefined for an error or a result with no changes. With more
13
+ * than one file, each file's lines follow a line with its path.
14
+ */
15
+ export declare function toPiMutationDetails(result: MutationResult): PiMutationDetails | undefined;
@@ -0,0 +1,76 @@
1
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u;
2
+ /**
3
+ * Converts an edit or apply_patch result to Pi's edit details, so Pi draws the
4
+ * diff. Returns undefined for an error or a result with no changes. With more
5
+ * than one file, each file's lines follow a line with its path.
6
+ */
7
+ export function toPiMutationDetails(result) {
8
+ if (result.status !== "ok" || result.changes.length === 0)
9
+ return undefined;
10
+ const blocks = [];
11
+ let firstChangedLine;
12
+ for (const change of result.changes) {
13
+ const display = toPiDisplayDiff(change.diff);
14
+ firstChangedLine ??= display.firstChangedLine;
15
+ const header = result.changes.length > 1 ? `${label(change)}\n` : "";
16
+ blocks.push(`${header}${display.text}`);
17
+ }
18
+ return {
19
+ diff: blocks.join("\n"),
20
+ patch: result.changes.map((change) => change.diff).join(""),
21
+ ...(firstChangedLine === undefined ? {} : { firstChangedLine }),
22
+ };
23
+ }
24
+ function label(change) {
25
+ return change.movedFrom === null ? change.path : `${change.movedFrom} → ${change.path}`;
26
+ }
27
+ // Context and removed lines carry the old line number, added lines the new one, as Pi's generateDiffString does.
28
+ function toPiDisplayDiff(unified) {
29
+ const rows = [];
30
+ let oldLine = 0;
31
+ let newLine = 0;
32
+ let inHunk = false;
33
+ let firstChangedLine;
34
+ for (const raw of unified.split("\n")) {
35
+ const hunk = HUNK_HEADER.exec(raw);
36
+ if (hunk !== null) {
37
+ if (inHunk)
38
+ rows.push({ kind: "...", line: 0, text: "" });
39
+ inHunk = true;
40
+ oldLine = firstLineOfRange(hunk[1], hunk[2]);
41
+ newLine = firstLineOfRange(hunk[3], hunk[4]);
42
+ continue;
43
+ }
44
+ if (!inHunk || raw.startsWith("\\"))
45
+ continue;
46
+ const kind = raw[0];
47
+ const text = raw.slice(1);
48
+ if (kind === "+") {
49
+ firstChangedLine ??= newLine;
50
+ rows.push({ kind, line: newLine, text });
51
+ newLine += 1;
52
+ }
53
+ else if (kind === "-") {
54
+ firstChangedLine ??= newLine;
55
+ rows.push({ kind, line: oldLine, text });
56
+ oldLine += 1;
57
+ }
58
+ else if (kind === " ") {
59
+ rows.push({ kind, line: oldLine, text });
60
+ oldLine += 1;
61
+ newLine += 1;
62
+ }
63
+ }
64
+ const width = String(Math.max(1, ...rows.map((row) => row.line))).length;
65
+ const text = rows
66
+ .map((row) => row.kind === "..."
67
+ ? ` ${"".padStart(width, " ")} ...`
68
+ : `${row.kind}${String(row.line).padStart(width, " ")} ${row.text}`)
69
+ .join("\n");
70
+ return { text, firstChangedLine };
71
+ }
72
+ // In a unified diff, a range with a zero count names the line before it.
73
+ function firstLineOfRange(line, count) {
74
+ const value = Number(line);
75
+ return count === "0" ? value + 1 : value;
76
+ }
@@ -0,0 +1,72 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TSchema } from "typebox";
3
+ import type { JsonObject, StateNeedsDigestOrDefault, ToolCallContext } from "@better-fs-tools/read";
4
+ import type { ApplyPatchToolDeps, EditToolDeps, MutationResult, WriteMessageCatalog, WriteToolDeps } from "@better-fs-tools/write";
5
+ import type { EditSignature, MutationSignature, PatchSignature, WriteSignature } from "@better-fs-tools/write/signature";
6
+ import type { PiMutationDetails } from "./mutation-details.ts";
7
+ import type { PiContentPart } from "./parts.ts";
8
+ import type { PiRootOptions } from "./roots.ts";
9
+ /** Pi prompt options of the edit, write, and apply_patch tools. */
10
+ export interface PiMutationOptions<TSignature> {
11
+ readonly signature?: TSignature;
12
+ readonly promptSnippet?: string;
13
+ readonly promptGuidelines?: readonly string[];
14
+ }
15
+ /**
16
+ * Options for createPiEditTool. The default signature is Pi's own edit input,
17
+ * multiEditSignature({ matchers }), so Pi's edit renderer and prompt fit.
18
+ * digest defaults to nodeDigest(); a state needs a digest that is not null.
19
+ */
20
+ export type CreatePiEditToolOptions = Omit<EditToolDeps<ExtensionContext>, "fs"> & StateNeedsDigestOrDefault & PiRootOptions & PiMutationOptions<EditSignature>;
21
+ /** As CreatePiEditToolOptions, for write. The default signature is defaultWriteSignature(). */
22
+ export type CreatePiWriteToolOptions = Omit<WriteToolDeps<ExtensionContext>, "fs"> & StateNeedsDigestOrDefault & PiRootOptions & PiMutationOptions<WriteSignature>;
23
+ /** As CreatePiEditToolOptions, for apply_patch. The default signature is freeformPatchSignature(). */
24
+ export type CreatePiApplyPatchToolOptions = Omit<ApplyPatchToolDeps<ExtensionContext>, "fs"> & StateNeedsDigestOrDefault & PiRootOptions & PiMutationOptions<PatchSignature>;
25
+ export interface PiMutationToolResult {
26
+ content: PiContentPart[];
27
+ details: PiMutationDetails | undefined;
28
+ }
29
+ /** Assignable to ToolDefinition<TSchema, PiMutationDetails | undefined> from Pi 0.84.2. */
30
+ export interface PiMutationTool {
31
+ readonly name: string;
32
+ readonly label: string;
33
+ readonly description: string;
34
+ readonly promptSnippet: string;
35
+ readonly promptGuidelines: string[];
36
+ /** The JSON Schema of the tool input. */
37
+ readonly parameters: TSchema;
38
+ /** Set when the signature has a grammar. */
39
+ readonly constrainedSampling?: {
40
+ type: "grammar";
41
+ variants: {
42
+ openai_lark: string;
43
+ };
44
+ };
45
+ execute(toolCallId: string, input: JsonObject, signal: AbortSignal | undefined, onUpdate: unknown, ctx: ExtensionContext): Promise<PiMutationToolResult>;
46
+ }
47
+ type Core = (input: never, ctx: ToolCallContext<ExtensionContext>) => Promise<MutationResult>;
48
+ export interface PiMutationParts {
49
+ readonly tool: "edit" | "write" | "apply_patch";
50
+ readonly signature: MutationSignature<unknown>;
51
+ readonly promptSnippet: string;
52
+ readonly promptGuidelines: readonly string[];
53
+ readonly messages: Partial<WriteMessageCatalog>;
54
+ }
55
+ type PartsInput<TSignature> = PiMutationOptions<TSignature> & {
56
+ readonly messages?: Partial<WriteMessageCatalog>;
57
+ };
58
+ export declare function piEditParts(options: PartsInput<EditSignature> & Pick<Partial<EditToolDeps>, "matchers">): PiMutationParts;
59
+ export declare function piWriteParts(options: PartsInput<WriteSignature>): PiMutationParts;
60
+ export declare function piApplyPatchParts(options: PartsInput<PatchSignature>): PiMutationParts;
61
+ /**
62
+ * Creates a Pi edit tool rooted at each call's ctx.cwd. Throws TypeError when
63
+ * options set fs, cwd, or allowedRoots. Read-before-write is off unless you
64
+ * pass a `state`; use createPiFsTools({ state }) for a store shared with read.
65
+ */
66
+ export declare function createPiEditTool(options?: CreatePiEditToolOptions): PiMutationTool;
67
+ /** Creates a Pi write tool, as createPiEditTool. `details` is always undefined, as for Pi's own write. */
68
+ export declare function createPiWriteTool(options?: CreatePiWriteToolOptions): PiMutationTool;
69
+ /** Creates a Pi apply_patch tool, as createPiEditTool. A grammar signature sets constrainedSampling. */
70
+ export declare function createPiApplyPatchTool(options?: CreatePiApplyPatchToolOptions): PiMutationTool;
71
+ export declare function adaptPiMutationTool(made: PiMutationParts, tool: Core): PiMutationTool;
72
+ export {};
@@ -0,0 +1,117 @@
1
+ import { Type } from "typebox";
2
+ import { nodeDigest } from "@better-fs-tools/node";
3
+ import { createApplyPatchTool, createEditTool, createWriteTool } from "@better-fs-tools/write";
4
+ import { defaultWriteSignature, freeformPatchSignature, multiEditSignature, writeSignatureMessages, } from "@better-fs-tools/write/signature";
5
+ import { toPiMutationDetails } from "./mutation-details.js";
6
+ import { toPiPart } from "./parts.js";
7
+ import { checkPiContext, checkPiOptions, piFileSystems } from "./roots.js";
8
+ const PI_BUILTIN_EDIT_SNIPPET = "Make precise file edits with exact text replacement, including multiple disjoint edits in one call";
9
+ const EDIT_GUIDELINES = [
10
+ "Use edit for precise changes (edits[].oldText must match exactly)",
11
+ "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
12
+ "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
13
+ "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
14
+ ];
15
+ const PI_BUILTIN_WRITE_SNIPPET = "Create or overwrite files";
16
+ const WRITE_GUIDELINES = ["Use write only for new files or complete rewrites."];
17
+ const PATCH_SNIPPET = "Add, update, move, or delete files with one patch";
18
+ const PATCH_GUIDELINES = [
19
+ "Use apply_patch for related changes across several files, or to add, move, or delete files.",
20
+ ];
21
+ export function piEditParts(options) {
22
+ const signature = options.signature ??
23
+ multiEditSignature(options.matchers === undefined ? {} : { matchers: options.matchers });
24
+ return parts("edit", signature, options, PI_BUILTIN_EDIT_SNIPPET, EDIT_GUIDELINES);
25
+ }
26
+ export function piWriteParts(options) {
27
+ const signature = options.signature ?? defaultWriteSignature();
28
+ return parts("write", signature, options, PI_BUILTIN_WRITE_SNIPPET, WRITE_GUIDELINES);
29
+ }
30
+ export function piApplyPatchParts(options) {
31
+ const signature = options.signature ?? freeformPatchSignature();
32
+ return parts("apply_patch", signature, options, PATCH_SNIPPET, PATCH_GUIDELINES);
33
+ }
34
+ function parts(tool, signature, options, snippet, guidelines) {
35
+ return {
36
+ tool,
37
+ signature,
38
+ promptSnippet: options.promptSnippet ?? snippet,
39
+ promptGuidelines: options.promptGuidelines ?? guidelines,
40
+ messages: { ...writeSignatureMessages(signature), ...options.messages },
41
+ };
42
+ }
43
+ /**
44
+ * Creates a Pi edit tool rooted at each call's ctx.cwd. Throws TypeError when
45
+ * options set fs, cwd, or allowedRoots. Read-before-write is off unless you
46
+ * pass a `state`; use createPiFsTools({ state }) for a store shared with read.
47
+ */
48
+ export function createPiEditTool(options = {}) {
49
+ checkPiOptions(options, "edit");
50
+ const { denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode, signature: _signature, promptSnippet: _snippet, promptGuidelines: _lines, ...deps } = options;
51
+ const fs = piFileSystems({ denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode });
52
+ const made = piEditParts(options);
53
+ const { messages } = made;
54
+ // digest: null narrows the options to the branch without a state.
55
+ if (deps.digest === null) {
56
+ return adaptPiMutationTool(made, createEditTool({ ...deps, fs, messages, digest: null }));
57
+ }
58
+ const digest = deps.digest ?? nodeDigest();
59
+ return adaptPiMutationTool(made, createEditTool({ ...deps, fs, messages, digest }));
60
+ }
61
+ /** Creates a Pi write tool, as createPiEditTool. `details` is always undefined, as for Pi's own write. */
62
+ export function createPiWriteTool(options = {}) {
63
+ checkPiOptions(options, "write");
64
+ const { denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode, signature: _signature, promptSnippet: _snippet, promptGuidelines: _lines, ...deps } = options;
65
+ const fs = piFileSystems({ denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode });
66
+ const made = piWriteParts(options);
67
+ const { messages } = made;
68
+ if (deps.digest === null) {
69
+ return adaptPiMutationTool(made, createWriteTool({ ...deps, fs, messages, digest: null }));
70
+ }
71
+ const digest = deps.digest ?? nodeDigest();
72
+ return adaptPiMutationTool(made, createWriteTool({ ...deps, fs, messages, digest }));
73
+ }
74
+ /** Creates a Pi apply_patch tool, as createPiEditTool. A grammar signature sets constrainedSampling. */
75
+ export function createPiApplyPatchTool(options = {}) {
76
+ checkPiOptions(options, "apply_patch");
77
+ const { denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode, signature: _signature, promptSnippet: _snippet, promptGuidelines: _lines, ...deps } = options;
78
+ const fs = piFileSystems({ denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode });
79
+ const made = piApplyPatchParts(options);
80
+ const { messages } = made;
81
+ if (deps.digest === null) {
82
+ return adaptPiMutationTool(made, createApplyPatchTool({ ...deps, fs, messages, digest: null }));
83
+ }
84
+ const digest = deps.digest ?? nodeDigest();
85
+ return adaptPiMutationTool(made, createApplyPatchTool({ ...deps, fs, messages, digest }));
86
+ }
87
+ export function adaptPiMutationTool(made, tool) {
88
+ const { signature, tool: toolName, promptSnippet, promptGuidelines } = made;
89
+ const withDetails = toolName !== "write";
90
+ const grammar = signature.grammar;
91
+ return Object.freeze({
92
+ name: signature.name,
93
+ label: signature.name,
94
+ description: signature.description,
95
+ promptSnippet,
96
+ promptGuidelines: [...promptGuidelines],
97
+ parameters: Type.Unsafe(signature.schema),
98
+ ...(grammar === undefined
99
+ ? {}
100
+ : {
101
+ constrainedSampling: { type: "grammar", variants: { openai_lark: grammar.lark } },
102
+ }),
103
+ async execute(toolCallId, input, signal, _onUpdate, ctx) {
104
+ checkPiContext(ctx, toolName);
105
+ const call = {
106
+ ...(signal === undefined ? {} : { signal }),
107
+ callId: toolCallId,
108
+ host: ctx,
109
+ };
110
+ const result = await tool(signature.toInput(input), call);
111
+ return {
112
+ content: result.content.map((part) => toPiPart(part, toolName)),
113
+ details: withDetails ? toPiMutationDetails(result) : undefined,
114
+ };
115
+ },
116
+ });
117
+ }
@@ -0,0 +1,10 @@
1
+ import type { ContentPart } from "@better-fs-tools/read";
2
+ export type PiContentPart = {
3
+ type: "text";
4
+ text: string;
5
+ } | {
6
+ type: "image";
7
+ data: string;
8
+ mimeType: string;
9
+ };
10
+ export declare function toPiPart(part: ContentPart, tool: string): PiContentPart;
package/dist/parts.js ADDED
@@ -0,0 +1,20 @@
1
+ import { Buffer } from "node:buffer";
2
+ export function toPiPart(part, tool) {
3
+ switch (part.type) {
4
+ case "text":
5
+ return { type: "text", text: part.text };
6
+ case "media":
7
+ // Pi's tool content takes images only.
8
+ if (!part.mediaType.startsWith("image/")) {
9
+ return {
10
+ type: "text",
11
+ text: `[${tool}:media-omitted] ${part.mediaType} (${part.data.byteLength} bytes) cannot be shown in Pi.`,
12
+ };
13
+ }
14
+ return {
15
+ type: "image",
16
+ data: Buffer.from(part.data.buffer, part.data.byteOffset, part.data.byteLength).toString("base64"),
17
+ mimeType: part.mediaType,
18
+ };
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ import type { NodeFileSystem, NodeFileSystemOptions } from "@better-fs-tools/node";
2
+ import type { ToolCallContext } from "@better-fs-tools/read";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ /** Filesystem options for a Pi tool. The root is always ctx.cwd. */
5
+ export interface PiRootOptions {
6
+ /** Added to /dev, /proc, /sys. */
7
+ readonly denyRoots?: readonly string[];
8
+ readonly symlinks?: NodeFileSystemOptions["symlinks"];
9
+ /** How to replace a file with more than one hard link. Default "in-place". "refuse" fails with DENIED. */
10
+ readonly hardLinks?: "refuse" | "in-place";
11
+ /** Mode of a new file, exactly. Default 0o666 less the process umask. */
12
+ readonly newFileMode?: number;
13
+ /** Mode of a directory that createParents makes, exactly. Default 0o777 less the umask. */
14
+ readonly newDirectoryMode?: number;
15
+ }
16
+ export declare const PI_ROOT_KEYS: readonly ["denyRoots", "symlinks", "hardLinks", "newFileMode", "newDirectoryMode"];
17
+ export type PiFileSystems = (call: ToolCallContext<ExtensionContext>) => NodeFileSystem;
18
+ export declare function checkPiOptions(options: unknown, tool: string): void;
19
+ export declare function checkPiContext(ctx: unknown, tool: string): void;
20
+ export declare function piFileSystems(options: PiRootOptions): PiFileSystems;
package/dist/roots.js ADDED
@@ -0,0 +1,58 @@
1
+ import path from "node:path";
2
+ import { nodeFileSystem } from "@better-fs-tools/node";
3
+ const MAX_CACHED_ROOTS = 8;
4
+ const ROOT_WIDENING_OPTIONS = ["fs", "cwd", "allowedRoots"];
5
+ export const PI_ROOT_KEYS = [
6
+ "denyRoots",
7
+ "symlinks",
8
+ "hardLinks",
9
+ "newFileMode",
10
+ "newDirectoryMode",
11
+ ];
12
+ export function checkPiOptions(options, tool) {
13
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
14
+ throw new TypeError(`Pi ${tool} tool options must be an object`);
15
+ }
16
+ for (const key of ROOT_WIDENING_OPTIONS) {
17
+ if (Object.hasOwn(options, key)) {
18
+ throw new TypeError(`Pi ${tool} tool options cannot set ${key}: the root is bound to ctx.cwd on every call`);
19
+ }
20
+ }
21
+ }
22
+ export function checkPiContext(ctx, tool) {
23
+ if (ctx === null ||
24
+ typeof ctx !== "object" ||
25
+ typeof ctx.cwd !== "string" ||
26
+ ctx.cwd.length === 0) {
27
+ throw new TypeError(`Pi ${tool} execution requires ctx.cwd`);
28
+ }
29
+ }
30
+ export function piFileSystems(options) {
31
+ const { denyRoots, symlinks, hardLinks, newFileMode, newDirectoryMode } = options;
32
+ const fileSystemFor = rootCache((root) => nodeFileSystem({
33
+ cwd: root,
34
+ allowedRoots: [root],
35
+ ...(denyRoots === undefined ? {} : { denyRoots }),
36
+ ...(symlinks === undefined ? {} : { symlinks }),
37
+ ...(hardLinks === undefined ? {} : { hardLinks }),
38
+ ...(newFileMode === undefined ? {} : { newFileMode }),
39
+ ...(newDirectoryMode === undefined ? {} : { newDirectoryMode }),
40
+ }));
41
+ return (call) => fileSystemFor(path.resolve(call.host.cwd));
42
+ }
43
+ function rootCache(build) {
44
+ const cache = new Map();
45
+ return (root) => {
46
+ const cached = cache.get(root);
47
+ if (cached !== undefined)
48
+ return cached;
49
+ const fs = build(root);
50
+ if (cache.size >= MAX_CACHED_ROOTS) {
51
+ const oldest = cache.keys().next();
52
+ if (!oldest.done)
53
+ cache.delete(oldest.value);
54
+ }
55
+ cache.set(root, fs);
56
+ return fs;
57
+ };
58
+ }
package/dist/tool.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TSchema } from "typebox";
3
+ import type { NodeFileSystemOptions } from "@better-fs-tools/node";
4
+ import type { Converter, Digest, JsonObject, ReadFormatter, ReadLimits, ReadMessageCatalog, ReadTool, ReadToolDeps, StateNeedsDigestOrDefault } from "@better-fs-tools/read";
5
+ import type { ReadSignature } from "@better-fs-tools/read/signature";
6
+ import type { PiReadDetails } from "./details.ts";
7
+ import type { PiContentPart } from "./parts.ts";
8
+ export type { PiContentPart } from "./parts.ts";
9
+ /** Pi prompt options of the read tool. */
10
+ export interface PiReadOptions {
11
+ /** Default defaultReadSignature({ name: "read" }). */
12
+ readonly signature?: ReadSignature;
13
+ /** Default "Read file contents". */
14
+ readonly promptSnippet?: string;
15
+ /** Default ["Use read to examine files instead of cat or sed."]. */
16
+ readonly promptGuidelines?: readonly string[];
17
+ }
18
+ /**
19
+ * Options for createPiReadTool. digest defaults to nodeDigest(); a state needs
20
+ * a digest that is not null. converters defaults to [imageConverter()], as
21
+ * Pi's own read shows images. A list you pass replaces it: [] turns images off.
22
+ */
23
+ export type CreatePiReadToolOptions = Omit<ReadToolDeps<ExtensionContext>, "fs"> & StateNeedsDigestOrDefault & PiReadOptions & {
24
+ /** Added to /dev, /proc, /sys. */
25
+ readonly denyRoots?: readonly string[];
26
+ readonly symlinks?: NodeFileSystemOptions["symlinks"];
27
+ };
28
+ export interface PiReadToolResult {
29
+ content: PiContentPart[];
30
+ details: PiReadDetails;
31
+ }
32
+ /** Assignable to ToolDefinition<TSchema, PiReadDetails> from Pi 0.84.2. */
33
+ export interface PiReadTool {
34
+ readonly name: string;
35
+ readonly label: string;
36
+ readonly description: string;
37
+ readonly promptSnippet: string;
38
+ readonly promptGuidelines: string[];
39
+ /** The JSON Schema of the tool input. */
40
+ readonly parameters: TSchema;
41
+ execute(toolCallId: string, input: JsonObject, signal: AbortSignal | undefined, onUpdate: unknown, ctx: ExtensionContext): Promise<PiReadToolResult>;
42
+ }
43
+ /**
44
+ * Creates a Pi read tool rooted at each call's ctx.cwd. Throws TypeError when
45
+ * options set fs, cwd, or allowedRoots. It keeps no read state by default and
46
+ * writes nothing outside the process. An image becomes a Pi image part unless
47
+ * options.converters leaves imageConverter() out.
48
+ */
49
+ export declare function createPiReadTool(options?: CreatePiReadToolOptions): PiReadTool;
50
+ export interface PiReadParts {
51
+ readonly signature: ReadSignature;
52
+ readonly promptSnippet: string;
53
+ readonly promptGuidelines: readonly string[];
54
+ readonly limits: Readonly<ReadLimits>;
55
+ readonly formatter: ReadFormatter<ExtensionContext>;
56
+ readonly messages: Partial<ReadMessageCatalog>;
57
+ readonly converters: readonly Converter<ExtensionContext>[];
58
+ }
59
+ export declare function piReadParts(options: PiReadOptions & Pick<Partial<ReadToolDeps<ExtensionContext>>, "limits" | "messages" | "formatter" | "converters">): PiReadParts;
60
+ export declare function piReadDeps(parts: PiReadParts): {
61
+ limits: Readonly<ReadLimits>;
62
+ formatter: ReadFormatter<ExtensionContext>;
63
+ messages: Partial<ReadMessageCatalog>;
64
+ converters: readonly Converter<ExtensionContext>[];
65
+ };
66
+ export declare function adaptPiReadTool(parts: PiReadParts, read: ReadTool<ExtensionContext>, digest: Digest | null): PiReadTool;
package/dist/tool.js ADDED
@@ -0,0 +1,89 @@
1
+ import { Type } from "typebox";
2
+ import { nodeDigest } from "@better-fs-tools/node";
3
+ import { createReadTool, imageConverter, lineNumberFormatter, resolveReadLimits, } from "@better-fs-tools/read";
4
+ import { defaultReadSignature, readSignatureMessages } from "@better-fs-tools/read/signature";
5
+ import { toPiReadDetails } from "./details.js";
6
+ import { toPiPart } from "./parts.js";
7
+ import { checkPiContext, checkPiOptions, piFileSystems } from "./roots.js";
8
+ const PI_BUILTIN_READ_SNIPPET = "Read file contents";
9
+ const PI_BUILTIN_READ_GUIDELINES = [
10
+ "Use read to examine files instead of cat or sed.",
11
+ ];
12
+ /**
13
+ * Creates a Pi read tool rooted at each call's ctx.cwd. Throws TypeError when
14
+ * options set fs, cwd, or allowedRoots. It keeps no read state by default and
15
+ * writes nothing outside the process. An image becomes a Pi image part unless
16
+ * options.converters leaves imageConverter() out.
17
+ */
18
+ export function createPiReadTool(options = {}) {
19
+ checkPiOptions(options, "read");
20
+ const { denyRoots, symlinks, signature: _signature, promptSnippet: _promptSnippet, promptGuidelines: _promptGuidelines, ...deps } = options;
21
+ const parts = piReadParts(options);
22
+ const fs = piFileSystems({ denyRoots, symlinks });
23
+ // digest: null narrows the options to the branch without a state.
24
+ if (deps.digest === null) {
25
+ const read = createReadTool({ ...deps, ...piReadDeps(parts), fs, digest: null });
26
+ return adaptPiReadTool(parts, read, null);
27
+ }
28
+ const digest = deps.digest ?? nodeDigest();
29
+ const read = createReadTool({ ...deps, ...piReadDeps(parts), fs, digest });
30
+ return adaptPiReadTool(parts, read, digest);
31
+ }
32
+ export function piReadParts(options) {
33
+ const signature = options.signature ?? defaultReadSignature({ name: "read" });
34
+ return {
35
+ signature,
36
+ promptSnippet: options.promptSnippet ?? PI_BUILTIN_READ_SNIPPET,
37
+ promptGuidelines: options.promptGuidelines ?? PI_BUILTIN_READ_GUIDELINES,
38
+ limits: resolveReadLimits(options.limits),
39
+ formatter: options.formatter ?? lineNumberFormatter(),
40
+ messages: { ...readSignatureMessages(signature), ...options.messages },
41
+ // Pi's own read returns images, so the Pi host turns them on.
42
+ converters: options.converters ?? [imageConverter()],
43
+ };
44
+ }
45
+ export function piReadDeps(parts) {
46
+ return {
47
+ limits: parts.limits,
48
+ formatter: parts.formatter,
49
+ messages: parts.messages,
50
+ converters: parts.converters,
51
+ };
52
+ }
53
+ export function adaptPiReadTool(parts, read, digest) {
54
+ const { signature, promptSnippet, promptGuidelines, limits, formatter } = parts;
55
+ return Object.freeze({
56
+ name: signature.name,
57
+ label: signature.name,
58
+ description: signature.description,
59
+ promptSnippet,
60
+ promptGuidelines: [...promptGuidelines],
61
+ parameters: Type.Unsafe(signature.schema),
62
+ async execute(toolCallId, input, signal, _onUpdate, ctx) {
63
+ checkPiContext(ctx, "read");
64
+ const call = {
65
+ ...(signal === undefined ? {} : { signal }),
66
+ callId: toolCallId,
67
+ host: ctx,
68
+ };
69
+ const result = await read(signature.toInput(input), call);
70
+ return {
71
+ content: result.content.map((part) => toPiPart(part, "read")),
72
+ details: toPiReadDetails(result, viewOf(formatter, result, { digest, limits, mode: "view", call }), limits.maxViewBytes),
73
+ };
74
+ },
75
+ });
76
+ }
77
+ function viewOf(formatter, result, ctx) {
78
+ if (result.status !== "ok")
79
+ return null;
80
+ const { content: _content, ...outcome } = result;
81
+ try {
82
+ const view = formatter.format(outcome, ctx);
83
+ return typeof view === "string" ? view : null;
84
+ }
85
+ catch {
86
+ // The core already fell back and reported the error in "model" mode.
87
+ return null;
88
+ }
89
+ }
package/package.json CHANGED
@@ -1,5 +1,67 @@
1
1
  {
2
2
  "name": "@better-fs-tools/pi",
3
- "version": "0.0.0-tegami-trusted-publish-setup",
4
- "description": "Placeholder published by Tegami for npm trusted publishing setup."
3
+ "version": "0.2.0",
4
+ "description": "The Better FS Tools read, edit, write, apply_patch, and bash tools and extension for the Pi coding agent.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gingerhendrix/better-fs-tools.git",
9
+ "directory": "packages/pi"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ },
21
+ "./extension": {
22
+ "types": "./dist/extension.d.ts",
23
+ "import": "./dist/extension.js"
24
+ }
25
+ },
26
+ "publishConfig": {
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./extension": {
33
+ "types": "./dist/extension.d.ts",
34
+ "import": "./dist/extension.js"
35
+ }
36
+ },
37
+ "access": "public",
38
+ "pi": {
39
+ "extensions": [
40
+ "./dist/extension.js"
41
+ ]
42
+ }
43
+ },
44
+ "dependencies": {
45
+ "@better-fs-tools/node": "^0.2.0",
46
+ "@better-fs-tools/read": "^0.2.0",
47
+ "@better-fs-tools/shell": "^0.2.0",
48
+ "@better-fs-tools/write": "^0.2.0"
49
+ },
50
+ "devDependencies": {
51
+ "@better-fs-tools/fs": "^0.2.0",
52
+ "@earendil-works/pi-coding-agent": "^0.84.2",
53
+ "typebox": "^1.3.16"
54
+ },
55
+ "peerDependencies": {
56
+ "@earendil-works/pi-coding-agent": "^0.84.2",
57
+ "typebox": "^1.3.16"
58
+ },
59
+ "engines": {
60
+ "node": ">=24"
61
+ },
62
+ "pi": {
63
+ "extensions": [
64
+ "./dist/extension.js"
65
+ ]
66
+ }
5
67
  }