pi-tuck 1.0.1 → 1.0.4
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 +65 -3
- package/README.md +138 -57
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -4
- package/dist/src/budget.d.ts +17 -4
- package/dist/src/budget.js +33 -10
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +7 -0
- package/dist/src/compaction.js +32 -16
- package/dist/src/diagnostics.d.ts +2 -1
- package/dist/src/omissions.d.ts +1 -1
- package/dist/src/omissions.js +8 -8
- package/dist/src/tuck/core/brief.d.ts +13 -43
- package/dist/src/tuck/core/brief.js +41 -255
- package/dist/src/tuck/core/filter-noise.js +2 -1
- package/dist/src/tuck/core/format.d.ts +2 -6
- package/dist/src/tuck/core/format.js +4 -10
- package/dist/src/tuck/core/normalize.js +22 -50
- package/dist/src/tuck/core/shrink.d.ts +33 -0
- package/dist/src/tuck/core/shrink.js +218 -0
- package/dist/src/tuck/core/summarize.d.ts +15 -7
- package/dist/src/tuck/core/summarize.js +17 -22
- package/dist/src/tuck/core/transcript.d.ts +24 -13
- package/dist/src/tuck/core/transcript.js +251 -83
- package/dist/src/tuck/types.d.ts +4 -21
- package/dist/src/tuck.d.ts +8 -10
- package/dist/src/tuck.js +23 -25
- package/index.ts +8 -5
- package/package.json +1 -1
- package/src/budget.ts +29 -9
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +33 -14
- package/src/diagnostics.ts +1 -1
- package/src/omissions.ts +8 -8
- package/src/tuck/core/brief.ts +48 -265
- package/src/tuck/core/filter-noise.ts +2 -1
- package/src/tuck/core/format.ts +4 -11
- package/src/tuck/core/normalize.ts +21 -50
- package/src/tuck/core/shrink.ts +217 -0
- package/src/tuck/core/summarize.ts +26 -24
- package/src/tuck/core/transcript.ts +238 -82
- package/src/tuck/types.ts +9 -20
- package/src/tuck.ts +30 -32
- package/dist/src/tuck/core/brief-meter.d.ts +0 -42
- package/dist/src/tuck/core/brief-meter.js +0 -273
- package/dist/src/tuck/core/rank.d.ts +0 -42
- package/dist/src/tuck/core/rank.js +0 -292
- package/src/tuck/core/brief-meter.ts +0 -235
- package/src/tuck/core/rank.ts +0 -331
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { installCompaction } from "./src/compaction";
|
|
5
|
+
import { validCutAt } from "./src/budget";
|
|
5
6
|
import { createSemanticRecall } from "./src/semantic-recall";
|
|
6
7
|
import { defaultRoot } from "./src/assets";
|
|
7
8
|
import { ensureSemanticRuntime } from "./src/setup";
|
|
@@ -14,13 +15,14 @@ export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/sem
|
|
|
14
15
|
export { ensureSemanticRuntime } from "./src/setup";
|
|
15
16
|
|
|
16
17
|
/** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
|
|
17
|
-
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts.
|
|
18
|
+
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
|
|
19
|
+
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 1 by default. */
|
|
18
20
|
export default async function (pi: ExtensionAPI) {
|
|
19
21
|
const semanticRecall = createSemanticRecall();
|
|
20
22
|
// `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
|
|
21
23
|
let off = false;
|
|
22
|
-
const handle = await installCompaction(pi, { semanticRecall, onSemanticSetting: async enabled => { off = !enabled; } });
|
|
23
24
|
const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
|
|
25
|
+
const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
|
|
24
26
|
if (settings.semanticRecall === false) return;
|
|
25
27
|
let session = 0, said = "", setup: Promise<unknown> | undefined;
|
|
26
28
|
// A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
|
|
@@ -55,14 +57,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
function readSettings(path: string): { semanticRecall?: boolean; error?: string } {
|
|
60
|
+
function readSettings(path: string): { semanticRecall?: boolean; cutAt?: number; error?: string } {
|
|
59
61
|
let text: string;
|
|
60
62
|
try { text = readFileSync(path, "utf8"); } catch { return {}; }
|
|
61
63
|
try {
|
|
62
64
|
const value = JSON.parse(text);
|
|
63
|
-
if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean")
|
|
65
|
+
if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean") &&
|
|
66
|
+
(value.cutAt === undefined || validCutAt(value.cutAt))) return value;
|
|
64
67
|
} catch { /* reported below */ }
|
|
65
|
-
return { error: `${path} should be a JSON object like {"semanticRecall": false}` };
|
|
68
|
+
return { error: `${path} should be a JSON object like {"semanticRecall": false, "cutAt": 0.8}, with cutAt a number above 0 and at most 1` };
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const reason = (error: unknown) => error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tuck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/budget.ts
CHANGED
|
@@ -16,8 +16,20 @@ export interface ToolShape { name: string; description: string; parameters: unkn
|
|
|
16
16
|
|
|
17
17
|
/** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
|
|
18
18
|
export const TRIGGER_MARGIN_FRACTION = 0.02;
|
|
19
|
-
/**
|
|
20
|
-
export const
|
|
19
|
+
/** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
|
|
20
|
+
export const validCutAt = (value: unknown): value is number => typeof value === "number" && value > 0 && value <= 1;
|
|
21
|
+
/**
|
|
22
|
+
* The one owner of the band: the limit a cut is triggered at and preferably planned to. It is the room less the margin, or the cut point
|
|
23
|
+
* `cutAt` (a fraction of the room) when that is lower. The exact room stays the limit of every throw, fault and the complete-context guard,
|
|
24
|
+
* whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
|
|
25
|
+
*/
|
|
26
|
+
/** The default cut point: a cut once a request passes 60% of the room. Measured against 1 with paired replays (no answer loss) and billed
|
|
27
|
+
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
28
|
+
export const DEFAULT_CUT_AT = 0.6;
|
|
29
|
+
export const bandOf = (room: number, cutAt = 1): number => {
|
|
30
|
+
const positive = Math.max(0, room);
|
|
31
|
+
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
|
|
32
|
+
};
|
|
21
33
|
|
|
22
34
|
/** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
|
|
23
35
|
const REPLAYING_APIS: ReadonlySet<string> = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
|
|
@@ -78,7 +90,7 @@ export const planningReserve = (window: number, targetRoom: number, factor: numb
|
|
|
78
90
|
window - Math.floor(targetRoom / factor) + mandatory;
|
|
79
91
|
|
|
80
92
|
export type Target = "band" | "room";
|
|
81
|
-
/** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or
|
|
93
|
+
/** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or shrunk). */
|
|
82
94
|
export type Plan<D extends object = {}> = ({ draft: true; after: number; mode: string } & D) | { draft: false; capacityRejected: boolean; reason: string };
|
|
83
95
|
export type Decision<D extends object = {}> =
|
|
84
96
|
/** The request fits below the band, or an opportunistic cut cannot help: no cut and no fault; the request goes to the guard. */
|
|
@@ -105,26 +117,34 @@ export interface CutInput<D extends object = {}> {
|
|
|
105
117
|
export type Verdict<D extends object = {}> = Exclude<Decision<D>, { kind: "defer" }>;
|
|
106
118
|
/**
|
|
107
119
|
* Rule 4. The exact room is planned first. When its plan already lands below the band it is taken as it is (target "room"): it keeps the
|
|
108
|
-
* room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is
|
|
120
|
+
* room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is smaller. Only
|
|
109
121
|
* otherwise is the band planned. Inside the band a cut is opportunistic, so a failed plan is never a fault and never falls back to the
|
|
110
122
|
* room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
|
|
111
123
|
* taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
|
|
112
124
|
* no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
|
|
113
125
|
* view), and a band plan stands when the room has none (a cut instead of a fault).
|
|
126
|
+
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
127
|
+
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
128
|
+
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
129
|
+
* and a cut is due. With the default cut point the band is never lean and the order and the decisions are those above.
|
|
114
130
|
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
115
131
|
* boundaries inside the band too, where no planner ran before the margin.
|
|
116
132
|
*/
|
|
117
133
|
export function chooseCut<D extends object = {}>(input: CutInput<D> & { opportunistic: boolean }): Verdict<D> {
|
|
118
134
|
const { before, mandatory, room, band, fits, plan, unreduced } = input;
|
|
119
|
-
const
|
|
120
|
-
|
|
135
|
+
const lands = (p: Plan<D>): p is Extract<Plan<D>, { draft: true }> => p.draft && p.after < before && fits(p.after + mandatory, band);
|
|
136
|
+
let roomPlan: Plan<D> | undefined;
|
|
137
|
+
const exact = () => roomPlan ??= plan("room");
|
|
138
|
+
if (band >= bandOf(room)) { const e = exact(); if (lands(e)) return { kind: "cut", target: "room", plan: e }; }
|
|
121
139
|
const preferred = plan("band");
|
|
122
140
|
if (input.opportunistic) {
|
|
123
|
-
if (!(preferred
|
|
124
|
-
|
|
141
|
+
if (!lands(preferred)) return { kind: "none" };
|
|
142
|
+
const e = exact();
|
|
143
|
+
return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
|
|
125
144
|
}
|
|
145
|
+
const e = exact();
|
|
126
146
|
let chosen: Plan<D> = preferred, target: Target = "band";
|
|
127
|
-
if (!preferred.draft || (
|
|
147
|
+
if (!preferred.draft || (e.draft && e.mode !== preferred.mode)) { chosen = e; target = "room"; }
|
|
128
148
|
if (!chosen.draft) return { kind: "fault", capacity: chosen.capacityRejected, message: chosen.reason };
|
|
129
149
|
if (chosen.after >= before || !fits(chosen.after + mandatory, room))
|
|
130
150
|
return { kind: "fault", capacity: !fits(chosen.after + mandatory, room), message: unreduced };
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
|
package/src/compaction.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { exportSource } from "./source-export.js";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
|
-
import { bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart,
|
|
8
|
+
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
|
|
9
9
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
10
10
|
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
11
|
import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
@@ -40,7 +40,17 @@ const RecallInput = Type.Union([
|
|
|
40
40
|
]);
|
|
41
41
|
const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({entryId,sourceSha:Type.String({minLength:64,maxLength:64}),start:Type.Integer({minimum:0}),end:Type.Integer({minimum:0})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
|
|
42
42
|
const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
|
|
43
|
-
|
|
43
|
+
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. It is a decision about the
|
|
44
|
+
* state as it was, so it ends with the attempt (`forgetRefusal`) and the next attempt decides afresh. Any other failure is a fault in the compactor
|
|
45
|
+
* or its host, and stays until the session restarts. */
|
|
46
|
+
class Refusal extends Error {}
|
|
47
|
+
/** A refusal for want of room, which the user can remedy: it carries advice. */
|
|
48
|
+
class CapacityError extends Refusal {}
|
|
49
|
+
/** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
|
|
50
|
+
* likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
|
|
51
|
+
export const refusalNotice = (message: string, factor: number): string =>
|
|
52
|
+
message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
|
|
53
|
+
(factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
|
|
44
54
|
/** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
|
|
45
55
|
export const CALIBRATION_CEILING = 1.5;
|
|
46
56
|
/** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
|
|
@@ -83,6 +93,10 @@ export interface CompactionOptions {
|
|
|
83
93
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
84
94
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
85
95
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
96
|
+
/** The cut point: a fraction of the input room, above 0 and at most 1 (default 1). A cut is triggered at, and preferably planned to, the band,
|
|
97
|
+
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The exact room
|
|
98
|
+
* stays the limit of every throw, fault and the complete-context guard. Anything else is rejected when the compactor is installed. */
|
|
99
|
+
cutAt?: number;
|
|
86
100
|
/** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
|
|
87
101
|
* catalogue maximum output, because a transport may omit the output cap and the reply can then grow to that maximum. A host that knows its
|
|
88
102
|
* transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */
|
|
@@ -101,6 +115,8 @@ export interface CompactionOptions {
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
118
|
+
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
119
|
+
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
104
120
|
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
105
121
|
let semanticTransition: Promise<void> = Promise.resolve();
|
|
106
122
|
let semanticRequest = 0;
|
|
@@ -115,7 +131,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
115
131
|
if (found) { recorder.capture("observed-entry", "current-native-branch-memory-not-durable", { entryId: found.id, diagnosticReceiptId: proposalId, details: found.type === "compaction" ? found.details : undefined }); recorder.cut(); proposalId = undefined; }
|
|
116
132
|
});
|
|
117
133
|
const captureDraft = (draft: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>, ctx: ExtensionContext, reserve: number, reason: string, tokensBefore: number, retainCurrentInput: boolean, operatorManual: boolean, previousSummary?: string, budget?: { response: number; host: number; mandatory: number; target: Target; margin: number }) => recorder.measureInline(() => {
|
|
118
|
-
const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
|
|
134
|
+
const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
|
|
119
135
|
proposalId = receiptId(attached);
|
|
120
136
|
if (recorder.enabled) { try {
|
|
121
137
|
const filtered = permittedEntries(ctx.sessionManager.getEntries(), options.recallPolicy);
|
|
@@ -186,7 +202,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
186
202
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
187
203
|
const planReserve = (target: Target, room: number, mandatory: number, ctx: ExtensionContext) => {
|
|
188
204
|
identityFor(ctx);
|
|
189
|
-
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room) : room, factor, mandatory);
|
|
205
|
+
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room, cutAt) : room, factor, mandatory);
|
|
190
206
|
};
|
|
191
207
|
pi.on("message_end", (event, ctx) => {
|
|
192
208
|
if (event.message.role !== "assistant") return;
|
|
@@ -214,7 +230,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
214
230
|
if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
215
231
|
});
|
|
216
232
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
217
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
|
|
233
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetRefusal(); });
|
|
218
234
|
const active = () => options.active?.() ?? true;
|
|
219
235
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
220
236
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -237,10 +253,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
237
253
|
throw new Error("Invalid active-input token ceiling");
|
|
238
254
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
239
255
|
};
|
|
240
|
-
// The exact room, and the band a cut is triggered at (budget.ts); every throw and every fault uses the exact room.
|
|
256
|
+
// The exact room, and the band a cut is triggered at (budget.ts, from this session's cutAt); every throw and every fault uses the exact room.
|
|
241
257
|
const limits = (ctx: ExtensionContext) => {
|
|
242
258
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
243
|
-
return { response, room, band: bandOf(room) };
|
|
259
|
+
return { response, room, band: bandOf(room, cutAt) };
|
|
244
260
|
};
|
|
245
261
|
const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
|
|
246
262
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
@@ -268,10 +284,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
268
284
|
pendingBoundaryCommit = false;
|
|
269
285
|
pending = undefined;
|
|
270
286
|
try { options.fail?.(error); } finally {
|
|
271
|
-
try { if (notify) ctx.ui.notify(failure.message
|
|
287
|
+
try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
|
|
272
288
|
finally { ctx.abort(); }
|
|
273
289
|
}
|
|
274
290
|
};
|
|
291
|
+
// The one place a refusal is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
292
|
+
// input started (a custom message) has only the first of these.
|
|
293
|
+
const forgetRefusal = () => { if (failure instanceof Refusal) failure = undefined; };
|
|
275
294
|
const access = () => {
|
|
276
295
|
if (failure) throw failure;
|
|
277
296
|
if (!active()) throw new Error("Compaction host is inactive");
|
|
@@ -284,7 +303,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
284
303
|
let phase: "idle" | "decided" | "starting" = "idle";
|
|
285
304
|
pi.on("input", async (event, ctx) => {
|
|
286
305
|
if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
|
|
287
|
-
|
|
306
|
+
forgetRefusal();
|
|
288
307
|
phase = "decided";
|
|
289
308
|
try { await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); }
|
|
290
309
|
catch (error) { fail(error, ctx); }
|
|
@@ -340,7 +359,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
340
359
|
// Pi also labels the awaited idle incoming preflight "manual".
|
|
341
360
|
const incoming = pendingCut;
|
|
342
361
|
const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
|
|
343
|
-
if (operatorManual && !admittedRun
|
|
362
|
+
if (operatorManual && !admittedRun) forgetRefusal();
|
|
344
363
|
access();
|
|
345
364
|
if (event.customInstructions?.trim()) {
|
|
346
365
|
recorder.stop("no-cut-custom-instructions");
|
|
@@ -385,7 +404,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
385
404
|
if (decision.kind === "none") return { cancel: true };
|
|
386
405
|
recorder.stop("no-cut");
|
|
387
406
|
ctx.ui.notify(decision.message, "warning");
|
|
388
|
-
if (!operatorManual) fail(decision.capacity ?
|
|
407
|
+
if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
389
408
|
return { cancel: true };
|
|
390
409
|
} catch (error) {
|
|
391
410
|
try { fail(error, ctx); } finally { return { cancel: true }; }
|
|
@@ -432,14 +451,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
432
451
|
// planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
|
|
433
452
|
// or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
|
|
434
453
|
if (cut.opportunistic && !failure && !cut.proposed) resolve();
|
|
435
|
-
else reject(error.message === "Compaction cancelled" && failure instanceof
|
|
454
|
+
else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : error);
|
|
436
455
|
} }));
|
|
437
456
|
} finally { pendingCut = undefined; }
|
|
438
457
|
access();
|
|
439
458
|
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
440
459
|
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
441
460
|
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
442
|
-
throw new (afterFits ?
|
|
461
|
+
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
443
462
|
};
|
|
444
463
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
445
464
|
const prompted = phase === "decided";
|
|
@@ -486,7 +505,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
486
505
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
487
506
|
},
|
|
488
507
|
});
|
|
489
|
-
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError :
|
|
508
|
+
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
490
509
|
if (decision.kind !== "cut") return;
|
|
491
510
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary,
|
|
492
511
|
{ response, host, mandatory, target: decision.target, margin: room - band });
|
package/src/diagnostics.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { createRecall, type RecallPolicy } from './recall.js';
|
|
8
8
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
9
9
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
10
|
-
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
10
|
+
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
11
11
|
export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
|
|
12
12
|
const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
|
|
13
13
|
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
package/src/omissions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { buildGlobalIndexById } from './tuck/core/global-indices';
|
|
3
|
-
import {
|
|
3
|
+
import { parseRecords, splitSummary, transcriptHeader } from './tuck/core/transcript';
|
|
4
4
|
import { createRecall, type RecallPolicy } from './recall';
|
|
5
5
|
/** Native branch edits supply exclusion; original global indices supply provenance. */
|
|
6
6
|
export function omittedSourceIndices(entries: readonly SessionEntry[], branch: readonly SessionEntry[]): ReadonlySet<number> {
|
|
@@ -12,30 +12,30 @@ export function omittedSourceIndices(entries: readonly SessionEntry[], branch: r
|
|
|
12
12
|
}
|
|
13
13
|
return new Set([...edits].flatMap(([id, omitted]) => omitted && indices.has(id) ? [indices.get(id)!] : []));
|
|
14
14
|
}
|
|
15
|
-
/** Preserve surviving record bytes/order. No text matching or semantic inference. */
|
|
15
|
+
/** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
|
|
16
16
|
export function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>): string {
|
|
17
17
|
if (!omitted.size) return summary;
|
|
18
18
|
const split = splitSummary(summary);
|
|
19
19
|
if (!split) throw new Error('Cannot apply native source omission to an unsupported compaction summary');
|
|
20
|
-
const { body, closing } = split;
|
|
21
|
-
const records = parseRecords(body), lines = body ? body.split('\n') : [];
|
|
20
|
+
const { body, closing, version } = split;
|
|
21
|
+
const records = parseRecords(body, version), lines = body ? body.split('\n') : [];
|
|
22
22
|
const retained = records.flatMap((record, i) =>
|
|
23
23
|
(record.sourceIndex !== undefined && omitted.has(record.sourceIndex)) || record.sourceIndices?.some(index => omitted.has(index)) ? [] : [lines[i]!]);
|
|
24
|
-
return
|
|
24
|
+
return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Custom quotes carry native entry identity; no text matching or legacy fallback. */
|
|
28
28
|
export function filterCustomQuotes(summary: string, entries: readonly SessionEntry[], branch: readonly SessionEntry[], policy: RecallPolicy = {}): string {
|
|
29
29
|
const split = splitSummary(summary);
|
|
30
30
|
if (!split) throw new Error('Cannot resolve custom sources in an unsupported compaction summary');
|
|
31
|
-
const { body, closing } = split;
|
|
31
|
+
const { body, closing, version } = split;
|
|
32
32
|
const omitted = new Set(branch.flatMap(entry => entry.type === 'context_edit' && entry.replacement === null ? [entry.targetId] : []));
|
|
33
33
|
const eligible = createRecall(policy).isHistorySource, indices = buildGlobalIndexById(entries);
|
|
34
34
|
const globalCounts = new Map<string, number>(), branchSources = new Map<string, SessionEntry[]>();
|
|
35
35
|
for (const entry of entries) globalCounts.set(entry.id, (globalCounts.get(entry.id) ?? 0) + 1);
|
|
36
36
|
for (const entry of branch) { const found = branchSources.get(entry.id) ?? []; found.push(entry); branchSources.set(entry.id, found); }
|
|
37
37
|
const lines = body ? body.split('\n') : [];
|
|
38
|
-
const records = parseRecords(body);
|
|
38
|
+
const records = parseRecords(body, version);
|
|
39
39
|
const retained = records.flatMap((record, i) => {
|
|
40
40
|
if (record.role !== 'custom') return [lines[i]!];
|
|
41
41
|
const id = record.sourceEntryId, source = id === undefined ? undefined : branchSources.get(id);
|
|
@@ -45,7 +45,7 @@ export function filterCustomQuotes(summary: string, entries: readonly SessionEnt
|
|
|
45
45
|
if (!(entry.type === 'custom_message' || entry.type === 'message' && entry.message.role === 'custom') || !eligible(entry)) return [];
|
|
46
46
|
return [lines[i]!];
|
|
47
47
|
});
|
|
48
|
-
return
|
|
48
|
+
return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
|
|
49
49
|
}
|
|
50
50
|
type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
|
|
51
51
|
/** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
|