pi-tuck 1.0.25 → 1.0.27

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/CHANGELOG.md CHANGED
@@ -3,6 +3,44 @@
3
3
  All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.0.27] - 2026-11-10
7
+
8
+ ### Fixed
9
+
10
+ - **A run that a custom message started could lose that message after a cut in the middle of the run.** pi-tuck protected only a prompt the
11
+ user typed. A tool-loop cut in a run that an extension started with a custom message kept the message neither whole nor quoted, so later
12
+ requests lacked the instruction that started the run. Now such a message gets the same protection as a typed prompt: it is kept, or
13
+ quoted whole in the summary, and the guard checks it. Runs started by typed prompts are unchanged. A custom message with no text has
14
+ nothing to quote and stays unprotected, as before.
15
+
16
+ ### Added
17
+
18
+ - A seeded lifecycle sequence test drives real Pi through orders of prompts, handled prompts, custom-message runs, tree moves, new
19
+ sessions, compactions and aborts. It checks invariants read from outputs only. It reproduces 1.0.23's stale-cut bug and found this one.
20
+
21
+ ### Release gates
22
+
23
+ - **Shipped without a live gate, because its replay matches 1.0.26 exactly:** 13,946 requests, 0 different, 0 dead tasks, clips 17 → 17.
24
+ Strict stress: 0 findings. Tests (745) and CI. The packed tarball loaded in the real installed Pi.
25
+
26
+ ## [1.0.26] - 2026-11-10
27
+
28
+ ### Fixed
29
+
30
+ - **1.0.25 failed to load in an installed Pi** ("Cannot find module '@earendil-works/pi-ai/utils/estimate'"). Pi's extension loader resolves
31
+ only a few pi-ai entry points for an installed package, and 1.0.25 imported another one by name.
32
+ - **Since 1.0.23, the check of Pi's own reply cap never ran in an installed Pi**, for the same reason. It loaded only in tests and the eval
33
+ harness, where pi-ai resolves by name; the installed package silently fell back without it. The 4,096-token margin in the hard line held,
34
+ because it fell back to the same value.
35
+ - pi-tuck now loads Pi's reply clamp and estimator from the running Pi's own install. If it can't find them (a host embedding Pi), it
36
+ keeps the fallback as before.
37
+
38
+ ### Release gates
39
+
40
+ - **New gate:** the packed tarball was loaded in the real installed Pi before publishing. It loaded, and Pi's clamp resolved from Pi's own
41
+ install.
42
+ - Requests are unchanged from 1.0.25 wherever pi-ai resolves by name, which covers replay, stress and tests. Tests and CI pass.
43
+
6
44
  ## [1.0.25] - 2026-11-10
7
45
 
8
46
  ### Fixed
package/README.md CHANGED
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
64
64
 
65
65
  - Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
66
66
  - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
- - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.24`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.24`.
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.26`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.26`.
69
69
 
70
70
  ## Uninstall
71
71
 
@@ -102,7 +102,7 @@ pi-tuck's tools appear only after its first cut. Before that there is nothing ou
102
102
 
103
103
  | Command | Does |
104
104
  |---|---|
105
- | `/tuck` | Says in words what pi-tuck is doing in this session: the version this session loaded (an open session keeps it until Pi restarts), how many cuts pi-tuck made and the last one's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works here. It is for you; the model sees nothing new. |
105
+ | `/tuck` | Says in words what pi-tuck is doing in this session: the version this session loaded (an open session keeps it until Pi restarts), how many cuts and retries pi-tuck made and the last cut's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works here. It is for you; the model sees nothing new. |
106
106
  | `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
107
107
  | `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
108
108
 
@@ -131,6 +131,8 @@ Known limits today:
131
131
  - A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
132
132
  - Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
133
133
  - pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21; fixed in 1.0.22). It refused at the room, the window less the full reply reserve, where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal now sits at the window less a reply floor ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
