@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.
@@ -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.0.0–1.3.0:
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.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
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: number; signal: AbortSignal },
421
+ { maxTokens?: number; signal: AbortSignal },
422
422
  ];
423
423
  expect(modelKey(model)).toBe("google/gemini-2.5-flash");
424
- // 0.8 × reserveTokens(16000) = 12800, under the model cap (8192).
425
- expect(options.maxTokens).toBe(8192);
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("clamps the budget by reserveTokens when the model cap is larger", async () => {
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
- AvailableModel,
438
- unknown,
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, output budgets, and the
13
- * summary file-list tail are verbatim pi built-in behavior (copied from
14
- * 0.87.1, byte-identical across every published version of the supported
15
- * window, 1.0.0 → 1.3.0) — only the executing model differs. The one
16
- * exception is `serializeConversation`, which is imported from the host
17
- * instead of mirrored, so it tracks whatever the running host does
18
- * (1.2.0 stopped feeding assistant thinking into the request).
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() (0.87.1, byte-identical across the supported
139
- * 1.0.0 → 1.3.0 window):
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.0.0 → 1.3.0): dist/core/compaction/compaction.js and
10
- * core/usage-totals.js are unchanged files across that span, and the only
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. 0.87.0 shipped a
14
- * different split-turn prefix prompt; the text mirrored here is 0.87.1's
15
- * fix, and the mirror is the only variant any supported host gets —
16
- * intentional.
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 followed by
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(conversationText: string): string {
153
- return `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
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.3.0): used when
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 window
12
- * (1.0.0 → 1.3.0) keybinding
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.3.0
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.0–1.3 (only
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.0.0 → 1.3.0:
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.0–1.3; the field is unchanged across that span).
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.0 through 1.3.0 alike) re-exports it via an
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 every published version in
25
- * pi 1.0.0 → 1.3.0 (and 0.99.0 → 1.2.0 before the floor moved).
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,