@andares/pi-toolkits 0.9.6 → 0.9.8
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/README.md +22 -16
- package/README.zh-CN.md +20 -14
- package/package.json +8 -6
- package/src/features/ask/constants.ts +12 -0
- package/src/features/ask/index.test.ts +158 -15
- package/src/features/ask/index.ts +151 -27
- package/src/features/ask/prompt.test.ts +34 -22
- package/src/features/ask/prompt.ts +26 -39
- package/src/features/ask/reasons.test.ts +42 -3
- package/src/features/ask/reasons.ts +69 -24
- package/src/features/ask/scratch-tool.test.ts +161 -0
- package/src/features/ask/scratch-tool.ts +195 -0
- package/src/features/ask/scratch.test.ts +172 -0
- package/src/features/ask/scratch.ts +324 -0
- package/src/features/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +33 -10
- package/src/features/compact/index.ts +27 -34
- package/src/features/compact/prompt.ts +23 -12
- package/src/features/favorites/constants.ts +6 -5
- package/src/features/favorites/patch-cycle.ts +4 -3
- package/src/features/favorites/patch-selector.ts +2 -2
- package/src/features/permission/index.test.ts +27 -3
- package/src/features/permission/index.ts +6 -0
- package/src/features/stash/stash.ts +1 -1
- package/src/lib/gates.ts +4 -0
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +1 -1
- package/src/lib/tools.ts +48 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask feature — scratch directory for planning notes.
|
|
3
|
+
*
|
|
4
|
+
* Ask mode used to confiscate every write tool, including writes to a temp
|
|
5
|
+
* file. That is stricter than the promise it makes ("do not change the
|
|
6
|
+
* project"): a model that wants to hold an outline, a checklist or a draft
|
|
7
|
+
* analysis had to either dump it into the reply or work around the sandbox. So
|
|
8
|
+
* the mode now hands out exactly one place to write — a per-session directory
|
|
9
|
+
* under the OS temp dir, outside the project:
|
|
10
|
+
*
|
|
11
|
+
* - the root is `<os.tmpdir()>/pi-ask/<session>`, so nothing here can touch the
|
|
12
|
+
* repository and no path arrives from the model (names are relative);
|
|
13
|
+
* - names are validated against a narrow allowlist (letters, digits, dot,
|
|
14
|
+
* dash, underscore; at most two levels; no `..`, no absolute path, no
|
|
15
|
+
* backslash), and the joined path is re-checked for containment as defence
|
|
16
|
+
* in depth;
|
|
17
|
+
* - existing symlinked components are refused, so a pre-existing link in the
|
|
18
|
+
* temp dir cannot turn a note into a write somewhere else;
|
|
19
|
+
* - files are capped at {@link SCRATCH_MAX_BYTES}: "notes" are not a dump of
|
|
20
|
+
* the repository, and the cap also keeps a tool result from flooding the
|
|
21
|
+
* context when the model reads its own note back;
|
|
22
|
+
* - `createScratchStore` throws when the temp dir cannot be created or written.
|
|
23
|
+
* The caller then degrades to the old fully read-only mode instead of
|
|
24
|
+
* running half-open.
|
|
25
|
+
*
|
|
26
|
+
* Nothing written here is ever executed: ask mode's bash sandbox stays
|
|
27
|
+
* read-only, so a script dropped into the scratch directory is just text.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
// Creation and sweeping are synchronous on purpose: ask mode can be entered from
|
|
31
|
+
// `session_start` (the autostart path), and the first run starts before an async
|
|
32
|
+
// directory creation would resolve. Those are a handful of syscalls; the actual
|
|
33
|
+
// note operations stay async because they run inside tool calls.
|
|
34
|
+
import {
|
|
35
|
+
mkdirSync,
|
|
36
|
+
realpathSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
statSync,
|
|
40
|
+
unlinkSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import {
|
|
44
|
+
appendFile,
|
|
45
|
+
lstat,
|
|
46
|
+
mkdir,
|
|
47
|
+
readFile,
|
|
48
|
+
readdir,
|
|
49
|
+
stat,
|
|
50
|
+
unlink,
|
|
51
|
+
writeFile,
|
|
52
|
+
} from "node:fs/promises";
|
|
53
|
+
import { tmpdir } from "node:os";
|
|
54
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
55
|
+
|
|
56
|
+
/** Directory under the OS temp dir that holds every session's scratch space. */
|
|
57
|
+
export const SCRATCH_DIR_NAME = "pi-ask";
|
|
58
|
+
/** Largest single note. Notes are planning aids, not a copy of the repository. */
|
|
59
|
+
export const SCRATCH_MAX_BYTES = 256 * 1024;
|
|
60
|
+
/** Scratch directories older than this are removed when a session enters ask mode. */
|
|
61
|
+
export const SCRATCH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
/** A note name segment: no leading dot, no separators, no shell metacharacters. */
|
|
64
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
65
|
+
/** A session id that is safe as a directory name (pi uses UUIDs; be defensive). */
|
|
66
|
+
const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
67
|
+
/** Deepest allowed note name, e.g. `notes/api.md`. */
|
|
68
|
+
const MAX_SEGMENTS = 2;
|
|
69
|
+
|
|
70
|
+
/** One note as reported by `list`. */
|
|
71
|
+
export interface ScratchFile {
|
|
72
|
+
readonly name: string;
|
|
73
|
+
readonly bytes: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Result of writing or appending one note. */
|
|
77
|
+
export interface ScratchWrite {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
readonly path: string;
|
|
80
|
+
readonly bytes: number;
|
|
81
|
+
readonly lines: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The scratch space of one session. Every operation takes a relative note name. */
|
|
85
|
+
export interface ScratchStore {
|
|
86
|
+
readonly root: string;
|
|
87
|
+
write(name: string, content: string): Promise<ScratchWrite>;
|
|
88
|
+
append(name: string, content: string): Promise<ScratchWrite>;
|
|
89
|
+
read(name: string): Promise<string>;
|
|
90
|
+
list(): Promise<ScratchFile[]>;
|
|
91
|
+
remove(name: string): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Why `name` is not a valid note name, or undefined when it is. */
|
|
95
|
+
export function nameError(name: string): string | undefined {
|
|
96
|
+
const trimmed = name.trim();
|
|
97
|
+
if (trimmed === "") {
|
|
98
|
+
return 'The note name is empty. Use a relative name such as "plan.md".';
|
|
99
|
+
}
|
|
100
|
+
if (isAbsolute(name) || /^[A-Za-z]:/.test(name) || name.includes("\\")) {
|
|
101
|
+
return `"${name}" is not a relative name. Scratch names are relative to the scratch directory (for example "plan.md"); absolute paths and backslashes are rejected.`;
|
|
102
|
+
}
|
|
103
|
+
const segments = name.split("/");
|
|
104
|
+
if (segments.length > MAX_SEGMENTS) {
|
|
105
|
+
return `"${name}" is nested too deeply. At most ${MAX_SEGMENTS} levels are allowed ("notes/api.md").`;
|
|
106
|
+
}
|
|
107
|
+
const invalid = segments.find((segment) => !SEGMENT.test(segment));
|
|
108
|
+
if (invalid !== undefined) {
|
|
109
|
+
return `"${name}" has an invalid segment "${invalid}". A segment is 1-64 characters of letters, digits, dot, dash or underscore and may not start with a dot.`;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Case-folded containment check (Windows paths are case-insensitive). */
|
|
115
|
+
function inside(root: string, target: string): boolean {
|
|
116
|
+
const fold = (value: string): string =>
|
|
117
|
+
process.platform === "win32" ? value.toLowerCase() : value;
|
|
118
|
+
const base = fold(root);
|
|
119
|
+
const candidate = fold(target);
|
|
120
|
+
return (
|
|
121
|
+
candidate === base ||
|
|
122
|
+
candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Absolute path of a validated note name, re-checked for containment. */
|
|
127
|
+
function targetFor(root: string, name: string): string {
|
|
128
|
+
const problem = nameError(name);
|
|
129
|
+
if (problem !== undefined) throw new Error(problem);
|
|
130
|
+
const target = resolve(root, name);
|
|
131
|
+
if (!inside(root, target)) {
|
|
132
|
+
throw new Error(`"${name}" resolves outside the scratch directory.`);
|
|
133
|
+
}
|
|
134
|
+
return target;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Refuse a target whose existing components go through a symlink.
|
|
139
|
+
*
|
|
140
|
+
* The store never creates links itself, so this only matters when something
|
|
141
|
+
* else (the operator, another process) put one in the temp directory first.
|
|
142
|
+
* Missing components are fine: they are about to be created inside the root.
|
|
143
|
+
*/
|
|
144
|
+
async function assertNoSymlink(root: string, target: string): Promise<void> {
|
|
145
|
+
const parts = target.slice(root.length).split(sep).filter(Boolean);
|
|
146
|
+
let current = root;
|
|
147
|
+
for (const part of parts) {
|
|
148
|
+
current = join(current, part);
|
|
149
|
+
try {
|
|
150
|
+
const info = await lstat(current);
|
|
151
|
+
if (info.isSymbolicLink()) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${part} in the scratch directory is a symbolic link; scratch notes must be regular files.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function tooBig(bytes: number): string {
|
|
164
|
+
const kib = Math.ceil(SCRATCH_MAX_BYTES / 1024);
|
|
165
|
+
return `Scratch notes are capped at ${kib} KiB; this note would be ${Math.ceil(bytes / 1024)} KiB. Keep it to the planning essentials — the reply, not the note, is the deliverable.`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function lineCount(text: string): number {
|
|
169
|
+
if (text === "") return 0;
|
|
170
|
+
const breaks = text.split("\n").length;
|
|
171
|
+
return text.endsWith("\n") ? breaks - 1 : breaks;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function sizeOf(path: string): Promise<number | undefined> {
|
|
175
|
+
try {
|
|
176
|
+
const info = await stat(path);
|
|
177
|
+
return info.size;
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Create a {@link ScratchStore} rooted in `options.baseDir` (default: OS temp). */
|
|
184
|
+
export function createScratchStore(options: {
|
|
185
|
+
readonly sessionId: string;
|
|
186
|
+
readonly baseDir?: string;
|
|
187
|
+
readonly now?: number;
|
|
188
|
+
}): ScratchStore {
|
|
189
|
+
const base = join(resolve(options.baseDir ?? tmpdir()), SCRATCH_DIR_NAME);
|
|
190
|
+
const leaf = SESSION.test(options.sessionId)
|
|
191
|
+
? options.sessionId
|
|
192
|
+
: createHash("sha256").update(options.sessionId).digest("hex").slice(0, 16);
|
|
193
|
+
const created = join(base, leaf);
|
|
194
|
+
mkdirSync(created, { recursive: true });
|
|
195
|
+
// macOS resolves /var (and therefore the default tmpdir) through a symlink.
|
|
196
|
+
const root = realpathSync(created);
|
|
197
|
+
// Prove the directory is writable: on a read-only TMPDIR `mkdir -p` on an
|
|
198
|
+
// existing directory succeeds without proving anything. Ask mode degrades
|
|
199
|
+
// when this throws.
|
|
200
|
+
const probe = join(root, `.probe-${process.pid}-${Date.now()}`);
|
|
201
|
+
writeFileSync(probe, "");
|
|
202
|
+
unlinkSync(probe);
|
|
203
|
+
sweepScratchDirs(base, SCRATCH_MAX_AGE_MS, options.now);
|
|
204
|
+
|
|
205
|
+
const store: ScratchStore = {
|
|
206
|
+
root,
|
|
207
|
+
|
|
208
|
+
async write(name, content) {
|
|
209
|
+
const target = targetFor(root, name);
|
|
210
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
211
|
+
if (bytes > SCRATCH_MAX_BYTES) throw new Error(tooBig(bytes));
|
|
212
|
+
await assertNoSymlink(root, target);
|
|
213
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
214
|
+
await writeFile(target, content, "utf8");
|
|
215
|
+
return { name, path: target, bytes, lines: lineCount(content) };
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async append(name, content) {
|
|
219
|
+
const target = targetFor(root, name);
|
|
220
|
+
const existing = (await sizeOf(target)) ?? 0;
|
|
221
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
222
|
+
if (existing + bytes > SCRATCH_MAX_BYTES) {
|
|
223
|
+
throw new Error(tooBig(existing + bytes));
|
|
224
|
+
}
|
|
225
|
+
await assertNoSymlink(root, target);
|
|
226
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
227
|
+
await appendFile(target, content, "utf8");
|
|
228
|
+
const total = existing + bytes;
|
|
229
|
+
return {
|
|
230
|
+
name,
|
|
231
|
+
path: target,
|
|
232
|
+
bytes: total,
|
|
233
|
+
// The appended text is what the caller just wrote; the file's own
|
|
234
|
+
// line count is not needed by the model.
|
|
235
|
+
lines: lineCount(content),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async read(name) {
|
|
240
|
+
const target = targetFor(root, name);
|
|
241
|
+
await assertNoSymlink(root, target);
|
|
242
|
+
let data: Buffer;
|
|
243
|
+
try {
|
|
244
|
+
data = await readFile(target);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`There is no scratch note named "${name}". Use op "list" to see the notes that exist.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (data.length > SCRATCH_MAX_BYTES) {
|
|
254
|
+
return `${data.subarray(0, SCRATCH_MAX_BYTES).toString("utf8")}\n…(truncated at ${Math.floor(SCRATCH_MAX_BYTES / 1024)} KiB)`;
|
|
255
|
+
}
|
|
256
|
+
return data.toString("utf8");
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async list() {
|
|
260
|
+
const files: ScratchFile[] = [];
|
|
261
|
+
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
262
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (entry.name.startsWith(".")) continue;
|
|
265
|
+
const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
266
|
+
if (entry.isDirectory()) {
|
|
267
|
+
// One level only: names cannot nest deeper than MAX_SEGMENTS.
|
|
268
|
+
if (prefix === "") await walk(join(dir, entry.name), name);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// Symlinks and other non-files are deliberately not listed.
|
|
272
|
+
if (!entry.isFile()) continue;
|
|
273
|
+
const size = await sizeOf(join(dir, entry.name));
|
|
274
|
+
files.push({ name, bytes: size ?? 0 });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
await walk(root, "");
|
|
278
|
+
return files.sort((a, b) => a.name.localeCompare(b.name));
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async remove(name) {
|
|
282
|
+
const target = targetFor(root, name);
|
|
283
|
+
try {
|
|
284
|
+
await unlink(target);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// Deleting a note that is already gone is not an error: the model
|
|
287
|
+
// asked for the note to be gone and it is.
|
|
288
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
return store;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Remove scratch directories older than `maxAgeMs`. Never throws: cleanup is
|
|
297
|
+
* housekeeping, and a stale directory must not keep ask mode from starting.
|
|
298
|
+
*/
|
|
299
|
+
export function sweepScratchDirs(
|
|
300
|
+
base: string,
|
|
301
|
+
maxAgeMs: number,
|
|
302
|
+
now = Date.now(),
|
|
303
|
+
): string[] {
|
|
304
|
+
const removed: string[] = [];
|
|
305
|
+
let entries;
|
|
306
|
+
try {
|
|
307
|
+
entries = readdirSync(base, { withFileTypes: true });
|
|
308
|
+
} catch {
|
|
309
|
+
return removed;
|
|
310
|
+
}
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!entry.isDirectory()) continue;
|
|
313
|
+
const dir = join(base, entry.name);
|
|
314
|
+
try {
|
|
315
|
+
const info = statSync(dir);
|
|
316
|
+
if (now - info.mtimeMs <= maxAgeMs) continue;
|
|
317
|
+
rmSync(dir, { recursive: true, force: true });
|
|
318
|
+
removed.push(entry.name);
|
|
319
|
+
} catch {
|
|
320
|
+
// Best effort; a directory in use elsewhere stays.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return removed;
|
|
324
|
+
}
|
|
@@ -48,7 +48,7 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
|
|
|
48
48
|
* `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
|
|
49
49
|
* existing session *before* the first runtime exists, and that runtime is
|
|
50
50
|
* created without an explicit session_start event, so extensions see pi's
|
|
51
|
-
* default reason — `startup` — not `resume` (verified on pi 1.
|
|
51
|
+
* default reason — `startup` — not `resume` (verified on pi 1.1.0–1.4.0:
|
|
52
52
|
* `main.js` builds the initial session manager from those flags and calls
|
|
53
53
|
* `createAgentSessionRuntime` with no `sessionStartEvent`;
|
|
54
54
|
* `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
|
|
28
28
|
* instead of the model prompt path. The wrapper defaults it to false in this
|
|
29
29
|
* package (each byte-identical across every published version of the supported
|
|
30
|
-
* window, 1.
|
|
30
|
+
* window, 1.1.0 → 1.4.0; see AGENTS.md host-compat discipline for the version
|
|
31
31
|
* matrix).
|
|
32
32
|
*
|
|
33
33
|
* Only extension commands can run this way. Built-in commands (/compact,
|
|
@@ -418,27 +418,25 @@ describe("session_before_compact handler", () => {
|
|
|
418
418
|
.calls[0] as [
|
|
419
419
|
AvailableModel,
|
|
420
420
|
unknown,
|
|
421
|
-
{ maxTokens
|
|
421
|
+
{ maxTokens?: number; signal: AbortSignal },
|
|
422
422
|
];
|
|
423
423
|
expect(modelKey(model)).toBe("google/gemini-2.5-flash");
|
|
424
|
-
// 0
|
|
425
|
-
|
|
424
|
+
// pi 1.4.0 (#9512): no fixed output cap — the request omits maxTokens
|
|
425
|
+
// so the provider uses the model's own output limit.
|
|
426
|
+
expect(options).not.toHaveProperty("maxTokens");
|
|
426
427
|
expect(options.signal).toBe(event.signal);
|
|
427
428
|
});
|
|
428
429
|
|
|
429
|
-
it("
|
|
430
|
+
it("does not derive an output budget from reserveTokens", async () => {
|
|
430
431
|
getCompactStore().setModel("google/gemini-2.5-flash");
|
|
431
432
|
const { handler } = registeredHandler();
|
|
432
433
|
const bigCapModel = { ...MODELS[0], maxTokens: 65536 };
|
|
433
434
|
const { ctx, complete } = fakeCtx({ models: [bigCapModel] });
|
|
434
435
|
await handler(fakeEvent() as never, ctx);
|
|
435
436
|
const [, , options] = (complete as ReturnType<typeof vi.fn>).mock
|
|
436
|
-
.calls[0] as [
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
{ maxTokens: number },
|
|
440
|
-
];
|
|
441
|
-
expect(options.maxTokens).toBe(12800);
|
|
437
|
+
.calls[0] as [AvailableModel, unknown, { maxTokens?: number }];
|
|
438
|
+
// reserveTokens (16000) used to cap this at 12800; the cap is gone.
|
|
439
|
+
expect(options).not.toHaveProperty("maxTokens");
|
|
442
440
|
});
|
|
443
441
|
|
|
444
442
|
it("falls back with a warning on an empty summary", async () => {
|
|
@@ -568,6 +566,31 @@ describe("session_before_compact handler", () => {
|
|
|
568
566
|
expect(prompts[0]).toContain("Additional focus: 保留命令输出细节");
|
|
569
567
|
});
|
|
570
568
|
|
|
569
|
+
it("passes customInstructions to the split-turn prefix summary too", async () => {
|
|
570
|
+
getCompactStore().setModel("google/gemini-2.5-flash");
|
|
571
|
+
const { handler } = registeredHandler();
|
|
572
|
+
const prompts: string[] = [];
|
|
573
|
+
const { ctx } = fakeCtx({
|
|
574
|
+
complete: vi.fn(async (_model, context) => {
|
|
575
|
+
prompts.push(
|
|
576
|
+
(context.messages[0]?.content as Array<{ text?: string }>)[0]?.text ??
|
|
577
|
+
"",
|
|
578
|
+
);
|
|
579
|
+
return fakeResponse("## Goal\n- x");
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
const event = fakeEvent({
|
|
583
|
+
messagesToSummarize: [],
|
|
584
|
+
turnPrefixMessages: [userMessage("prefix msg")],
|
|
585
|
+
isSplitTurn: true,
|
|
586
|
+
});
|
|
587
|
+
Object.assign(event, { customInstructions: "保留命令输出细节" });
|
|
588
|
+
await handler(event as never, ctx);
|
|
589
|
+
expect(prompts).toHaveLength(1);
|
|
590
|
+
expect(prompts[0]).toContain("# Instructions");
|
|
591
|
+
expect(prompts[0]).toContain("Additional focus: 保留命令输出细节");
|
|
592
|
+
});
|
|
593
|
+
|
|
571
594
|
it("switches to the update prompt when a previous summary exists", async () => {
|
|
572
595
|
getCompactStore().setModel("google/gemini-2.5-flash");
|
|
573
596
|
const { handler } = registeredHandler();
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
* - takeover point: the `session_before_compact` extension event, which pi
|
|
10
10
|
* emits for ALL three trigger reasons (manual / threshold / overflow)
|
|
11
11
|
* before running its own compaction
|
|
12
|
-
* - prompts, message assembly, split-turn handling,
|
|
13
|
-
* summary file-list tail are verbatim pi built-in behavior (copied
|
|
14
|
-
* 0.87.1
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* - prompts, message assembly, split-turn handling, summary budgets, and
|
|
13
|
+
* the summary file-list tail are verbatim pi built-in behavior (copied
|
|
14
|
+
* from 0.87.1; `core/compaction/compaction.js` stayed byte-identical
|
|
15
|
+
* 1.0.0 → 1.3.0, and 1.4.0 changed only the summary output budget and
|
|
16
|
+
* the split-turn focus handling mirrored below) — only the executing
|
|
17
|
+
* model differs. The one exception is `serializeConversation`, which is
|
|
18
|
+
* imported from the host instead of mirrored, so it tracks whatever the
|
|
19
|
+
* running host does (1.2.0 stopped feeding assistant thinking into the
|
|
20
|
+
* request).
|
|
19
21
|
* - lossless fallback: unset config, unavailable model, empty/failed
|
|
20
22
|
* summary, or user abort → return undefined and pi's default compaction
|
|
21
23
|
* (session model) runs untouched. The extension can never make
|
|
@@ -75,7 +77,6 @@ async function completeSummary(
|
|
|
75
77
|
ctx: ExtensionContext,
|
|
76
78
|
model: AvailableModel,
|
|
77
79
|
promptText: string,
|
|
78
|
-
maxTokens: number,
|
|
79
80
|
signal: AbortSignal,
|
|
80
81
|
): Promise<{ text: string; usage: CompleteUsage }> {
|
|
81
82
|
const response = await ctx.modelRegistry.complete(
|
|
@@ -91,7 +92,10 @@ async function completeSummary(
|
|
|
91
92
|
],
|
|
92
93
|
},
|
|
93
94
|
{
|
|
94
|
-
maxTokens
|
|
95
|
+
// No `maxTokens`: since pi 1.4.0 (#9512) the built-in compaction asks
|
|
96
|
+
// for the model's own output limit instead of a fixed fraction of
|
|
97
|
+
// reserveTokens, so a long reasoning block can no longer eat the cap
|
|
98
|
+
// and fail the summary with "generation hit the token cap".
|
|
95
99
|
signal,
|
|
96
100
|
cacheRetention: "none",
|
|
97
101
|
sessionId: uuidv7(),
|
|
@@ -118,30 +122,26 @@ async function completeSummary(
|
|
|
118
122
|
return { text, usage: response.usage };
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
/**
|
|
122
|
-
* pi's output-budget formula (compaction.js, 0.87.1 → 1.3.0 unchanged): a
|
|
123
|
-
* fraction of reserveTokens clamped by the model's own output cap.
|
|
124
|
-
*/
|
|
125
|
-
function summarizationBudget(
|
|
126
|
-
fraction: number,
|
|
127
|
-
reserveTokens: number,
|
|
128
|
-
modelMaxTokens: number,
|
|
129
|
-
): number {
|
|
130
|
-
return Math.min(
|
|
131
|
-
Math.floor(fraction * reserveTokens),
|
|
132
|
-
modelMaxTokens > 0 ? modelMaxTokens : Number.POSITIVE_INFINITY,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
125
|
/**
|
|
137
126
|
* Run the full compaction summarization with the configured model,
|
|
138
|
-
* replicating pi's compact()
|
|
139
|
-
*
|
|
127
|
+
* replicating pi's compact() as of 1.4.0 (byte-identical to 0.87.1's shape
|
|
128
|
+
* apart from the two 1.4.0 fixes adopted for the whole window, see below):
|
|
140
129
|
* history summary (first-connection or iterative-update prompt, custom
|
|
141
130
|
* focus appended), plus a separate turn-prefix checkpoint when the cut point
|
|
142
131
|
* splits a turn, merged into one summary with the file-list tail. Returns
|
|
143
132
|
* undefined on any failure after notifying (pi default compaction then takes
|
|
144
133
|
* over).
|
|
134
|
+
*
|
|
135
|
+
* 1.4.0 changed two things this mirror covers, and we follow 1.4.0 for every
|
|
136
|
+
* host in the supported window (1.1.0 → 1.4.0) because both are bug fixes
|
|
137
|
+
* rather than behavior we could sensibly keep per version:
|
|
138
|
+
* - summaries no longer cap maxTokens at 0.8/0.5 × reserveTokens; the
|
|
139
|
+
* request omits maxTokens so the provider uses the model's output limit
|
|
140
|
+
* (#9512 — long reasoning made summaries fail with "generation hit the
|
|
141
|
+
* token cap"). `reserveTokens` now only decides when compaction starts.
|
|
142
|
+
* - `/compact <instructions>` also reaches the split-turn prefix summary
|
|
143
|
+
* (#11058; before 1.4.0 the focus was dropped when the split turn was the
|
|
144
|
+
* only new content).
|
|
145
145
|
*/
|
|
146
146
|
async function runCompactModelCompaction(
|
|
147
147
|
ctx: ExtensionContext,
|
|
@@ -157,7 +157,6 @@ async function runCompactModelCompaction(
|
|
|
157
157
|
firstKeptEntryId,
|
|
158
158
|
previousSummary,
|
|
159
159
|
fileOps,
|
|
160
|
-
settings,
|
|
161
160
|
} = preparation;
|
|
162
161
|
|
|
163
162
|
try {
|
|
@@ -177,11 +176,6 @@ async function runCompactModelCompaction(
|
|
|
177
176
|
previousSummary,
|
|
178
177
|
customInstructions,
|
|
179
178
|
),
|
|
180
|
-
summarizationBudget(
|
|
181
|
-
0.8,
|
|
182
|
-
settings.reserveTokens,
|
|
183
|
-
model.maxTokens,
|
|
184
|
-
),
|
|
185
179
|
signal,
|
|
186
180
|
);
|
|
187
181
|
historyText = history.text;
|
|
@@ -192,8 +186,8 @@ async function runCompactModelCompaction(
|
|
|
192
186
|
model,
|
|
193
187
|
buildTurnPrefixPromptText(
|
|
194
188
|
serializeConversation(convertToLlm(turnPrefixMessages)),
|
|
189
|
+
customInstructions,
|
|
195
190
|
),
|
|
196
|
-
summarizationBudget(0.5, settings.reserveTokens, model.maxTokens),
|
|
197
191
|
signal,
|
|
198
192
|
);
|
|
199
193
|
summaryText = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefix.text}`;
|
|
@@ -212,7 +206,6 @@ async function runCompactModelCompaction(
|
|
|
212
206
|
previousSummary,
|
|
213
207
|
customInstructions,
|
|
214
208
|
),
|
|
215
|
-
summarizationBudget(0.8, settings.reserveTokens, model.maxTokens),
|
|
216
209
|
signal,
|
|
217
210
|
);
|
|
218
211
|
summaryText = history.text;
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
* summaries produced by the configured compact model are formatted exactly
|
|
7
7
|
* like pi's own compaction output. Only the executing model differs.
|
|
8
8
|
* Verified byte-identical against every published version of the supported
|
|
9
|
-
* window (1.
|
|
10
|
-
*
|
|
11
|
-
* utils.js change (1.2.0 dropping assistant thinking from
|
|
9
|
+
* window (1.1.0 → 1.4.0): core/usage-totals.js is unchanged across that
|
|
10
|
+
* span, the only utils.js change (1.2.0 dropping assistant thinking from
|
|
12
11
|
* serializeConversation) does not touch the strings mirrored here — that
|
|
13
|
-
* serializer is imported from the host, not mirrored
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* serializer is imported from the host, not mirrored — and compaction.js
|
|
13
|
+
* changed once, in 1.4.0, in the output budget and the split-turn focus
|
|
14
|
+
* handling (both adopted here, see buildTurnPrefixPromptText). 0.87.0
|
|
15
|
+
* shipped a different split-turn prefix prompt; the text mirrored here is
|
|
16
|
+
* 0.87.1's fix, and the mirror is the only variant any supported host gets
|
|
17
|
+
* — intentional.
|
|
17
18
|
*
|
|
18
19
|
* Update these strings whenever pi changes its built-in prompts (diff
|
|
19
20
|
* dist/core/compaction/compaction.js between versions).
|
|
@@ -146,11 +147,21 @@ export function buildHistoryPromptText(
|
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
149
|
* Build the split-turn prefix prompt text (pi 0.87.1
|
|
149
|
-
* generateTurnPrefixSummary): `# Conversation` block
|
|
150
|
-
* `# Instructions
|
|
150
|
+
* generateTurnPrefixSummary, extended in 1.4.0): `# Conversation` block
|
|
151
|
+
* followed by `# Instructions`, with the `/compact <instructions>` focus
|
|
152
|
+
* appended as `Additional focus: ...`. 1.4.0 added that last line (#11058:
|
|
153
|
+
* the focus used to be dropped when the split turn was the only new
|
|
154
|
+
* content since the last compaction); we use the fixed form on every host.
|
|
151
155
|
*/
|
|
152
|
-
export function buildTurnPrefixPromptText(
|
|
153
|
-
|
|
156
|
+
export function buildTurnPrefixPromptText(
|
|
157
|
+
conversationText: string,
|
|
158
|
+
customInstructions?: string,
|
|
159
|
+
): string {
|
|
160
|
+
let promptText = `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
161
|
+
if (customInstructions) {
|
|
162
|
+
promptText = `${promptText}\n\nAdditional focus: ${customInstructions}`;
|
|
163
|
+
}
|
|
164
|
+
return promptText;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
/** File-operation sets extracted by pi's prepareCompaction. */
|
|
@@ -203,7 +214,7 @@ export function formatFileOperations(
|
|
|
203
214
|
/**
|
|
204
215
|
* Combine two provider usage records field-wise (pi 0.87.1 compaction.js
|
|
205
216
|
* combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
|
|
206
|
-
* unchanged through 1.
|
|
217
|
+
* unchanged through 1.4.0): used when
|
|
207
218
|
* a split turn produces two summarization calls.
|
|
208
219
|
*/
|
|
209
220
|
export function combineUsage<T extends UsageLike>(first: T, second: T): T {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* bindings — only the cycle semantics change (patch-cycle.ts), so no
|
|
9
9
|
* keybinding conflicts arise.
|
|
10
10
|
*
|
|
11
|
-
* Verified against every published version of the supported
|
|
12
|
-
* (
|
|
11
|
+
* Verified against every published version of the supported range
|
|
12
|
+
* (0.99.0 → 1.4.0) keybinding
|
|
13
13
|
* tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
|
|
14
14
|
* `pnpm check:keybindings` after every host upgrade before trusting this.
|
|
15
15
|
*/
|
|
@@ -30,11 +30,12 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
|
|
|
30
30
|
* Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
|
|
31
31
|
* `TuiAltScreen.handleViewportInput()` checks it before
|
|
32
32
|
* `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
|
|
33
|
-
* search and the selector never sees it (verified in pi-tui 1.
|
|
33
|
+
* search and the selector never sees it (verified in pi-tui 1.4.0
|
|
34
34
|
* `dist/tui-alt-screen.js`: the search check is the first key branch and
|
|
35
|
-
* returns `{ consume: true }` unconditionally
|
|
35
|
+
* returns `{ consume: true }` unconditionally; the 1.4.0 changes to that
|
|
36
|
+
* file are exit/teardown only).
|
|
36
37
|
*
|
|
37
|
-
* Why ctrl+alt+f was discarded: clean at the pi layer across 1.
|
|
38
|
+
* Why ctrl+alt+f was discarded: clean at the pi layer across 1.1–1.4 (only
|
|
38
39
|
* ctrl+alt+] is taken from that family) and byte-distinguishable without
|
|
39
40
|
* kitty — but grabbed at the OS/app layer on the author's machine by a
|
|
40
41
|
* Windows screenshot tool, so it never reached the terminal. Lesson: a key
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* model available" feedback); otherwise jump to it
|
|
16
16
|
* - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
|
|
17
17
|
* (byte-identical across every published version in the supported window
|
|
18
|
-
* 1.
|
|
18
|
+
* 1.1.0 → 1.4.0 — `cycleModel` itself is untouched by 1.4.0's recovery and
|
|
19
|
+
* tool-restore rewrites:
|
|
19
20
|
* state swap, session persistence, default-model persistence only when
|
|
20
21
|
* `options.persist` is set, thinking-level re-clamp, model_select emit
|
|
21
22
|
* with source "cycle")
|
|
@@ -31,9 +32,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* pi's `Model<any>` — the exact type of `ModelCycleResult.model`
|
|
34
|
-
* (pi-coding-agent 1.
|
|
35
|
+
* (pi-coding-agent 1.1–1.4; the field is unchanged across that span).
|
|
35
36
|
* Derived here instead of importing `Model` from @earendil-works/pi-ai,
|
|
36
|
-
* whose index.d.ts (1.
|
|
37
|
+
* whose index.d.ts (1.1 through 1.4.0 alike) re-exports it via an
|
|
37
38
|
* extensioned `"./types.ts"` specifier that not every TS resolver substitutes
|
|
38
39
|
* to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
|
|
39
40
|
*/
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* upgraded internals), patches are skipped with a warning — the extension
|
|
22
22
|
* keeps loading and ctrl+p falls back to stock behavior. The prototype
|
|
23
23
|
* shape touched here (handleInput/updateList/filterModels and the instance
|
|
24
|
-
* fields below) is byte-identical across
|
|
25
|
-
* pi 1.
|
|
24
|
+
* fields below) is byte-identical across the mandatory window
|
|
25
|
+
* pi 1.1.0 → 1.4.0 (and exercised by the 0.99.0 → 1.0.4 backward probes).
|
|
26
26
|
*/
|
|
27
27
|
import {
|
|
28
28
|
ModelSelectorComponent,
|