134
+ - pi-tuck 1.0.25 failed to load in an installed Pi ("Cannot find module '@earendil-works/pi-ai/utils/estimate'"); fixed in 1.0.26, and 1.0.25 is deprecated. Since 1.0.23, the check of Pi's own reply cap also never ran in an installed Pi, for the same reason: Pi's extension loader resolves only a few pi-ai entry points for an installed package. It loaded only in tests and the eval harness, and the installed package fell back to the same 4,096-token margin without it. Fixed in 1.0.26, which was loaded in a real installed Pi before publishing ([`CHANGELOG.md`](CHANGELOG.md) 1.0.26).
135
+ - A request above the room could be admitted and then refused, leaving the session stuck where native Pi would have sent (1.0.22 to 1.0.24; fixed in 1.0.25). pi-tuck checked its line in its own corrected token count, while Pi's reply cap uses Pi's own estimate, which is higher when the provider counts fewer tokens than pi-tuck's raw measure ([`CHANGELOG.md`](CHANGELOG.md) 1.0.25).
134
136
  - Near the 1.0.22 refusal line, replies were cut to a few hundred tokens (1.0.22; fixed in 1.0.23). 1.0.22 left only its reply floor and did not count Pi's 4,096-token margin on the reply cap ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
135
137
  - Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
136
138
  - A prompt's cut could reach a run it was not planned for (fixed in 1.0.24). pi-tuck plans a prompt's cut when the prompt arrives. When another extension handled that prompt, no run started, and the plan waited for the next run. A run started by a custom message carried a cut sized for the prompt that never ran, deeper than needed. After a move in the session tree (`/tree`), the plan was applied on the new branch, where its kept entry was not present, so the request lost the custom message that started the run, even on a branch that needed no cut ([`CHANGELOG.md`](CHANGELOG.md) 1.0.24).
@@ -193,7 +195,9 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
193
195
 
194
196
  pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
195
197
 
196
- Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.23 pi-tuck reads that margin from Pi. A request above the room goes out only if Pi's own cap still leaves the reply floor. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
198
+ Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.26 pi-tuck loads Pi's own reply clamp and estimator from the running Pi's install (a host that embeds Pi falls back to the same 4,096-token margin). Since 1.0.25, each point that sends a request above the room checks Pi's own cap on the request it would send. When that leaves less than the reply floor, pi-tuck cuts deeper, with up to three re-plans and then the deepest cut that still carries the whole prompt, and refuses only if that leaves less than the floor too. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
199
+
200
+ Since 1.0.25, after a reply that stops for length with thinking only, pi-tuck retries the turn once, leaving the stopped reply out of the request. It uses the deepest cut that still carries the whole prompt when that gives the reply more room, and otherwise retries uncut. A retried reply that stops again ends the run. When a run ends on a reply cut at the output limit, pi-tuck says so ("the last reply stopped at the model's output limit and may be cut off"): in the interface, or on stderr when there is no UI. The notice never enters the model's context.
197
201
 
198
202
  pi-tuck estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. If a small window refuses, use a larger window, or pin one upstream as below.
199
203
 
@@ -1 +1 @@
1
- export declare const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export declare const BUILD_ID = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
@@ -1 +1 @@
1
- export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export const BUILD_ID = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
@@ -11,7 +11,6 @@ import { projectedMessages } from "./omissions.js";
11
11
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
13
13
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
14
- import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
15
14
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
16
15
  import { Type } from "typebox";
17
16
  import { Check } from "typebox/value";
@@ -105,9 +104,9 @@ export function seedWindow(branch, key) {
105
104
  return initialWindow(key);
106
105
  }
