@andares/pi-toolkits 0.9.6 → 0.9.7

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for the `scratchpad` tool: it must stay inactive until ask mode turns
3
+ * it on, keep every operation inside the session store, and teach the
4
+ * "notes, not deliverables" rule through its own declaration.
5
+ */
6
+ import { mkdtempSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { ASK_SCRATCH_TOOL } from "./constants.js";
12
+ import { createScratchStore, type ScratchStore } from "./scratch.js";
13
+ import { registerScratchTool } from "./scratch-tool.js";
14
+
15
+ interface ToolDefinition {
16
+ name: string;
17
+ defaultActive?: boolean;
18
+ description: string;
19
+ promptSnippet?: string;
20
+ promptGuidelines?: string[];
21
+ parameters: unknown;
22
+ execute(
23
+ toolCallId: string,
24
+ params: Record<string, unknown>,
25
+ signal: undefined,
26
+ onUpdate: undefined,
27
+ ctx: unknown,
28
+ ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
29
+ }
30
+
31
+ function harness() {
32
+ let definition: ToolDefinition | undefined;
33
+ let current: ScratchStore | undefined;
34
+ const pi = {
35
+ registerTool(candidate: ToolDefinition) {
36
+ definition = candidate;
37
+ },
38
+ } as unknown as ExtensionAPI;
39
+ registerScratchTool(pi, () => current);
40
+ const registered = definition;
41
+ if (!registered) throw new Error("scratch tool was not registered");
42
+
43
+ const call = (params: Record<string, unknown>) =>
44
+ registered.execute("call-1", params, undefined, undefined, {});
45
+
46
+ return {
47
+ definition: registered,
48
+ use: (store: ScratchStore | undefined) => {
49
+ current = store;
50
+ },
51
+ call,
52
+ };
53
+ }
54
+
55
+ function store(): ScratchStore {
56
+ return createScratchStore({
57
+ baseDir: mkdtempSync(join(tmpdir(), "pi-ask-tool-")),
58
+ sessionId: "session-1",
59
+ });
60
+ }
61
+
62
+ describe("scratchpad tool declaration", () => {
63
+ it("is inactive until ask mode activates it", () => {
64
+ const h = harness();
65
+ expect(h.definition.name).toBe(ASK_SCRATCH_TOOL);
66
+ expect(h.definition.defaultActive).toBe(false);
67
+ });
68
+
69
+ it("carries the mode-scoped guidelines that keep notes out of the deliverable", () => {
70
+ const h = harness();
71
+ const guidelines = (h.definition.promptGuidelines ?? []).join("\n");
72
+ expect(guidelines).toContain("ask mode");
73
+ expect(guidelines).toContain("not deliverables");
74
+ expect(h.definition.description).toContain("NOT deliverables");
75
+ expect(h.definition.description).toContain("absolute paths");
76
+ });
77
+ });
78
+
79
+ describe("scratchpad operations", () => {
80
+ it("writes and reads a note back", async () => {
81
+ const h = harness();
82
+ const s = await store();
83
+ h.use(s);
84
+
85
+ const written = await h.call({
86
+ op: "write",
87
+ name: "plan.md",
88
+ content: "1. read\n2. answer\n",
89
+ });
90
+ expect(written.isError).toBeUndefined();
91
+ expect(written.content[0]?.text).toContain("Wrote plan.md");
92
+ expect(written.content[0]?.text).toContain("2 lines");
93
+
94
+ const read = await h.call({ op: "read", name: "plan.md" });
95
+ expect(read.content[0]?.text).toBe("1. read\n2. answer\n");
96
+ });
97
+
98
+ it("appends and lists", async () => {
99
+ const h = harness();
100
+ h.use(await store());
101
+ await h.call({ op: "write", name: "plan.md", content: "a" });
102
+ const appended = await h.call({ op: "append", name: "plan.md", content: "b" });
103
+ expect(appended.content[0]?.text).toContain("Appended to plan.md");
104
+ const listed = await h.call({ op: "list" });
105
+ expect(listed.content[0]?.text).toContain("plan.md");
106
+ });
107
+
108
+ it("deletes, and reading afterwards explains the miss", async () => {
109
+ const h = harness();
110
+ h.use(await store());
111
+ await h.call({ op: "write", name: "plan.md", content: "x" });
112
+ const deleted = await h.call({ op: "delete", name: "plan.md" });
113
+ expect(deleted.content[0]?.text).toContain("Deleted plan.md");
114
+
115
+ const read = await h.call({ op: "read", name: "plan.md" });
116
+ expect(read.isError).toBe(true);
117
+ expect(read.content[0]?.text).toContain("no scratch note");
118
+ });
119
+ });
120
+
121
+ describe("scratchpad failures", () => {
122
+ it("rejects an absolute name without touching the disk", async () => {
123
+ const h = harness();
124
+ h.use(await store());
125
+ const result = await h.call({
126
+ op: "write",
127
+ name: "/etc/passwd",
128
+ content: "x",
129
+ });
130
+ expect(result.isError).toBe(true);
131
+ expect(result.content[0]?.text).toContain("not a relative name");
132
+ });
133
+
134
+ it("reports the size cap", async () => {
135
+ const h = harness();
136
+ h.use(await store());
137
+ const result = await h.call({
138
+ op: "write",
139
+ name: "big.md",
140
+ content: "x".repeat(256 * 1024 + 1),
141
+ });
142
+ expect(result.isError).toBe(true);
143
+ expect(result.content[0]?.text).toContain("capped at");
144
+ });
145
+
146
+ it("asks for a name when the op needs one", async () => {
147
+ const h = harness();
148
+ h.use(await store());
149
+ const result = await h.call({ op: "read" });
150
+ expect(result.isError).toBe(true);
151
+ expect(result.content[0]?.text).toContain("needs a note name");
152
+ });
153
+
154
+ it("refuses to write when the mode has no scratch space", async () => {
155
+ const h = harness();
156
+ h.use(undefined);
157
+ const result = await h.call({ op: "list" });
158
+ expect(result.isError).toBe(true);
159
+ expect(result.content[0]?.text).toContain("not available");
160
+ });
161
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ask feature — the `scratchpad` tool.
3
+ *
4
+ * The tool the model writes planning notes with while ask mode is on. It is
5
+ * registered once with `defaultActive: false` and activated by `/ask`, so the
6
+ * tool declaration is part of the request only in that mode — the mode-scoped
7
+ * surface is the cheapest place to keep the rules: `promptGuidelines` rides in
8
+ * the system prompt only while the tool is active, and the declaration itself
9
+ * tells the model that the only writable place is throwaway.
10
+ *
11
+ * Deliberately *not* a path-taking tool: `name` is a relative note name and the
12
+ * store validates it, so the model never juggles absolute paths and the host
13
+ * never has to parse one. Nothing written here can reach the project.
14
+ */
15
+ import { Type } from "typebox";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { ASK_SCRATCH_TOOL } from "./constants.js";
18
+ import type { ScratchStore } from "./scratch.js";
19
+ import { SCRATCH_MAX_BYTES } from "./scratch.js";
20
+
21
+ const SCRATCH_PARAMS = Type.Object({
22
+ op: Type.Union(
23
+ [
24
+ Type.Literal("write"),
25
+ Type.Literal("append"),
26
+ Type.Literal("read"),
27
+ Type.Literal("list"),
28
+ Type.Literal("delete"),
29
+ ],
30
+ {
31
+ description:
32
+ "write replaces a note, append adds to it, read returns it, list shows the notes that exist, delete removes one.",
33
+ },
34
+ ),
35
+ name: Type.Optional(
36
+ Type.String({
37
+ description:
38
+ 'Relative note name, for example "plan.md" or "notes/api.md". Required for every op except list. Absolute paths are rejected.',
39
+ }),
40
+ ),
41
+ content: Type.Optional(
42
+ Type.String({
43
+ description: 'Full note text for op "write", the added text for op "append".',
44
+ }),
45
+ ),
46
+ });
47
+
48
+ const SCRATCH_DESCRIPTION = `Throwaway scratch notes for this consult session.
49
+
50
+ Ask mode is read-only for the project, so this is the one place to keep
51
+ intermediate planning output that would clutter the reply: an outline, a
52
+ checklist, a comparison table, draft analysis. Notes live in a temp directory
53
+ that is separate from the repository and cleaned up by the system later.
54
+
55
+ These notes are NOT deliverables and NOT changes: nothing written here shows up
56
+ in the project, and the operator never asked for it. If the answer requires
57
+ changing a real file, describe the change and tell the operator to exit ask mode
58
+ with /ask instead of writing the change here.
59
+
60
+ Ops: write (create or replace), append, read, list, delete. Names are relative
61
+ ("plan.md", "notes/api.md"); absolute paths are rejected. Each note is capped at
62
+ ${Math.floor(SCRATCH_MAX_BYTES / 1024)} KiB.`;
63
+
64
+ const SCRATCH_GUIDELINES = [
65
+ "While in ask mode, use `scratchpad` for intermediate notes (outlines, checklists, draft analysis) instead of pasting them into the reply; it writes only to a throwaway temp directory, never to the project.",
66
+ "Scratch notes are not deliverables: when the answer needs a real file change, describe the change and tell the operator to exit ask mode with /ask.",
67
+ ];
68
+
69
+ /** Bytes as `1.2 KiB` / `320 B`, for tool results. */
70
+ function formatBytes(bytes: number): string {
71
+ if (bytes < 1024) return `${bytes} B`;
72
+ return `${(bytes / 1024).toFixed(1)} KiB`;
73
+ }
74
+
75
+ /** Model-facing text of an operation on a note. */
76
+ function describe(
77
+ op: "write" | "append" | "delete",
78
+ name: string,
79
+ bytes: number,
80
+ lines: number,
81
+ ): string {
82
+ if (op === "delete") return `Deleted ${name} from the scratch directory.`;
83
+ const size = formatBytes(bytes);
84
+ if (op === "append") {
85
+ return `Appended to ${name} (now ${size}). Scratch notes are not part of the project.`;
86
+ }
87
+ return `Wrote ${name} (${size}, ${lines} line${lines === 1 ? "" : "s"}). Scratch notes are not part of the project.`;
88
+ }
89
+
90
+ /** Result details of a `scratchpad` call (shown by renderers, not the model). */
91
+ export interface ScratchDetails {
92
+ readonly op: string;
93
+ readonly name?: string;
94
+ readonly bytes?: number;
95
+ }
96
+
97
+ /**
98
+ * Register the tool. `store` is read on every call: ask mode sets it on entry
99
+ * and clears it on exit, so an unexpected call after exit fails with an
100
+ * explanation instead of writing anywhere.
101
+ */
102
+ export function registerScratchTool(
103
+ pi: ExtensionAPI,
104
+ store: () => ScratchStore | undefined,
105
+ ): void {
106
+ pi.registerTool<typeof SCRATCH_PARAMS, ScratchDetails>({
107
+ name: ASK_SCRATCH_TOOL,
108
+ label: "Scratch note",
109
+ description: SCRATCH_DESCRIPTION,
110
+ promptSnippet:
111
+ "Write throwaway planning notes to a temp scratch directory (ask mode only)",
112
+ promptGuidelines: SCRATCH_GUIDELINES,
113
+ parameters: SCRATCH_PARAMS,
114
+ defaultActive: false,
115
+ async execute(_toolCallId, params) {
116
+ const scratch = store();
117
+ if (!scratch) {
118
+ return {
119
+ content: [
120
+ {
121
+ type: "text" as const,
122
+ text: "The scratch directory is not available in this session, so nothing can be written. Answer with the information you can gather from reading.",
123
+ },
124
+ ],
125
+ details: { op: params.op },
126
+ isError: true,
127
+ };
128
+ }
129
+
130
+ const name = params.name?.trim();
131
+ try {
132
+ switch (params.op) {
133
+ case "list": {
134
+ const files = await scratch.list();
135
+ const text =
136
+ files.length === 0
137
+ ? "The scratch directory is empty."
138
+ : [
139
+ `Scratch notes (${files.length}):`,
140
+ ...files.map((file) => `- ${file.name} — ${formatBytes(file.bytes)}`),
141
+ ].join("\n");
142
+ return { content: [{ type: "text" as const, text }], details: { op: params.op } };
143
+ }
144
+ case "read": {
145
+ if (name === undefined) throw new Error('op "read" needs a note name.');
146
+ const text = await scratch.read(name);
147
+ return {
148
+ content: [{ type: "text" as const, text }],
149
+ details: { op: params.op, name, bytes: Buffer.byteLength(text, "utf8") },
150
+ };
151
+ }
152
+ case "delete": {
153
+ if (name === undefined) throw new Error('op "delete" needs a note name.');
154
+ await scratch.remove(name);
155
+ return {
156
+ content: [
157
+ { type: "text" as const, text: describe("delete", name, 0, 0) },
158
+ ],
159
+ details: { op: params.op, name },
160
+ };
161
+ }
162
+ case "write":
163
+ case "append": {
164
+ if (name === undefined) throw new Error(`op "${params.op}" needs a note name.`);
165
+ const content = params.content ?? "";
166
+ const result =
167
+ params.op === "write"
168
+ ? await scratch.write(name, content)
169
+ : await scratch.append(name, content);
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text" as const,
174
+ text: describe(params.op, result.name, result.bytes, result.lines),
175
+ },
176
+ ],
177
+ details: { op: params.op, name, bytes: result.bytes },
178
+ };
179
+ }
180
+ default: {
181
+ const unknown: never = params.op;
182
+ throw new Error(`Unknown scratchpad op: ${String(unknown)}`);
183
+ }
184
+ }
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ return {
188
+ content: [{ type: "text" as const, text: `scratchpad: ${message}` }],
189
+ details: { op: params.op, name },
190
+ isError: true,
191
+ };
192
+ }
193
+ },
194
+ });
195
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for the ask scratch store: name validation, containment, size caps,
3
+ * symlink refusal, listing, and the cleanup/availability contract.
4
+ */
5
+ import {
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ symlinkSync,
9
+ utimesSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { readdirSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { describe, expect, it } from "vitest";
16
+ import {
17
+ createScratchStore,
18
+ nameError,
19
+ SCRATCH_MAX_BYTES,
20
+ sweepScratchDirs,
21
+ } from "./scratch.js";
22
+
23
+ function tempBase(): string {
24
+ return mkdtempSync(join(tmpdir(), "pi-ask-scratch-"));
25
+ }
26
+
27
+ function store(base: string, sessionId = "session-1") {
28
+ return createScratchStore({ baseDir: base, sessionId });
29
+ }
30
+
31
+ describe("nameError", () => {
32
+ it.each(["plan.md", "notes/api.md", "a", "A_1-2.txt", "topic.v2.json"])(
33
+ "accepts %s",
34
+ (name) => {
35
+ expect(nameError(name)).toBeUndefined();
36
+ },
37
+ );
38
+
39
+ it.each([
40
+ [""],
41
+ [" "],
42
+ ["/etc/passwd"],
43
+ ["../x"],
44
+ ["notes/../../x"],
45
+ ["notes//api.md"],
46
+ ["C:\\x"],
47
+ ["a\\b"],
48
+ ["a/b/c"],
49
+ [".hidden"],
50
+ ["notes/"],
51
+ ["with space"],
52
+ ["semi;colon"],
53
+ ])("rejects %s", (name) => {
54
+ expect(nameError(name)).toBeDefined();
55
+ });
56
+ });
57
+
58
+ describe("createScratchStore", () => {
59
+ it("keeps sessions in separate directories under one base", async () => {
60
+ const base = tempBase();
61
+ const a = await store(base, "a");
62
+ const b = await store(base, "b");
63
+ expect(a.root).not.toBe(b.root);
64
+ await a.write("plan.md", "one");
65
+ expect(await b.list()).toEqual([]);
66
+ });
67
+
68
+ it("hashes a session id that is not directory-safe", async () => {
69
+ const base = tempBase();
70
+ const s = await store(base, "../../etc");
71
+ expect(s.root.startsWith(base)).toBe(true);
72
+ expect(s.root).not.toContain("..");
73
+ });
74
+
75
+ it("writes, reads, appends and reports sizes", async () => {
76
+ const s = await store(tempBase());
77
+ const written = await s.write("plan.md", "one\ntwo\n");
78
+ expect(written.bytes).toBe(8);
79
+ expect(written.lines).toBe(2);
80
+ expect(await s.read("plan.md")).toBe("one\ntwo\n");
81
+
82
+ await s.append("plan.md", "three");
83
+ expect(await s.read("plan.md")).toBe("one\ntwo\nthree");
84
+ });
85
+
86
+ it("nests one level", async () => {
87
+ const s = await store(tempBase());
88
+ await s.write("notes/api.md", "x");
89
+ expect(await s.list()).toEqual([{ name: "notes/api.md", bytes: 1 }]);
90
+ });
91
+
92
+ it("lists notes sorted, skipping directories and dotfiles", async () => {
93
+ const s = await store(tempBase());
94
+ await s.write("b.md", "22");
95
+ await s.write("a.md", "1");
96
+ await s.write("notes/c.md", "333");
97
+ writeFileSync(join(s.root, ".hidden"), "x");
98
+ expect((await s.list()).map((file) => file.name)).toEqual([
99
+ "a.md",
100
+ "b.md",
101
+ "notes/c.md",
102
+ ]);
103
+ });
104
+
105
+ it("deletes a note, and deleting a missing note is not an error", async () => {
106
+ const s = await store(tempBase());
107
+ await s.write("plan.md", "x");
108
+ await s.remove("plan.md");
109
+ expect(await s.list()).toEqual([]);
110
+ await expect(s.remove("plan.md")).resolves.toBeUndefined();
111
+ });
112
+
113
+ it("explains a missing note instead of throwing an fs error", async () => {
114
+ const s = await store(tempBase());
115
+ await expect(s.read("nope.md")).rejects.toThrow(
116
+ 'There is no scratch note named "nope.md"',
117
+ );
118
+ });
119
+
120
+ it("caps a single note at SCRATCH_MAX_BYTES", async () => {
121
+ const s = await store(tempBase());
122
+ const tooBig = "x".repeat(SCRATCH_MAX_BYTES + 1);
123
+ await expect(s.write("big.md", tooBig)).rejects.toThrow("capped at");
124
+ await s.write("ok.md", "x".repeat(10));
125
+ await expect(
126
+ s.append("ok.md", "x".repeat(SCRATCH_MAX_BYTES)),
127
+ ).rejects.toThrow("capped at");
128
+ });
129
+
130
+ it("refuses to write through a symlink that already sits in the root", async () => {
131
+ const base = tempBase();
132
+ const s = await store(base);
133
+ const outside = mkdtempSync(join(tmpdir(), "pi-ask-outside-"));
134
+ try {
135
+ symlinkSync(outside, join(s.root, "link"), "dir");
136
+ } catch {
137
+ // Unprivileged Windows cannot create symlinks; nothing to assert there.
138
+ return;
139
+ }
140
+ await expect(s.write("link/x.md", "escape")).rejects.toThrow(
141
+ "symbolic link",
142
+ );
143
+ });
144
+
145
+ it("refuses when the base directory is not a directory", async () => {
146
+ const base = tempBase();
147
+ const file = join(base, "not-a-dir");
148
+ writeFileSync(file, "x");
149
+ expect(() => createScratchStore({ baseDir: file, sessionId: "s" })).toThrow();
150
+ });
151
+ });
152
+
153
+ describe("sweepScratchDirs", () => {
154
+ it("removes directories older than the cap and keeps fresh ones", async () => {
155
+ const base = tempBase();
156
+ const oldDir = join(base, "old");
157
+ const freshDir = join(base, "fresh");
158
+ mkdirSync(oldDir);
159
+ mkdirSync(freshDir);
160
+ const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
161
+ utimesSync(oldDir, old, old);
162
+
163
+ const removed = sweepScratchDirs(base, 7 * 24 * 60 * 60 * 1000);
164
+
165
+ expect(removed).toEqual(["old"]);
166
+ expect(readdirSync(base)).toEqual(["fresh"]);
167
+ });
168
+
169
+ it("is silent when the base does not exist", async () => {
170
+ expect(sweepScratchDirs(join(tempBase(), "missing"), 1)).toEqual([]);
171
+ });
172
+ });