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 +38 -0
- package/README.md +8 -4
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.js +13 -10
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +17 -4
- package/dist/src/request-capacity.js +47 -9
- package/dist/src/tuck.js +16 -10
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +12 -9
- package/src/request-capacity.ts +42 -9
- package/src/tuck.ts +17 -10
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.
|
|
68
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
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
|
|
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.
|
|
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
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "480a4cbd70d99cf72adcc93f282c17434d1997e54d3f38fe2f26b3c642f50b2f";
|
package/dist/src/compaction.js
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 } 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
|
|
109
|
-
const
|
|
110
|
-
|
|
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 =>
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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 ===
|
|
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 ===
|
|
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 &&
|
|
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
|
-
|
|
262
|
-
|
|
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 ===
|
|
408
|
-
const anchored = new Set(protectedView.kept.map(
|
|
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
|
-
|
|
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"
|
|
416
|
-
}), ...protectedView.kept.filter(record => !expanded.has(record
|
|
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
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
-
|
|
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");
|
package/src/request-capacity.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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,
|
|
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 ===
|
|
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 ===
|
|
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 &&
|
|
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
|
-
|
|
271
|
-
|
|
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 ===
|
|
392
|
-
const anchored = new Set(protectedView.kept.map(
|
|
393
|
-
const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row =>
|
|
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
|
-
|
|
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"
|
|
398
|
-
}), ...protectedView.kept.filter(record => !expanded.has(record
|
|
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));
|