107
106
  export async function installCompaction(pi, options = {}) {
108
- const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
109
- const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
110
- .then(module => module.clampMaxTokensToContext).catch(() => undefined);
107
+ const piMath = await loadPiRequestMath();
108
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
109
+ const piClamp = piMath?.clampMaxTokensToContext;
111
110
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
112
111
  if (!validCutAt(cutAt))
113
112
  throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
@@ -225,8 +224,10 @@ export async function installCompaction(pi, options = {}) {
225
224
  // Pi's native start/settled events bracket the run and its post-turn cuts.
226
225
  let admittedRun = false;
227
226
  let runPrompt;
227
+ let runPromptMessageType = "user";
228
228
  let thinkingRetryPending = false;
229
- const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
229
+ const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => runPromptMessageType === "custom" ? entry.type === "custom_message" && textOf(entry.content) === runPrompt :
230
+ entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
230
231
  const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
231
232
  let estimatedTokens = 0;
232
233
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -367,7 +368,7 @@ export async function installCompaction(pi, options = {}) {
367
368
  const context = normalizeContext({ messages: convertToLlm(messages) });
368
369
  const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
369
370
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
370
- estimate: estimateContextTokens(context).tokens };
371
+ estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
371
372
  };
372
373
  const projectedRequest = (messages, ctx, host, incoming, prompt, tools, cut) => {
373
374
  const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
@@ -793,7 +794,9 @@ export async function installCompaction(pi, options = {}) {
793
794
  // input, so no run started for it): plan afresh.
794
795
  const prompted = phase === "decided" && event.messageType !== "custom";
795
796
  phase = "starting";
796
- runPrompt = event.messageType === "custom" ? undefined : event.prompt;
797
+ runPromptMessageType = event.messageType;
798
+ // Image-only and empty custom messages have no starting text to quote.
799
+ runPrompt = event.messageType === "custom" ? textOf(event.message.content) || undefined : event.prompt;
797
800
  try {
798
801
  syncHistoryTools(ctx);
799
802
  if (prompted)
@@ -1039,7 +1042,7 @@ export async function installCompaction(pi, options = {}) {
1039
1042
  if (decision.kind !== "cut")
1040
1043
  throw new CapacityError(clampMessage);
1041
1044
  const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
1042
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1045
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt, runPromptMessageType))
1043
1046
  throw promptRefusal();
1044
1047
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
1045
1048
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
@@ -1174,7 +1177,7 @@ export async function installCompaction(pi, options = {}) {
1174
1177
  observeBudget(tokens, ctx);
1175
1178
  estimatedTokens = corrected(tokens, ctx);
1176
1179
  const { room, hardLine } = limits(ctx);
1177
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1180
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt, runPromptMessageType))
1178
1181
  throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
1179
1182
  if (!fits(tokens, hardLine, ctx))
1180
1183
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
@@ -1,6 +1,6 @@
1
1
  import { type RecallPolicy } from './recall.js';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
- export declare const DIAGNOSTIC_BUILD = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
3
+ export declare const DIAGNOSTIC_BUILD = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -1,9 +1,21 @@
1
1
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage, Messages } from './budget.js';
3
+ type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
4
+ type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
5
+ export type PiRequestMath = {
6
+ clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext'];
7
+ estimateContextTokens: Estimate['estimateContextTokens'];
8
+ };
9
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
10
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
11
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
12
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
13
+ * keep their fallbacks. */
14
+ export declare function loadPiRequestMath(): Promise<PiRequestMath | undefined>;
3
15
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
16
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
17
  */
6
- export declare function piContextSafetyMargin(): Promise<number>;
18
+ export declare function piContextSafetyMargin(math?: PiRequestMath): Promise<number>;
7
19
  /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
8
20
  export declare const REPLY_FLOOR_MIN = 3584;
9
21
  /** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
@@ -14,5 +26,6 @@ export declare const needsFullReplyReserve: (message: AgentMessage) => boolean;
14
26
  export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
15
27
  /** Admission never promises more output than a model with a positive reply cap can produce. */
16
28
  export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
17
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
18
- export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
29
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
30
+ export declare function carriesPrompt(messages: Messages, prompt: string, role?: 'user' | 'custom'): boolean;
31
+ export {};
@@ -1,13 +1,51 @@
1
1
  import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
2
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
3
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
4
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
5
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
6
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
7
+ * keep their fallbacks. */
8
+ export async function loadPiRequestMath() {
9
+ try {
10
+ const [simple, estimate] = await Promise.all([import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate')]);
11
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
12
+ }
13
+ catch { /* not resolvable by name: an installed extension */ }
14
+ try {
15
+ // pi-ai's exports map these subpaths for `import` only (./api/* -> ./dist/api/*.js), so find the package beside Pi's executable and
16
+ // import the files that map names.
17
+ const [{ existsSync, realpathSync }, { dirname, join }, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')]);
18
+ const entry = process.argv[1];
19
+ if (!entry)
20
+ return undefined;
21
+ let dir = dirname(realpathSync(entry)), root;
22
+ for (let up = 0; up < 12 && !root; up++, dir = dirname(dir)) {
23
+ const candidate = join(dir, 'node_modules', '@earendil-works', 'pi-ai');
24
+ if (existsSync(join(candidate, 'dist', 'api', 'simple-options.js')))
25
+ root = candidate;
26
+ if (dirname(dir) === dir)
27
+ break;
28
+ }
29
+ if (!root)
30
+ return undefined;
31
+ const [simple, estimate] = await Promise.all([
32
+ import(pathToFileURL(join(root, 'dist', 'api', 'simple-options.js')).href),
33
+ import(pathToFileURL(join(root, 'dist', 'utils', 'estimate.js')).href)
34
+ ]);
35
+ if (typeof simple.clampMaxTokensToContext !== 'function' || typeof estimate.estimateContextTokens !== 'function')
36
+ return undefined;
37
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
3
43
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
44
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
45
  */
6
- export async function piContextSafetyMargin() {
46
+ export async function piContextSafetyMargin(math) {
7
47
  try {
8
- const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
9
- import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
10
- ]);
48
+ const { clampMaxTokensToContext, estimateContextTokens } = math ?? await loadPiRequestMath() ?? (() => { throw new Error('No Pi request math'); })();
11
49
  const window = 1_000_000, context = normalizeContext({ messages: [] });
12
50
  const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window }, context, window);
13
51
  if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window)
@@ -49,18 +87,18 @@ export function requestReplyFloor(branch, reserve, maxTokens) {
49
87
  const floor = branchReplyFloor(branch, reserve);
50
88
  return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
51
89
  }
52
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
53
- export function carriesPrompt(messages, prompt) {
90
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
91
+ export function carriesPrompt(messages, prompt, role = 'user') {
54
92
  if (!prompt.length)
55
93
  return true;
56
94
  return messages.some(message => {
57
- if (message.role === 'user') {
95
+ if (message.role === role) {
58
96
  const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
59
97
  return text.includes(prompt);
60
98
  }
61
99
  if (message.role !== 'compactionSummary')
62
100
  return false;
63
101
  const split = splitSummary(message.summary);
64
- return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
102
+ return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === role && record.text.includes(prompt));
65
103
  });
66
104
  }
package/dist/src/tuck.js CHANGED
@@ -116,7 +116,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
116
116
  const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
117
117
  const textOnly = isTextOnly(userContent);
118
118
  // Steering can become the latest user source while the run still owns its original prompt.
119
- const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
119
+ const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId &&
120
+ (entry.type === "custom_message" || entry.type === "message" && entry.message.role === "user"));
120
121
  const starting = entries[startingPosition];
121
122
  // Anchor the actual first source only; a native omission never promotes a later prompt.
122
123
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
@@ -256,10 +257,12 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
256
257
  if (cached)
257
258
  return cached;
258
259
  const index = indices.get(entry.id);
259
- const text = entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
260
+ const text = entry.type === "custom_message" ? textOf(entry.content) : entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
260
261
  const sourceTimes = index === undefined ? undefined : [{ sourceIndex: index, ...resolveSourceTime(entry) }];
261
- const records = index === undefined ? [] : take === Infinity
262
- ? [{ kind: "excerpt", role: "user", sourceIndex: index, text, sourceTimes }]
262
+ // Custom entries have a native identity but no global message index.
263
+ const records = index === undefined && entry.type !== "custom_message" ? [] : take === Infinity
264
+ ? [{ kind: "excerpt", role: entry.type === "custom_message" ? "custom" : "user", ...(index === undefined ? {} : { sourceIndex: index }), text, sourceTimes,
265
+ ...(entry.type === "custom_message" ? { sourceEntryId: entry.id } : {}) }]
263
266
  : userPrefixRecords({ kind: "user", text, originalText: text, startsSource: true, sourceIndex: index }, undefined, take).map(record => ({ ...record, sourceTimes }));
264
267
  cached = { records, cost: lineCost(records), whole: take >= text.length };
265
268
  quoteCache.set(key, cached);
@@ -399,21 +402,24 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
399
402
  if (row.message.role === "user" && Array.isArray(row.message.content))
400
403
  imageLabels.set(row.index, new Set(row.message.content.flatMap(part => part.type === "image" ? [`[image: ${part.mimeType}]`] : [])));
401
404
  const compiledBoundary = boundary;
405
+ // A protected custom prompt uses its native entry ID in the same anchoring/movement path as indexed user prompts.
406
+ const sourceKey = (record) => starting?.type === "custom_message" && record.sourceEntryId === starting.id ? starting.id : record.sourceIndex;
402
407
  const assemble = (compiled, at = compiledBoundary) => {
403
408
  const split = compiled ? splitSummary(compiled) : undefined;
404
409
  if (compiled && !split)
405
410
  throw new Error("pi-tuck returned an invalid summary");
406
411
  const inputRecords = split ? parseRecords(split.body, split.version) : protectedView.omissions;
407
- const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row => row.kind === "excerpt" && row.role === "user" && row.sourceIndex === record.sourceIndex && row.text.startsWith(record.text) && row.text.length >= record.text.length)).map(record => record.sourceIndex));
408
- const anchored = new Set(protectedView.kept.map(record => record.sourceIndex));
409
- const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => row.index));
412
+ const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row => row.kind === "excerpt" && row.role === record.role && sourceKey(row) === sourceKey(record) && row.text.startsWith(record.text) && row.text.length >= record.text.length)).map(sourceKey));
413
+ const anchored = new Set(protectedView.kept.map(sourceKey));
414
+ const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => starting?.type === "custom_message" && row.id === starting.id ? row.id : row.index));
410
415
  const records = [...inputRecords.filter(record => {
411
- if (record.sourceIndex !== undefined && moved.has(record.sourceIndex))
416
+ const key = sourceKey(record);
417
+ if (key !== undefined && moved.has(key))
412
418
  return false;
413
419
  if (record.kind === "excerpt" && record.role === "user" && imageLabels.get(record.sourceIndex)?.has(record.text))
414
420
  return true;
415
- return record.role !== "user" || record.sourceIndex === undefined || !anchored.has(record.sourceIndex) || expanded.has(record.sourceIndex);
416
- }), ...protectedView.kept.filter(record => !expanded.has(record.sourceIndex) && !moved.has(record.sourceIndex))];
421
+ return record.role !== "user" && record.role !== "custom" || key === undefined || !anchored.has(key) || expanded.has(key);
422
+ }), ...protectedView.kept.filter(record => !expanded.has(sourceKey(record)) && !moved.has(sourceKey(record)))];
417
423
  // Mixed user turns keep their text opening, while their image
