pi-tuck 1.0.21 → 1.0.23
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 +83 -0
- package/README.md +9 -10
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +14 -3
- package/dist/src/compaction.js +127 -30
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/eligibility-cache.d.ts +1 -1
- package/dist/src/eligibility-cache.js +3 -3
- package/dist/src/request-capacity.d.ts +14 -0
- package/dist/src/request-capacity.js +57 -0
- package/dist/src/semantic-recall.d.ts +6 -1
- package/dist/src/semantic-recall.js +51 -24
- package/dist/src/tuck/core/search-entries.js +2 -2
- package/dist/src/tuck.d.ts +2 -1
- package/dist/src/tuck.js +8 -3
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +114 -34
- package/src/eligibility-cache.ts +3 -3
- package/src/request-capacity.ts +56 -0
- package/src/semantic-recall.ts +19 -8
- package/src/tuck/core/search-entries.ts +2 -2
- package/src/tuck.ts +8 -4
package/src/compaction.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { textOf } from "./tuck/core/content.js";
|
|
2
|
+
import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
|
|
3
|
+
import { BUILD_ID } from "./build-id.js";
|
|
1
4
|
import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
|
|
2
5
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -8,7 +11,7 @@ import { projectedMessages } from "./omissions.js";
|
|
|
8
11
|
import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
|
|
9
12
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
|
|
10
13
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
11
|
-
import { getCurrentSystemMessage, isContextOverflow, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
14
|
+
import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
12
15
|
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
16
|
import { Type } from "typebox";
|
|
14
17
|
import { Check } from "typebox/value";
|
|
@@ -102,15 +105,18 @@ export interface CompactionOptions {
|
|
|
102
105
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
103
106
|
semanticStatus?: () => unknown;
|
|
104
107
|
diagnostics?: RecorderOptions;
|
|
108
|
+
/** Optional read-only observation of actual corrected budget checks; no request content. */
|
|
109
|
+
observeBudget?: (measurement: { rawTokens: number; tokens: number; factor: number; response: number; room: number; hardLine?: number; context: ExtensionContext }) => void;
|
|
105
110
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
106
111
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
107
112
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
108
113
|
/** An explicit cut point: a fraction of the input room, above 0 and at most 1. A cut is triggered at, and preferably planned to, the band,
|
|
109
|
-
* 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
|
|
110
|
-
*
|
|
114
|
+
* 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 room
|
|
115
|
+
* owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
|
|
111
116
|
cutAt?: number;
|
|
112
117
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
113
|
-
* Standalone
|
|
118
|
+
* Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
|
|
119
|
+
* After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
114
120
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
115
121
|
requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
|
|
116
122
|
/** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
|
|
@@ -127,6 +133,9 @@ export interface CompactionOptions {
|
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
136
|
+
const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
|
|
137
|
+
const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
|
|
138
|
+
.then(module => module.clampMaxTokensToContext).catch(() => undefined);
|
|
130
139
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
131
140
|
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
132
141
|
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
@@ -139,7 +148,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
139
148
|
const semanticPending = new Set<Promise<unknown>>();
|
|
140
149
|
// Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
|
|
141
150
|
// attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
|
|
142
|
-
|
|
151
|
+
// A prompt's cut reaches the requests at once but the session only at the end of the first turn: until then search sees the history as the model does.
|
|
152
|
+
const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, promptCut ? promptCut.firstKeptEntryId || ctx.sessionManager.getBranch().findLast(e => e.type === "message" && e.message.role === "user")?.id : undefined);
|
|
143
153
|
const recorder = new IncidentRecorder(options.diagnostics);
|
|
144
154
|
let proposalId: string | undefined;
|
|
145
155
|
const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
|
|
@@ -208,6 +218,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
208
218
|
} });
|
|
209
219
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
210
220
|
let admittedRun = false;
|
|
221
|
+
let runPrompt: string | undefined;
|
|
222
|
+
const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
|
|
223
|
+
entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
|
|
224
|
+
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");
|
|
225
|
+
let estimatedTokens = 0;
|
|
211
226
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
212
227
|
// start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
|
|
213
228
|
let identity = "", factor = 1, ratios: number[] = [];
|
|
@@ -221,6 +236,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
221
236
|
// The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
|
|
222
237
|
const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
223
238
|
const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
239
|
+
const observeBudget = (rawTokens: number, ctx: ExtensionContext) => {
|
|
240
|
+
if (options.observeBudget) options.observeBudget({ rawTokens, tokens: corrected(rawTokens, ctx), factor, ...limits(ctx), context: ctx });
|
|
241
|
+
};
|
|
224
242
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
225
243
|
const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
|
|
226
244
|
identityFor(ctx);
|
|
@@ -263,7 +281,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
263
281
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
264
282
|
});
|
|
265
283
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
266
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
284
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
267
285
|
const active = () => options.active?.() ?? true;
|
|
268
286
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
269
287
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
@@ -288,10 +306,12 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
288
306
|
throw new Error("Invalid active-input token ceiling");
|
|
289
307
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
290
308
|
};
|
|
291
|
-
//
|
|
309
|
+
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
292
310
|
const limits = (ctx: ExtensionContext) => {
|
|
293
311
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
294
|
-
|
|
312
|
+
const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
|
|
313
|
+
const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
|
|
314
|
+
return { response, room, hardLine, replyFloor: floor };
|
|
295
315
|
};
|
|
296
316
|
const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
|
|
297
317
|
const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
@@ -331,12 +351,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
331
351
|
// Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
|
|
332
352
|
// before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
|
|
333
353
|
let promptCutAt = 0;
|
|
334
|
-
const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
|
|
354
|
+
const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
|
|
355
|
+
ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
|
|
335
356
|
let failure: Error | undefined;
|
|
357
|
+
let refusalReceipted = false;
|
|
336
358
|
let pendingBoundaryCommit = false;
|
|
337
359
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
338
360
|
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
339
361
|
const clips = new Map<string, Clip>();
|
|
362
|
+
const receipt = (kind: "refusal" | "above-room", reason: string, ctx: ExtensionContext) => {
|
|
363
|
+
let room: number | null = null, hardLine: number | null = null;
|
|
364
|
+
try { ({ room, hardLine } = limits(ctx)); } catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
|
|
365
|
+
pi.appendEntry("pi-tuck-capacity", { kind, reason, estimatedTokens, room, hardLine, buildId: BUILD_ID });
|
|
366
|
+
};
|
|
340
367
|
const fail = (error: unknown, ctx: ExtensionContext) => {
|
|
341
368
|
recorder.stop("native-attempt-failed");
|
|
342
369
|
// Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
|
|
@@ -347,7 +374,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
347
374
|
pendingBoundaryCommit = false;
|
|
348
375
|
pending = undefined;
|
|
349
376
|
promptCut = undefined;
|
|
350
|
-
try {
|
|
377
|
+
try {
|
|
378
|
+
if (!refusalReceipted) { refusalReceipted = true; receipt("refusal", failure.message, ctx); }
|
|
379
|
+
options.fail?.(error);
|
|
380
|
+
} finally {
|
|
351
381
|
try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
|
|
352
382
|
? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
|
|
353
383
|
: `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
|
|
@@ -356,7 +386,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
356
386
|
};
|
|
357
387
|
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
358
388
|
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
359
|
-
const forgetFailure = () => { if (!(failure instanceof CommitFault)) failure = undefined; };
|
|
389
|
+
const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault)) failure = undefined; };
|
|
360
390
|
const access = () => {
|
|
361
391
|
if (failure) throw failure;
|
|
362
392
|
hostAccess();
|
|
@@ -406,7 +436,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
406
436
|
// Bind the actual independent lexical offer, including attribution derived
|
|
407
437
|
// from custom markers and receipts excluded from semantic indexing. Rebuild
|
|
408
438
|
// under current caller policy after await, when the session moved; never send this block to the encoder.
|
|
409
|
-
|
|
439
|
+
// Keyword search over a very long history can hit its time limit. That limit guards one call, not the semantic half: the agent still gets the
|
|
440
|
+
// semantic results, and is told the keyword half stopped (seen in the field on an 84.7 MB session, where the whole search failed instead).
|
|
441
|
+
const wordOffer=()=>{try{return tuckRecall(tuck,ctx.sessionManager.getEntries(),ctx.sessionManager.getBranch(),{query:input.query},options.recallPolicy);}
|
|
442
|
+
catch(error){if(error instanceof Error&&error.message.startsWith("Search aborted:"))return{status:"keyword_timeout",notice:"Keyword search stopped at its time limit on this long history; the semantic results follow. For a keyword match, use more specific words or read an entry by index.",hits:[]};throw error;}};
|
|
410
443
|
// The key is taken in the same synchronous step as the first lexical pass. After the await the second pass runs only if it moved (see eligibilityKey); a moved key alone invalidates nothing.
|
|
411
444
|
// The snapshot id check covers every in-contract change (any branch change moves the id); the key-gated second pass is defence in depth for out-of-contract callers.
|
|
412
445
|
const provider=semanticProvider(ctx),initialSnapshot=provider(),initialKey=eligibilityKey(ctx),words=wordOffer(),wordText=JSON.stringify(words);
|
|
@@ -444,6 +477,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
444
477
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
445
478
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
446
479
|
const before = plannerMeasureFor(ctx)(saved);
|
|
480
|
+
observeBudget(before + mandatory, ctx);
|
|
447
481
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
448
482
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
449
483
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
@@ -455,25 +489,33 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
455
489
|
thresholdOpportunistic = gate.opportunistic;
|
|
456
490
|
}
|
|
457
491
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
458
|
-
const
|
|
459
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
492
|
+
const decide = (targetRoom = room, targetBand = band) => chooseCut({
|
|
493
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
460
494
|
opportunistic: thresholdOpportunistic,
|
|
461
495
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
462
496
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
463
|
-
const reserve = planReserve(target,
|
|
497
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
464
498
|
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
465
499
|
// from any run that ended. A retry is inside its run.
|
|
466
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
500
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
|
|
467
501
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
468
502
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
469
503
|
},
|
|
470
504
|
});
|
|
505
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
506
|
+
let decision = decide();
|
|
507
|
+
const { hardLine } = limits(ctx);
|
|
508
|
+
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
509
|
+
if (fits(before + mandatory, hardLine, ctx)) return { cancel: true };
|
|
510
|
+
decision = decide(hardLine, hardLine);
|
|
511
|
+
}
|
|
471
512
|
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
472
513
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
473
514
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
474
515
|
if (decision.kind === "none") return { cancel: true };
|
|
475
516
|
recorder.stop("no-cut");
|
|
476
517
|
ctx.ui.notify(decision.message, "warning");
|
|
518
|
+
if (operatorManual) receipt("refusal", decision.message, ctx);
|
|
477
519
|
if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
478
520
|
return { cancel: true };
|
|
479
521
|
} catch (error) {
|
|
@@ -481,7 +523,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
481
523
|
}
|
|
482
524
|
});
|
|
483
525
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
484
|
-
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
526
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
485
527
|
const historyToolsEnabled = (ctx: ExtensionContext) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
|
|
486
528
|
const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
487
529
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -516,27 +558,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
516
558
|
const band = decisionBand(ctx, room, system);
|
|
517
559
|
const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
518
560
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
519
|
-
|
|
561
|
+
observeBudget(before + mandatory, ctx);
|
|
562
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
563
|
+
if (!fits(incoming, limits(ctx).hardLine, ctx))
|
|
520
564
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
521
565
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
522
566
|
// help: leave it to the complete-context guard, which fails closed.
|
|
523
567
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
524
|
-
if (gate.kind
|
|
568
|
+
if (gate.kind === "none") return;
|
|
525
569
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
526
|
-
const
|
|
527
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic
|
|
570
|
+
const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
|
|
571
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
|
|
528
572
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
529
573
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
530
|
-
const reserve = planReserve(target,
|
|
574
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
531
575
|
// Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
|
|
532
576
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
533
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !
|
|
577
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
534
578
|
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
535
579
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
536
580
|
assertNativeToolRounds(messages);
|
|
537
581
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
538
582
|
},
|
|
539
583
|
});
|
|
584
|
+
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" as const };
|
|
585
|
+
const { hardLine } = limits(ctx);
|
|
586
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
587
|
+
if (fits(before + mandatory, hardLine, ctx)) return;
|
|
588
|
+
decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
589
|
+
}
|
|
590
|
+
if (decision.kind === "defer") return;
|
|
540
591
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
541
592
|
if (decision.kind === "none") return;
|
|
542
593
|
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
@@ -547,6 +598,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
547
598
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
548
599
|
const prompted = phase === "decided";
|
|
549
600
|
phase = "starting";
|
|
601
|
+
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
550
602
|
try {
|
|
551
603
|
syncHistoryTools(ctx);
|
|
552
604
|
if (prompted) access(); // decided on input; a failure latched there still stops the run
|
|
@@ -622,19 +674,23 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
622
674
|
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
623
675
|
let previews: Messages[] = [];
|
|
624
676
|
let floorKept: number | undefined;
|
|
625
|
-
|
|
677
|
+
let wholePromptFloor: number | undefined;
|
|
678
|
+
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
679
|
+
const decide = (targetRoom = room, targetBand = band) => {
|
|
626
680
|
previews = [];
|
|
627
681
|
floorKept = undefined;
|
|
682
|
+
wholePromptFloor = undefined;
|
|
628
683
|
const before = measure(saved);
|
|
629
|
-
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
684
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
630
685
|
if (gate.kind !== "plan") return gate;
|
|
631
686
|
return chooseCut({
|
|
632
|
-
opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
687
|
+
opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
633
688
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
634
689
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
635
|
-
const reserve = planReserve(target,
|
|
636
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
690
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
691
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
637
692
|
if (!draft.compaction) {
|
|
693
|
+
if (protectedId && draft.floorMessages) wholePromptFloor = measure(draft.floorMessages) + mandatory;
|
|
638
694
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
639
695
|
if (draft.floorMessages && event.toolResults) {
|
|
640
696
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
@@ -662,23 +718,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
662
718
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
663
719
|
// built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
|
|
664
720
|
// refusal stands.
|
|
665
|
-
|
|
721
|
+
const tryClips = (clipRoom: number, targetBand: number) => {
|
|
722
|
+
if (decision.kind !== "fault" || !event.toolResults) return;
|
|
666
723
|
const branch = ctx.sessionManager.getBranch();
|
|
667
724
|
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
668
725
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
669
726
|
const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message =>
|
|
670
727
|
!(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId)))))) : floorKept;
|
|
671
|
-
for (const budget of clipBudgets(
|
|
728
|
+
for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
|
|
672
729
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
673
730
|
if (!round.length) continue;
|
|
674
731
|
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
675
|
-
const clippedDecision = decide();
|
|
732
|
+
const clippedDecision = decide(clipRoom, targetBand);
|
|
676
733
|
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
677
734
|
else { decision = clippedDecision; break; }
|
|
678
735
|
}
|
|
679
|
-
}
|
|
736
|
+
};
|
|
737
|
+
tryClips(room, band);
|
|
680
738
|
const before = measure(saved);
|
|
681
|
-
|
|
739
|
+
observeBudget(before + mandatory, ctx);
|
|
740
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
741
|
+
const { hardLine } = limits(ctx);
|
|
742
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
743
|
+
if (fits(before + mandatory, hardLine, ctx)) return;
|
|
744
|
+
decision = decide(hardLine, hardLine);
|
|
745
|
+
tryClips(hardLine, hardLine);
|
|
746
|
+
}
|
|
747
|
+
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
748
|
+
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
|
|
749
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
750
|
+
}
|
|
682
751
|
if (decision.kind !== "cut") return;
|
|
683
752
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary,
|
|
684
753
|
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
@@ -785,8 +854,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
785
854
|
const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
|
|
786
855
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
787
856
|
const tokens = system + conversationTokens(messages);
|
|
788
|
-
|
|
857
|
+
observeBudget(tokens, ctx);
|
|
858
|
+
estimatedTokens = corrected(tokens, ctx);
|
|
859
|
+
const { room, hardLine, replyFloor } = limits(ctx);
|
|
860
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
861
|
+
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");
|
|
862
|
+
if (!fits(tokens, hardLine, ctx))
|
|
789
863
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
864
|
+
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
865
|
+
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
866
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
867
|
+
piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
|
|
868
|
+
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
869
|
+
if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
790
870
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
791
871
|
recorder.request();
|
|
792
872
|
// No system prompt or hidden assistant blocks; no provider payload/headers.
|
package/src/eligibility-cache.ts
CHANGED
|
@@ -32,12 +32,12 @@ export const eligibilityKeyMoved = (ctx: Context, since: readonly unknown[]) =>
|
|
|
32
32
|
* the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
|
|
33
33
|
* The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
|
|
34
34
|
* `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
|
|
35
|
-
export function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void): Snapshot {
|
|
35
|
+
export function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void, pendingKeptId?: string): Snapshot {
|
|
36
36
|
try { access(); } catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : 'Source access denied'), { name: 'SemanticAccessError' }); }
|
|
37
|
-
const key = eligibilityKey(ctx), held = cache.get(ctx.sessionManager);
|
|
37
|
+
const key = [...eligibilityKey(ctx), pendingKeptId ?? null], held = cache.get(ctx.sessionManager);
|
|
38
38
|
if (held && held.policy === policy && same(held.key, key)) return { ...held.stored, units: [], complete: false, indexed: new Set() };
|
|
39
39
|
// Access was checked on the line above with nothing awaited in between, so the rebuild does not check it a second time.
|
|
40
|
-
const built = semanticEligibleSources(ctx, policy ?? {}, () => {});
|
|
40
|
+
const built = semanticEligibleSources(ctx, policy ?? {}, () => {}, pendingKeptId);
|
|
41
41
|
cache.set(ctx.sessionManager, { policy, key, stored: { id: built.id, session: built.session, project: built.project, sources: built.sources, throughEntryId: built.throughEntryId, viewStart: built.viewStart } });
|
|
42
42
|
return built;
|
|
43
43
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { Messages } from './budget.js';
|
|
4
|
+
import { parseRecords, splitSummary } from './tuck/core/transcript.js';
|
|
5
|
+
|
|
6
|
+
/** 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.
|
|
8
|
+
*/
|
|
9
|
+
export async function piContextSafetyMargin(): Promise<number> {
|
|
10
|
+
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
|
+
]);
|
|
14
|
+
const window = 1_000_000, context = normalizeContext({ messages: [] });
|
|
15
|
+
const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window } as never, context, window);
|
|
16
|
+
if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window) throw new Error('Invalid Pi context margin');
|
|
17
|
+
return margin;
|
|
18
|
+
} catch { return 4_096; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 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). */
|
|
22
|
+
export const REPLY_FLOOR_MIN = 3_584;
|
|
23
|
+
|
|
24
|
+
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
25
|
+
export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
|
|
26
|
+
let largest = REPLY_FLOOR_MIN;
|
|
27
|
+
for (const entry of branch) {
|
|
28
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
29
|
+
const message = entry.message;
|
|
30
|
+
if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message)) return reserve;
|
|
31
|
+
const output = message.usage?.output;
|
|
32
|
+
if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
|
|
33
|
+
}
|
|
34
|
+
return Math.min(reserve, Math.ceil(largest));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Admission never promises more output than a model with a positive reply cap can produce. */
|
|
38
|
+
export function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number {
|
|
39
|
+
const floor = branchReplyFloor(branch, reserve);
|
|
40
|
+
return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
|
|
44
|
+
export function carriesPrompt(messages: Messages, prompt: string): boolean {
|
|
45
|
+
if (!prompt.length) return true;
|
|
46
|
+
return messages.some(message => {
|
|
47
|
+
if (message.role === 'user') {
|
|
48
|
+
const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
|
|
49
|
+
return text.includes(prompt);
|
|
50
|
+
}
|
|
51
|
+
if (message.role !== 'compactionSummary') return false;
|
|
52
|
+
const split = splitSummary(message.summary);
|
|
53
|
+
return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record =>
|
|
54
|
+
record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/semantic-recall.ts
CHANGED
|
@@ -195,6 +195,11 @@ export function viewStart(branch:SessionEntry[]):number{
|
|
|
195
195
|
}
|
|
196
196
|
const TOOL_OUTPUT=new Set(['toolResult','bashExecution']);
|
|
197
197
|
const isToolOutput=(source:Source)=>TOOL_OUTPUT.has(source.row.role);
|
|
198
|
+
/** Whether `now` only grew from `plan`: the same session and project, and every source the plan ranks still eligible with the same hash. */
|
|
199
|
+
function grownFrom(now:Snapshot,plan:Snapshot):boolean{
|
|
200
|
+
if(now.session!==plan.session||now.project!==plan.project)return false;if(now.id===plan.id)return true;
|
|
201
|
+
const shas=new Map(now.sources.map(source=>[source.entry.id,source.sha]));return plan.sources.every(source=>shas.get(source.entry.id)===source.sha);
|
|
202
|
+
}
|
|
198
203
|
/** Sources in the order they are covered: out-of-view conversation (user messages, assistant text, tool-call names and arguments), then out-of-view tool output, then what is in view.
|
|
199
204
|
* Each part is newest first, extending backward. */
|
|
200
205
|
function windowOrder(snapshot:Snapshot):Source[]{
|
|
@@ -216,7 +221,9 @@ function windowCoverage(snapshot:Snapshot,prefix=indexedPrefix(snapshot)){
|
|
|
216
221
|
return{indexedCharacters,windowCharacters:window.windowCharacters,outOfViewCharacters:window.conversationOutOfView+window.toolOutOfView,eligibleCharacters:window.eligibleCharacters,oldestCoveredEntryId:oldest?.entry.id??null,oldestCoveredTime:time(oldest),
|
|
217
222
|
conversation:{indexedCharacters:conversation,outOfViewCharacters:window.conversationOutOfView},toolResults:{indexedCharacters:tools,outOfViewCharacters:window.toolOutOfView,oldestCoveredTime:time(oldestTool)}};
|
|
218
223
|
}
|
|
219
|
-
|
|
224
|
+
/** `pendingKeptId`: the first kept entry of a cut pi-tuck has applied to the requests but Pi has not yet saved (a prompt's cut is saved at the end of
|
|
225
|
+
* the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
|
|
226
|
+
export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManager'|'cwd'>,policy:RecallPolicy,access:()=>void,pendingKeptId?:string):Snapshot {
|
|
220
227
|
try{access();}catch(error){throw Object.assign(new Error(error instanceof Error?error.message:'Source access denied'),{name:'SemanticAccessError'});}const branch=ctx.sessionManager.getBranch(),all=ctx.sessionManager.getEntries();
|
|
221
228
|
if(branch.some(e=>e.type==='context_edit'&&e.replacement!==null))throw new Error('Unsupported replacement context edit');
|
|
222
229
|
const omitted=new Set(branch.flatMap(e=>e.type==='context_edit'?[e.targetId]:[]));
|
|
@@ -225,7 +232,8 @@ export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManage
|
|
|
225
232
|
const eligible=branch.filter(e=>!omitted.has(e.id)&&ids.get(e.id)===1),recall=createRecall(policy),attempts=recall.recallAttempts(eligible),links=recall.producerReads(eligible),indices=buildGlobalIndexById(all);
|
|
226
233
|
const orders=new Map(branch.map((entry,order)=>[entry.id,order]));
|
|
227
234
|
const sources=eligible.flatMap(entry=>{const order=orders.get(entry.id)!;const row=recall.discoveryEntry(entry,attempts,links);if(!row)return[];const {sourceText:unusedText,ranges:unusedRanges,...readable}=row;return[{entry,row:readable,sourceText:row.sourceText,ranges:row.ranges,order,sha:hash({entry,order,row:readable,ranges:row.ranges,index:indices.get(entry.id)})}];});
|
|
228
|
-
const
|
|
235
|
+
const pending=pendingKeptId===undefined?-1:branch.findIndex(e=>e.id===pendingKeptId),start=Math.max(viewStart(branch),pending);
|
|
236
|
+
const session=ctx.sessionManager.getSessionId(),id=hash({session,project:resolve(ctx.cwd),branch:branch.map(e=>[e.id,e.parentId]),sources:sources.map(s=>[s.entry.id,s.sha]),...(pending>=0?{pendingKeptId}:{})});return{id,session,project:ctx.cwd,sources,units:[],complete:false,throughEntryId:sources.at(-1)?.entry.id??null,viewStart:start,indexed:new Set()};
|
|
229
237
|
}
|
|
230
238
|
export class SemanticRecallController {
|
|
231
239
|
private readonly ownerId=randomUUID();private readonly indexFiles=new Set<string>();private closing?:Promise<void>;private joiningSearches=0;private activeSearches=new Set<Promise<SemanticPage>>();private config?:SemanticRecallConfig;private worker?:LocalWorker;private identity='';private provider?:()=>Snapshot;private generation=0;private cleanupError:string|null=null;private dirty=false;private operations={preparationEmbeddings:0,queryEmbeddings:0};
|
|
@@ -267,7 +275,7 @@ export class SemanticRecallController {
|
|
|
267
275
|
private async runtime(project:string){if(this.idling)await this.idling;if(!this.enabled)throw abort();outsideProject(project,this.config!.runtimeRoot,'runtime');outsideProject(project,scratchParent(this.config!),'scratch');outsideRuntime(this.config!.runtimeRoot,scratchParent(this.config!),'scratch');if(this.worker&&this.recycle){const old=this.worker;await old.close();if(this.worker===old){this.worker=undefined;this.identity='';this.recycle=false;}if(!this.enabled)throw abort();}
|
|
268
276
|
if(!this.worker){const worker=new LocalWorker(this.config!,project,error=>{if(this.worker!==worker)return;this.cleanupError=error instanceof Error?error.message:'Semantic cleanup failed';this.error=this.cleanupError;this.enabled=false;this.generation++;},reason=>{if(this.worker===worker)this.heal(new WorkerFailure(reason));},{startMs:this.timing.startDeadlineMs??START_DEADLINE_MS,callMs:this.timing.callDeadlineMs??CALL_DEADLINE_MS,embedTextMs:this.timing.embedTextMs??EMBED_TEXT_MS});this.worker=worker;}if(!this.identity){const ready=await this.worker.call('ready');if(ready.dimensions!==768||ready.maxTokens!==8192||!ready.offline)throw new Error('Incompatible semantic model; EG2 768/8192 offline required');this.identity=hash({worker:ready.identity,chunk:'eg2-passage256-focused-disjoint-direct-v3'});}return this.worker;}
|
|
269
277
|
private path(snapshot:Snapshot){const directory=this.config!.indexDirectory;outsideProject(snapshot.project,directory,'index');return join(directory,hash({owner:this.ownerId,session:snapshot.session,project:resolve(snapshot.project)})+'.sqlite');}
|
|
270
|
-
private async index(action:string,snapshot:Snapshot,extra:object={}){const path=this.path(snapshot),generation=this.generation,worker=await this.runtime(snapshot.project);if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);this.indexFiles.add(path);const result=await worker.call('index',{path,identity:this.identity,action,projectDirectory:snapshot.project,...extra});if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);return result;}
|
|
278
|
+
private async index(action:string,snapshot:Snapshot,extra:object={},boundId?:string){const path=this.path(snapshot),generation=this.generation,worker=await this.runtime(snapshot.project);if(!this.enabled||generation!==this.generation)throw abort();if(this.provider){if(boundId!==undefined)this.recheckGrown(this.provider,snapshot,boundId);else this.recheck(this.provider,snapshot);} /* a search's scoring may read a plan its snapshot grew from */this.indexFiles.add(path);const result=await worker.call('index',{path,identity:this.identity,action,projectDirectory:snapshot.project,...extra});if(!this.enabled||generation!==this.generation)throw abort();if(this.provider){if(boundId!==undefined)this.recheckGrown(this.provider,snapshot,boundId);else this.recheck(this.provider,snapshot);}return result;}
|
|
271
279
|
private async prepare(provider:()=>Snapshot,full=true){
|
|
272
280
|
if(!this.enabled)return;
|
|
273
281
|
const generation=this.generation,started=performance.now();let snapshot=provider();const prior=this.current;
|
|
@@ -333,6 +341,9 @@ export class SemanticRecallController {
|
|
|
333
341
|
/** A plan for this snapshot that is fully indexed or, for a search, has all its out-of-view conversation indexed. The tool output behind it and the in-view tail are not worth waiting for: they keep indexing in the background and the coverage statement says how far they got.
|
|
334
342
|
* With no out-of-view conversation (no cut) nothing waits: the search returns at once. Only a window whose out-of-view part is tool output alone still waits for the whole window. */
|
|
335
343
|
private readyFor(id:string|undefined,forSearch:boolean){const plan=this.current;return Boolean(plan&&plan.id===id&&(plan.complete||(forSearch&&plan.window!==undefined&&plan.window.searchUnits>0&&plan.units.slice(0,plan.window.searchUnits).every(unit=>plan.indexed.has(unit.key)))));}
|
|
344
|
+
/** A search may rank a plan its own snapshot has grown from (every source in the plan still eligible, unchanged), as long as the session has not
|
|
345
|
+
* moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
|
|
346
|
+
private recheckGrown(provider:()=>Snapshot,snapshot:Snapshot,boundId:string){const now=provider();if(now.id!==boundId||!grownFrom(now,snapshot))throw new Error('Eligible branch or source changed before semantic admission');}
|
|
336
347
|
private recheck(provider:()=>Snapshot,snapshot:Snapshot){const now=provider();if(now.id!==snapshot.id||now.session!==snapshot.session||now.project!==snapshot.project)throw new Error('Eligible branch or source changed before semantic admission');}
|
|
337
348
|
private async joinPreparation(deadline:number,signal?:AbortSignal){
|
|
338
349
|
const task=this.task;if(!task)return true;let timer:ReturnType<typeof setTimeout>|undefined,onAbort:(()=>void)|undefined;
|
|
@@ -355,15 +366,15 @@ export class SemanticRecallController {
|
|
|
355
366
|
if(!planned){this.error=null;this.schedule(provider);}
|
|
356
367
|
if(!(this.current?.id===bound.id&&((this.current.complete&&!this.current.units.length)||(indexedPrefix(this.current)>0&&this.worker!==undefined&&!this.recycle&&!this.idling)))){if(page)return page;return{status:'indexing_incomplete',notice:'No semantic ranking performed: nothing is out of view, so the whole history is in your context.',coverage:this.status(),groups:[]};}
|
|
357
368
|
}else{
|
|
358
|
-
if(this.current?.id!==bound.id){const prepared=!this.task||await this.joinPreparation(deadline,signal);if(signal?.aborted||queryGeneration!==this.generation)throw abort();if(
|
|
369
|
+
if(this.current?.id!==bound.id){const prepared=!this.task||await this.joinPreparation(deadline,signal);if(signal?.aborted||queryGeneration!==this.generation)throw abort();if(prepared&&performance.now()<deadline){this.error=null;this.schedule(provider);await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}}else{if(!this.current.complete){this.error=null;this.schedule(provider);}await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}if(signal?.aborted||queryGeneration!==this.generation)throw abort();
|
|
359
370
|
}
|
|
360
371
|
// Ranking needs a plan for the bound snapshot and an indexed prefix; the coverage statement describes exactly that prefix and nothing else is ranked, so a search that reaches its wait limit with part of the window indexed ranks that part. Only an empty prefix is `indexing_incomplete`.
|
|
361
|
-
const snapshot=this.current;if(!snapshot||snapshot.id!==bound.id||(snapshot.units.length?indexedPrefix(snapshot)===0:!snapshot.complete)){const failure=this.error??this.healReason;return failure&&!CHURN.test(failure)?unavailable():incomplete();}
|
|
372
|
+
const snapshot=this.current,older=snapshot!==undefined&&snapshot.id!==bound.id&&grownFrom(bound,snapshot);if(!snapshot||(snapshot.id!==bound.id&&!older)||(snapshot.units.length?indexedPrefix(snapshot)===0:!snapshot.complete)){const failure=this.error??this.healReason;return failure&&!CHURN.test(failure)?unavailable():incomplete();}
|
|
362
373
|
if(!snapshot.units.length)return{status:'no_matches',notice:'No eligible original source units in this snapshot.',coverage:this.status(),method:'embedding',groups:[]};
|
|
363
|
-
try{const worker=await this.runtime(snapshot.project);this.
|
|
364
|
-
if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.
|
|
374
|
+
try{const worker=await this.runtime(snapshot.project);this.recheckGrown(provider,snapshot,bound.id);const encoded=await worker.call('embed',{texts:[query],kind:'query'},signal);this.operations.queryEmbeddings++;this.recheckGrown(provider,snapshot,bound.id);const prefix=indexedPrefix(snapshot),stored=snapshot.units.slice(0,prefix),byKey=new Map(stored.map(u=>[u.key,u])),covered=windowCoverage(snapshot,prefix)!;let ranked:{unit:Unit;score:number}[]=[],observed=0;for(let offset=0;offset<stored.length;offset+=SCORE_CHUNK){this.recheckGrown(provider,snapshot,bound.id);const scored=await this.index('score',snapshot,{keys:stored.slice(offset,offset+SCORE_CHUNK).map(u=>u.key),query:encoded.vectors[0]},bound.id) as {ranked:{key:string;score:number}[];observed:number};observed+=scored.observed;ranked.push(...scored.ranked.map(row=>({unit:byKey.get(row.key)!,score:row.score})));ranked.sort((a,b)=>b.score-a.score);ranked=ranked.slice(0,24);if(signal?.aborted)throw abort();await yieldWork();}if(observed!==stored.length)throw new Error('Snapshot index coverage changed');
|
|
375
|
+
if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.recheckGrown(provider,snapshot,bound.id);const final=provider();const finalSources=new Map(final.sources.map(source=>[source.entry.id,source.sha]));if(snapshot.sources.some(source=>finalSources.get(source.entry.id)!==source.sha))throw new Error('Eligible source changed or access revoked before return');
|
|
365
376
|
// What the caller already read only spares it a repeat. A reference that does not match this history spares nothing, and it must not fail the search.
|
|
366
|
-
const usable=prior.filter(reference=>{const source=snapshot.sources.find(s=>s.entry.id===reference.entryId);return Number.isSafeInteger(reference.start)&&Number.isSafeInteger(reference.end)&&reference.start<=reference.end&&source!==undefined&&reference.sourceSha===source.sha&&reference.end<=source.sourceText.length;}),ignored=prior.length-usable.length;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({entryId:hit.entryId,start:hit.read.offset,end:hit.read.offset+hit.read.limit,sourceSha:snapshot.sources.find(s=>s.entry.id===hit.entryId)?.sha}))];return{status:'ok',notice:'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.'+(ignored?` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.`:'')+` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime??covered.oldestCoveredTime??'an unknown time'}); use tuck_recall for the rest
|
|
377
|
+
const usable=prior.filter(reference=>{const source=snapshot.sources.find(s=>s.entry.id===reference.entryId);return Number.isSafeInteger(reference.start)&&Number.isSafeInteger(reference.end)&&reference.start<=reference.end&&source!==undefined&&reference.sourceSha===source.sha&&reference.end<=source.sourceText.length;}),ignored=prior.length-usable.length;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({entryId:hit.entryId,start:hit.read.offset,end:hit.read.offset+hit.read.limit,sourceSha:snapshot.sources.find(s=>s.entry.id===hit.entryId)?.sha}))];return{status:'ok',notice:'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.'+(ignored?` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.`:'')+` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime??covered.oldestCoveredTime??'an unknown time'}); use tuck_recall for the rest.`+(older?` This ranking covers the history through entry ${snapshot.throughEntryId}: later entries are still being indexed, so search them by keyword.`:''),coverage:{...this.status(),window:covered},method:'embedding',groups:this.assemble(ranked.map(hit=>hit.unit),snapshot,previous)};
|
|
367
378
|
}catch(error){if(error instanceof Error&&error.name==='SemanticAccessError'){await this.revoked(error);throw error;}if(signal?.aborted||queryGeneration!==this.generation||error instanceof Error&&error.name==='AbortError'){await this.cancelled();throw abort();}this.heal(error);return{status:'unavailable',notice:'Local semantic search failed; ordinary recall remains available. '+(error instanceof Error?error.message:'failure'),coverage:this.status(),groups:[]};}
|
|
368
379
|
}
|
|
369
380
|
private assemble(units:Unit[],snapshot:Snapshot,previous:PriorResult[]):SemanticGroup[]{
|
|
@@ -38,8 +38,8 @@ const startBudget = (): (() => void) => {
|
|
|
38
38
|
return () => {
|
|
39
39
|
if (Date.now() > deadline) {
|
|
40
40
|
throw new Error(
|
|
41
|
-
`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms
|
|
42
|
-
"
|
|
41
|
+
`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms on this history. ` +
|
|
42
|
+
"Use more specific words, or read an entry by index.",
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
};
|
package/src/tuck.ts
CHANGED
|
@@ -52,14 +52,14 @@ import { prepareKeywordQuery } from "./tuck/core/keyword-query";
|
|
|
52
52
|
import { searchEntriesDetailed } from "./tuck/core/search-entries";
|
|
53
53
|
export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
|
|
54
54
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
55
|
-
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
|
|
56
|
-
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
55
|
+
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false, protectedPromptEntryId?: string) {
|
|
56
|
+
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt, protectedPromptEntryId }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
57
57
|
}
|
|
58
58
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
59
59
|
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
60
60
|
* a run a custom message starts); the session has moved on from an ended run.
|
|
61
61
|
* `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
|
|
62
|
-
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean }, ctx: ExtensionContext,
|
|
62
|
+
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean; protectedPromptEntryId?: string }, ctx: ExtensionContext,
|
|
63
63
|
reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
|
|
64
64
|
const { isHistorySource, readable } = createRecall(policy);
|
|
65
65
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -143,6 +143,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
143
143
|
const current = entries[userPosition];
|
|
144
144
|
const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
|
|
145
145
|
const textOnly = isTextOnly(userContent);
|
|
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");
|
|
148
|
+
const starting = entries[startingPosition];
|
|
146
149
|
// Anchor the actual first source only; a native omission never promotes a later prompt.
|
|
147
150
|
const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
|
|
148
151
|
const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
|
|
@@ -280,7 +283,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
280
283
|
if (index !== undefined && quoted.records.length) { selected.add(index); if (quoted.whole) whole.add(index); }
|
|
281
284
|
};
|
|
282
285
|
if (userPosition >= 0 && userPosition < boundary && textOnly) add(current!, Infinity);
|
|
283
|
-
|
|
286
|
+
if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id) add(starting!, Infinity);
|
|
287
|
+
for (const entry of anchors) if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id)! < boundary) add(entry, length);
|
|
284
288
|
const omissions: TranscriptRecord[] = [];
|
|
285
289
|
for (const role of ["user", "assistant", "toolResult"] as const) {
|
|
286
290
|
const list = units[role], prefix = prefixes[role];
|