418
424
  // metadata remains in the ordinary lower-ranked dialogue lane.
419
425
  const wholeUser = new Set(records.filter(record => record.kind === "excerpt" && record.role === "user" && record.text === userTexts.get(record.sourceIndex)).map(record => record.sourceIndex));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export const BUILD_ID = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
package/src/compaction.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
@@ -12,7 +12,6 @@ import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
13
13
  type AgentMessage, type Decision, type Messages, type Plan, type Target } from "./budget.js";
14
14
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
15
- import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
16
15
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
17
16
  import { Type } from "typebox";
18
17
  import { Check } from "typebox/value";
@@ -135,9 +134,9 @@ export interface CompactionOptions {
135
134
  }
136
135
 
137
136
  export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
138
- const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
139
- const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
140
- .then(module => module.clampMaxTokensToContext).catch(() => undefined);
137
+ const piMath = await loadPiRequestMath();
138
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
139
+ const piClamp = piMath?.clampMaxTokensToContext;
141
140
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
142
141
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
143
142
  const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
@@ -221,8 +220,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
221
220
  // Pi's native start/settled events bracket the run and its post-turn cuts.
222
221
  let admittedRun = false;
223
222
  let runPrompt: string | undefined;
223
+ let runPromptMessageType: "user" | "custom" = "user";
224
224
  let thinkingRetryPending = false;
225
225
  const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
226
+ runPromptMessageType === "custom" ? entry.type === "custom_message" && textOf(entry.content) === runPrompt :
226
227
  entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
227
228
  const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
228
229
  let estimatedTokens = 0;
@@ -350,7 +351,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
350
351
  const context = normalizeContext({ messages: convertToLlm(messages) });
351
352
  const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
352
353
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
353
- estimate: estimateContextTokens(context).tokens };
354
+ estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
354
355
  };
355
356
  const projectedRequest = (messages: Messages, ctx: ExtensionContext, host: Messages, incoming: Messages, prompt: string, tools: readonly string[], cut: boolean) => {
356
357
  const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
@@ -700,7 +701,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
700
701
  // input, so no run started for it): plan afresh.
701
702
  const prompted = phase === "decided" && event.messageType !== "custom";
702
703
  phase = "starting";
703
- runPrompt = event.messageType === "custom" ? undefined : event.prompt;
704
+ runPromptMessageType = event.messageType;
705
+ // Image-only and empty custom messages have no starting text to quote.
706
+ runPrompt = event.messageType === "custom" ? textOf(event.message.content) || undefined : event.prompt;
704
707
  try {
705
708
  syncHistoryTools(ctx);
706
709
  if (prompted) access(); // decided on input; a failure latched there still stops the run
@@ -916,7 +919,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
916
919
  }
917
920
  if (decision.kind !== "cut") throw new CapacityError(clampMessage);
918
921
  const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
919
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt)) throw promptRefusal();
922
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt, runPromptMessageType)) throw promptRefusal();
920
923
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
921
924
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
922
925
  const { response, room, hardLine } = limits(view);
@@ -1020,7 +1023,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
1020
1023
  observeBudget(tokens, ctx);
1021
1024
  estimatedTokens = corrected(tokens, ctx);
1022
1025
  const { room, hardLine } = limits(ctx);
1023
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1026
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt, runPromptMessageType))
1024
1027
  throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
1025
1028
  if (!fits(tokens, hardLine, ctx))
1026
1029
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
@@ -3,14 +3,47 @@ import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
3
  import type { AgentMessage, Messages } from './budget.js';
4
4
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
5
5
 
6
+ type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
7
+ type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
8
+ export type PiRequestMath = { clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext']; estimateContextTokens: Estimate['estimateContextTokens'] };
9
+
10
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
11
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
12
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
13
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
14
+ * keep their fallbacks. */
15
+ export async function loadPiRequestMath(): Promise<PiRequestMath | undefined> {
16
+ try {
17
+ const [simple, estimate] = await Promise.all([import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate')]);
18
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
19
+ } catch { /* not resolvable by name: an installed extension */ }
20
+ try {
21
+ // pi-ai's exports map these subpaths for `import` only (./api/* -> ./dist/api/*.js), so find the package beside Pi's executable and
22
+ // import the files that map names.
23
+ const [{ existsSync, realpathSync }, { dirname, join }, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')]);
24
+ const entry = process.argv[1];
25
+ if (!entry) return undefined;
26
+ let dir = dirname(realpathSync(entry)), root: string | undefined;
27
+ for (let up = 0; up < 12 && !root; up++, dir = dirname(dir)) {
28
+ const candidate = join(dir, 'node_modules', '@earendil-works', 'pi-ai');
29
+ if (existsSync(join(candidate, 'dist', 'api', 'simple-options.js'))) root = candidate;
30
+ if (dirname(dir) === dir) break;
31
+ }
32
+ if (!root) return undefined;
33
+ const [simple, estimate] = await Promise.all([
34
+ import(pathToFileURL(join(root, 'dist', 'api', 'simple-options.js')).href) as Promise<SimpleOptions>,
35
+ import(pathToFileURL(join(root, 'dist', 'utils', 'estimate.js')).href) as Promise<Estimate>]);
36
+ if (typeof simple.clampMaxTokensToContext !== 'function' || typeof estimate.estimateContextTokens !== 'function') return undefined;
37
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
38
+ } catch { return undefined; }
39
+ }
40
+
6
41
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
7
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
42
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
8
43
  */
9
- export async function piContextSafetyMargin(): Promise<number> {
44
+ export async function piContextSafetyMargin(math?: PiRequestMath): Promise<number> {
10
45
  try {
11
- const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
12
- import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
13
- ]);
46
+ const { clampMaxTokensToContext, estimateContextTokens } = math ?? await loadPiRequestMath() ?? (() => { throw new Error('No Pi request math'); })();
14
47
  const window = 1_000_000, context = normalizeContext({ messages: [] });
15
48
  const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window } as never, context, window);
16
49
  if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window) throw new Error('Invalid Pi context margin');
@@ -51,17 +84,17 @@ export function requestReplyFloor(branch: readonly SessionEntry[], reserve: numb
51
84
  return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
52
85
  }
53
86
 
54
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
55
- export function carriesPrompt(messages: Messages, prompt: string): boolean {
87
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
88
+ export function carriesPrompt(messages: Messages, prompt: string, role: 'user' | 'custom' = 'user'): boolean {
56
89
  if (!prompt.length) return true;
57
90
  return messages.some(message => {
58
- if (message.role === 'user') {
91
+ if (message.role === role) {
59
92
  const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
60
93
  return text.includes(prompt);
61
94
  }
62
95
  if (message.role !== 'compactionSummary') return false;
63
96
  const split = splitSummary(message.summary);
64
97
  return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record =>
65
- record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
98
+ record.kind === 'excerpt' && record.role === role && record.text.includes(prompt));
66
99
  });
67
100
  }
package/src/tuck.ts CHANGED
@@ -144,7 +144,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
144
144
  const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
145
145
  const textOnly = isTextOnly(userContent);
146
146
  // Steering can become the latest user source while the run still owns its original prompt.
147
- const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
147
+ const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId &&
148
+ (entry.type === "custom_message" || entry.type === "message" && entry.message.role === "user"));
148
149
  const starting = entries[startingPosition];
149
150
  // Anchor the actual first source only; a native omission never promotes a later prompt.
150
151
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
@@ -265,10 +266,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
265
266
  let cached = quoteCache.get(key);
266
267
  if (cached) return cached;
267
268
  const index = indices.get(entry.id);
268
- const text = entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
269
+ const text = entry.type === "custom_message" ? textOf(entry.content) : entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
269
270
  const sourceTimes = index === undefined ? undefined : [{ sourceIndex: index, ...resolveSourceTime(entry) }];
270
- const records: TranscriptRecord[] = index === undefined ? [] : take === Infinity
271
- ? [{ kind: "excerpt", role: "user", sourceIndex: index, text, sourceTimes }]
271
+ // Custom entries have a native identity but no global message index.
272
+ const records: TranscriptRecord[] = index === undefined && entry.type !== "custom_message" ? [] : take === Infinity
273
+ ? [{ kind: "excerpt", role: entry.type === "custom_message" ? "custom" : "user", ...(index === undefined ? {} : { sourceIndex: index }), text, sourceTimes,
274
+ ...(entry.type === "custom_message" ? { sourceEntryId: entry.id } : {}) }]
272
275
  : userPrefixRecords({ kind: "user", text, originalText: text, startsSource: true, sourceIndex: index }, undefined, take).map(record => ({ ...record, sourceTimes }));
273
276
  cached = { records, cost: lineCost(records), whole: take >= text.length };
274
277
  quoteCache.set(key, cached);
@@ -383,19 +386,23 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
383
386
  for (const row of sourceRows) if (row.message.role === "user" && Array.isArray(row.message.content))
384
387
  imageLabels.set(row.index, new Set(row.message.content.flatMap(part => part.type === "image" ? [`[image: ${part.mimeType}]`] : [])));
385
388
  const compiledBoundary = boundary;
389
+ // A protected custom prompt uses its native entry ID in the same anchoring/movement path as indexed user prompts.
390
+ const sourceKey = (record: TranscriptRecord) => starting?.type === "custom_message" && record.sourceEntryId === starting.id ? starting.id : record.sourceIndex;
386
391
  const assemble = (compiled: string, at = compiledBoundary) => {
387
392
  const split = compiled ? splitSummary(compiled) : undefined;
388
393
  if (compiled && !split) throw new Error("pi-tuck returned an invalid summary");
389
394
  const inputRecords = split ? parseRecords(split.body, split.version) : protectedView.omissions;
390
395
  const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row =>
391
- row.kind === "excerpt" && row.role === "user" && row.sourceIndex === record.sourceIndex && row.text.startsWith(record.text!) && row.text.length >= record.text!.length)).map(record => record.sourceIndex));
392
- const anchored = new Set(protectedView.kept.map(record => record.sourceIndex));
393
- const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => row.index));
396
+ row.kind === "excerpt" && row.role === record.role && sourceKey(row) === sourceKey(record) && row.text.startsWith(record.text!) && row.text.length >= record.text!.length)).map(sourceKey));
397
+ const anchored = new Set(protectedView.kept.map(sourceKey));
398
+ const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row =>
399
+ starting?.type === "custom_message" && row.id === starting.id ? row.id : row.index));
394
400
  const records = [...inputRecords.filter(record => {
395
- if (record.sourceIndex !== undefined && moved.has(record.sourceIndex)) return false;
401
+ const key = sourceKey(record);
402
+ if (key !== undefined && moved.has(key)) return false;
396
403
  if (record.kind === "excerpt" && record.role === "user" && imageLabels.get(record.sourceIndex)?.has(record.text)) return true;
397
- return record.role !== "user" || record.sourceIndex === undefined || !anchored.has(record.sourceIndex) || expanded.has(record.sourceIndex);
398
- }), ...protectedView.kept.filter(record => !expanded.has(record.sourceIndex) && !moved.has(record.sourceIndex))];
404
+ return record.role !== "user" && record.role !== "custom" || key === undefined || !anchored.has(key) || expanded.has(key);
405
+ }), ...protectedView.kept.filter(record => !expanded.has(sourceKey(record)) && !moved.has(sourceKey(record)))];
399
406
  // Mixed user turns keep their text opening, while their image
400
407
  // metadata remains in the ordinary lower-ranked dialogue lane.
401
408
  const wholeUser = new Set(records.filter(record => record.kind === "excerpt" && record.role === "user" && record.text === userTexts.get(record.sourceIndex)).map(record => record.sourceIndex));