pi-tuck 1.0.22 → 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 CHANGED
@@ -3,6 +3,45 @@
3
3
  All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.0.23] - 2026-11-10
7
+
8
+ ### Fixed
9
+
10
+ - **Near the new refusal line, replies were cut to a few hundred tokens (1.0.22).** Pi sets each request's reply cap to the window less its own
11
+ estimate of the request less a 4,096-token safety margin. 1.0.22 left only its reply floor and did not count that margin. pi-tuck now:
12
+ - reads Pi's margin from Pi itself;
13
+ - sends a request above the room only if Pi's own cap still leaves the reply floor;
14
+ - never draws the line below the room, where 1.0.21 also sends.
15
+
16
+ The floor is the larger of the branch's largest reply and 3,584 tokens, capped at the model's own reply cap. After a reply stopped for length or
17
+ refused for context overflow, it is the reserve.
18
+ - **`tuck_search` said semantic recall was still indexing when it was fully indexed.** After the session grew, the next index plan could still be
19
+ preparing when a search ran. The search now ranks the plan its history grew from, as long as nothing in it changed, and says which entries are
20
+ still being indexed.
21
+ - **`tuck_search` in the turn after a prompt's cut ranked nothing ("nothing is out of view").** The cut reaches the requests at once but the session
22
+ only at the turn's end. Search now uses the pending cut.
23
+ - **A keyword-search timeout on a very long history failed the whole `tuck_search`.** The keyword half now reports `keyword_timeout`, and semantic
24
+ results still answer. The timeout message names the history's size, not a pattern.
25
+
26
+ ### Added
27
+
28
+ - The settings stress test builds every request through pi-ai's real request path, so Pi's own reply cap is tested. A large-session stress test
29
+ searches an ~85 MB session that grows while the next index plan prepares.
30
+
31
+ ### Release gates
32
+
33
+ - **Shipped without a live gate, because its replay matches 1.0.22:** 13,946 requests on 29 recordings, read per request. One recording's 13
34
+ differing requests matched exactly in two more runs of each build. 0 lost and 0 dead tasks.
35
+ - **Stress on Pi 1.2.0 and 1.5.0, 310 cells, through pi-ai's request path:**
36
+ - no cell answers fewer than 1.0.21;
37
+ - replies cut off below the floor 11 → 0;
38
+ - native-parity findings all inside its four exceptions;
39
+ - the 85 MB test passes.
40
+
41
+ Tests (712) and CI.
42
+ - **The 1.0.22 floor patch's Tier 1 gate runs on this version instead,** because its pilot found the reply-cap defect fixed here.
43
+ - Sessions that are open keep the build they started with until they are restarted.
44
+
6
45
  ## [1.0.22] - 2026-11-10
7
46
 
8
47
  ### Fixed
package/README.md CHANGED
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
64
64
 
65
65
  - Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
66
66
  - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
- - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.21`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.21`.
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.22`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.22`.
69
69
 
70
70
  ## Uninstall
71
71
 
@@ -125,11 +125,13 @@ The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.m
125
125
  Known limits today:
126
126
 
127
127
  - pi-tuck's summary does not explain why things happened. It holds the words that were said, not a model's reading of them. The agent must look things up. Each look-up costs a tool call.
128
- - Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space. pi-tuck then refuses to cut rather than overflow. This made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Version 1.0.4 added a notice that says when the provider counts far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts; 1.0.5 fixed both. Version 1.0.8 clips a fresh tool result that is too large for the room (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. If a small window still refuses, pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
128
+ - Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space. pi-tuck then refuses to cut rather than overflow. This made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Version 1.0.4 added a notice that says when the provider counts far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts; 1.0.5 fixed both. Version 1.0.8 clips a fresh tool result that is too large for the room (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. If a small window still refuses, pin a different upstream or use a larger window; see [Small windows](#small-windows).
129
129
  - Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which was the window less the larger of `compaction.reserveTokens` and the model's maximum output (16,384 at a 32k window with the default reserve). After a run that is stopped (Esc, or a time limit) with the context above the room, every later prompt could be refused with "The request needs a cut and Pi has none to make (Nothing to compact (session too small))", and the session stayed stuck. In one check at these defaults (pi-tuck 1.0.8, 32k window, one session of 30 tasks, one local model), 28 tasks were lost, tasks 3 to 30 ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)). If you are on 1.0.8 or 1.0.9, upgrade to 1.0.10. In 1.0.10 the cut a prompt needs is pi-tuck's own, so `keepRecentTokens` no longer leaves it with nothing to cut. A rerun of the same check on the 1.0.10 candidate had no refusal and no stuck session. It had two caveats: two of its tasks had a first request the local server never answered, and 8 of its 30 tasks began without their prompt, which the refusal counter cannot see (fixed in 1.0.12; see Benchmarks and [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Windows of 128k and up were not affected at the defaults.
130
130
  - A prompt could be lost, with no notice (1.0.10 and 1.0.11; fixed in 1.0.12). When a new prompt arrived over the cut point and pi-tuck's cut kept nothing of the conversation before it, which happens when everything since the last cut is one long turn, the prompt was dropped from the request and from the saved session. The model never saw it and usually went on with the previous task, so work you asked for could be lost, and could look answered. The next prompt ran normally. If you used 1.0.10 or 1.0.11 and a reply ignored what you had just asked, this is the likely cause. Upgrade to 1.0.12 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
131
131
  - A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
132
132
  - Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
133
+ - pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21; fixed in 1.0.22). It refused at the room, the window less the full reply reserve, where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal now sits at the window less a reply floor ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
134
+ - Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
133
135
  - pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve (1.0.0 to 1.0.19; fixed in 1.0.20). It reserved the larger of Pi's `compaction.reserveTokens` and the model's `maxTokens`: with `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi compacts at 65,536 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.20).
134
136
  - A summary could drop the task a session was working on (1.0.0 to 1.0.17; fixed in 1.0.18). Until 1.0.15 a summary quoted only the session's first user turn, up to 12,000 characters, and the newest one, so in a long session the current task, an older turn followed by short steps, was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 1.0.16 added more quotes. From 1.0.13 to 1.0.17, separate rules decided what a summary quoted and what the kept tail held, and the prompt that set the current task, or the first prompt, could lose its opening after a cut: the stress test found it 40 times in 32 cells on 1.0.16 and 1.0.17. Since 1.0.18 one ranked list fills a cut's room, and the stress test finds no lost opening in 287 cells. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.18 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
135
137
  - pi-tuck 1.0.16 cost more in live chains than its notes first said. Against 1.0.13 at an 81,920 window, it took 1.48 times the turns on the tasks both resolved (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with 56 of 60 tasks resolved against 55. Replay of single-task sessions cannot show this cost. 1.0.18 replaced its quote rules; in the 1.0.18 gate it took 22.27 turns per task against 29.35 for 1.0.16 and 20.22 for the 1.0.13 arm (different pairs of runs; reported, not claimed) ([`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md), [`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
@@ -188,13 +190,9 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
188
190
 
189
191
  pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
190
192
 
191
- pi-tuck refuses a request that cannot fit, rather than let the provider cut it off. It estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. pi-tuck then refuses to cut rather than overflow. Lower the reserve for that model in Pi's `settings.json`, or use a larger window. For example, to lower it to 8,192:
193
+ Since 1.0.22, pi-tuck refuses a request only at the window less a reply floor. The floor is the larger of the biggest reply seen on the branch and 4,096 tokens, capped at the reserve. After a reply that stopped for length or was refused as a context overflow, the floor is the full reserve. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
192
194
 
193
- ```json
194
- { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
195
- ```
196
-
197
- Lowering `reserveTokens` takes effect even when `models.json` sets a larger `maxTokens` for that model, until a reply on the branch does not fit. After that, the larger of the two applies for the rest of the branch, so lower both if needed, and keep `maxTokens` above your largest expected reply.
195
+ pi-tuck estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. If a small window refuses, use a larger window, or pin one upstream as below.
198
196
 
199
197
  If you reach the model through OpenRouter, you can also pin one upstream, so that every request is served by the same one. Put this in `models.json` in Pi's agent folder, and name the upstream you want:
200
198
 
@@ -1 +1 @@
1
- export declare const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
1
+ export declare const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
@@ -1 +1 @@
1
- export const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
1
+ export const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
@@ -50,7 +50,7 @@ export interface CompactionOptions {
50
50
  * owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
51
51
  cutAt?: number;
52
52
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
53
- * Standalone cuts against Pi's reserveTokens, then admits up to the window less max(8192, largest branch output), capped at that reserve.
53
+ * 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.
54
54
  * After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
55
55
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
56
56
  requestReserve?: (ctx: ExtensionContext) => {
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { branchReplyFloor, carriesPrompt } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
@@ -10,7 +10,7 @@ import { assertNativeToolRounds } from "./protocol.js";
10
10
  import { projectedMessages } from "./omissions.js";
11
11
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
13
- import { getCurrentSystemMessage, isContextOverflow } from "@earendil-works/pi-ai";
13
+ import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
14
14
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
15
15
  import { Type } from "typebox";
16
16
  import { Check } from "typebox/value";
@@ -104,6 +104,9 @@ export function seedWindow(branch, key) {
104
104
  return initialWindow(key);
105
105
  }
106
106
  export async function installCompaction(pi, options = {}) {
107
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
108
+ const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
109
+ .then(module => module.clampMaxTokensToContext).catch(() => undefined);
107
110
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
108
111
  if (!validCutAt(cutAt))
109
112
  throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
@@ -118,7 +121,8 @@ export async function installCompaction(pi, options = {}) {
118
121
  const semanticPending = new Set();
119
122
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
120
123
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
121
- const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
124
+ // 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.
125
+ const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, promptCut ? promptCut.firstKeptEntryId || ctx.sessionManager.getBranch().findLast(e => e.type === "message" && e.message.role === "user")?.id : undefined);
122
126
  const recorder = new IncidentRecorder(options.diagnostics);
123
127
  let proposalId;
124
128
  const observe = (ctx) => recorder.measureInline(() => {
@@ -325,9 +329,9 @@ export async function installCompaction(pi, options = {}) {
325
329
  // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
326
330
  const limits = (ctx) => {
327
331
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
328
- const floor = options.requestReserve ? response : branchReplyFloor(ctx.sessionManager.getBranch(), response);
329
- const hardLine = Math.min((ctx.model?.contextWindow ?? 0) - floor, lastCeiling ?? Infinity);
330
- return { response, room, hardLine };
332
+ const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
333
+ const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
334
+ return { response, room, hardLine, replyFloor: floor };
331
335
  };
332
336
  const decisionBand = (ctx, room, system) => {
333
337
  const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
@@ -477,7 +481,18 @@ export async function installCompaction(pi, options = {}) {
477
481
  // Bind the actual independent lexical offer, including attribution derived
478
482
  // from custom markers and receipts excluded from semantic indexing. Rebuild
479
483
  // under current caller policy after await, when the session moved; never send this block to the encoder.
480
- const wordOffer = () => tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
484
+ // 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
485
+ // 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).
486
+ const wordOffer = () => {
487
+ try {
488
+ return tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
489
+ }
490
+ catch (error) {
491
+ if (error instanceof Error && error.message.startsWith("Search aborted:"))
492
+ 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: [] };
493
+ throw error;
494
+ }
495
+ };
481
496
  // 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.
482
497
  // 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.
483
498
  const provider = semanticProvider(ctx), initialSnapshot = provider(), initialKey = eligibilityKey(ctx), words = wordOffer(), wordText = JSON.stringify(words);
@@ -977,11 +992,16 @@ export async function installCompaction(pi, options = {}) {
977
992
  const tokens = system + conversationTokens(messages);
978
993
  observeBudget(tokens, ctx);
979
994
  estimatedTokens = corrected(tokens, ctx);
980
- const { room, hardLine } = limits(ctx);
995
+ const { room, hardLine, replyFloor } = limits(ctx);
981
996
  if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
982
997
  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");
983
998
  if (!fits(tokens, hardLine, ctx))
984
999
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
1000
+ // Pi's request estimator and its compaction estimator need not agree. The hard line is
1001
+ // necessary; above the room, also require the real request clamp to preserve the floor.
1002
+ if (!options.requestReserve && estimatedTokens > room && piClamp &&
1003
+ piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
1004
+ throw new CapacityError("Pi's request clamp leaves less than the reply floor");
985
1005
  if (estimatedTokens > room)
986
1006
  receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
987
1007
  if (recorder.enabled && !recorder.waitingForCut)
@@ -1,6 +1,6 @@
1
1
  import { type RecallPolicy } from './recall.js';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
- export declare const DIAGNOSTIC_BUILD = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
3
+ export declare const DIAGNOSTIC_BUILD = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -15,5 +15,5 @@ export declare const eligibilityKeyMoved: (ctx: Context, since: readonly unknown
15
15
  * the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
16
16
  * The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
17
17
  * `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
18
- export declare function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void): Snapshot;
18
+ export declare function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void, pendingKeptId?: string): Snapshot;
19
19
  export {};
@@ -16,18 +16,18 @@ export const eligibilityKeyMoved = (ctx, since) => !same(since, eligibilityKey(c
16
16
  * the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
17
17
  * The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
18
18
  * `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
19
- export function memoizedEligibleSources(ctx, policy, access) {
19
+ export function memoizedEligibleSources(ctx, policy, access, pendingKeptId) {
20
20
  try {
21
21
  access();
22
22
  }
23
23
  catch (error) {
24
24
  throw Object.assign(new Error(error instanceof Error ? error.message : 'Source access denied'), { name: 'SemanticAccessError' });
25
25
  }
26
- const key = eligibilityKey(ctx), held = cache.get(ctx.sessionManager);
26
+ const key = [...eligibilityKey(ctx), pendingKeptId ?? null], held = cache.get(ctx.sessionManager);
27
27
  if (held && held.policy === policy && same(held.key, key))
28
28
  return { ...held.stored, units: [], complete: false, indexed: new Set() };
29
29
  // Access was checked on the line above with nothing awaited in between, so the rebuild does not check it a second time.
30
- const built = semanticEligibleSources(ctx, policy ?? {}, () => { });
30
+ const built = semanticEligibleSources(ctx, policy ?? {}, () => { }, pendingKeptId);
31
31
  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 } });
32
32
  return built;
33
33
  }
@@ -1,6 +1,14 @@
1
1
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
2
2
  import type { Messages } from './budget.js';
3
+ /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
+ * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
+ */
6
+ export declare function piContextSafetyMargin(): Promise<number>;
7
+ /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
8
+ export declare const REPLY_FLOOR_MIN = 3584;
3
9
  /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
4
10
  export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
11
+ /** Admission never promises more output than a model with a positive reply cap can produce. */
12
+ export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
5
13
  /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
6
14
  export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
@@ -1,8 +1,28 @@
1
- import { isContextOverflow } from '@earendil-works/pi-ai';
1
+ import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
2
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
3
+ /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
+ * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
+ */
6
+ export async function piContextSafetyMargin() {
7
+ try {
8
+ const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
9
+ import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
10
+ ]);
11
+ const window = 1_000_000, context = normalizeContext({ messages: [] });
12
+ const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window }, context, window);
13
+ if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window)
14
+ throw new Error('Invalid Pi context margin');
15
+ return margin;
16
+ }
17
+ catch {
18
+ 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;
3
23
  /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
4
24
  export function branchReplyFloor(branch, reserve) {
5
- let largest = 4_096;
25
+ let largest = REPLY_FLOOR_MIN;
6
26
  for (const entry of branch) {
7
27
  if (entry.type !== 'message' || entry.message.role !== 'assistant')
8
28
  continue;
@@ -15,6 +35,11 @@ export function branchReplyFloor(branch, reserve) {
15
35
  }
16
36
  return Math.min(reserve, Math.ceil(largest));
17
37
  }
38
+ /** Admission never promises more output than a model with a positive reply cap can produce. */
39
+ export function requestReplyFloor(branch, reserve, maxTokens) {
40
+ const floor = branchReplyFloor(branch, reserve);
41
+ return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
42
+ }
18
43
  /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
19
44
  export function carriesPrompt(messages, prompt) {
20
45
  if (!prompt.length)
@@ -129,7 +129,9 @@ export declare function healDelayMs(attempts: number, integrity: boolean, delays
129
129
  export type SemanticState = 'disabled' | 'unavailable' | 'preparing' | 'ready' | 'waiting';
130
130
  /** Where the current view starts, as a branch position: the latest compaction entry's first kept entry. Earlier entries were compacted away. With no compaction, nothing is out of view. */
131
131
  export declare function viewStart(branch: SessionEntry[]): number;
132
- export declare function semanticEligibleSources(ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>, policy: RecallPolicy, access: () => void): Snapshot;
132
+ /** `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
133
+ * the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
134
+ export declare function semanticEligibleSources(ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>, policy: RecallPolicy, access: () => void, pendingKeptId?: string): Snapshot;
133
135
  export declare class SemanticRecallController {
134
136
  private readonly timing;
135
137
  private readonly ownerId;
@@ -294,6 +296,9 @@ export declare class SemanticRecallController {
294
296
  /** 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.
295
297
  * 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. */
296
298
  private readyFor;
299
+ /** 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
300
+ * moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
301
+ private recheckGrown;
297
302
  private recheck;
298
303
  private joinPreparation;
299
304
  search(query: string, provider: () => Snapshot, signal?: AbortSignal, prior?: PriorResult[], words?: any): Promise<SemanticPage>;
@@ -427,6 +427,15 @@ export function viewStart(branch) {
427
427
  }
428
428
  const TOOL_OUTPUT = new Set(['toolResult', 'bashExecution']);
429
429
  const isToolOutput = (source) => TOOL_OUTPUT.has(source.row.role);
430
+ /** Whether `now` only grew from `plan`: the same session and project, and every source the plan ranks still eligible with the same hash. */
431
+ function grownFrom(now, plan) {
432
+ if (now.session !== plan.session || now.project !== plan.project)
433
+ return false;
434
+ if (now.id === plan.id)
435
+ return true;
436
+ const shas = new Map(now.sources.map(source => [source.entry.id, source.sha]));
437
+ return plan.sources.every(source => shas.get(source.entry.id) === source.sha);
438
+ }
430
439
  /** 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.
431
440
  * Each part is newest first, extending backward. */
432
441
  function windowOrder(snapshot) {
@@ -464,7 +473,9 @@ function windowCoverage(snapshot, prefix = indexedPrefix(snapshot)) {
464
473
  return { indexedCharacters, windowCharacters: window.windowCharacters, outOfViewCharacters: window.conversationOutOfView + window.toolOutOfView, eligibleCharacters: window.eligibleCharacters, oldestCoveredEntryId: oldest?.entry.id ?? null, oldestCoveredTime: time(oldest),
465
474
  conversation: { indexedCharacters: conversation, outOfViewCharacters: window.conversationOutOfView }, toolResults: { indexedCharacters: tools, outOfViewCharacters: window.toolOutOfView, oldestCoveredTime: time(oldestTool) } };
466
475
  }
467
- export function semanticEligibleSources(ctx, policy, access) {
476
+ /** `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
477
+ * the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
478
+ export function semanticEligibleSources(ctx, policy, access, pendingKeptId) {
468
479
  try {
469
480
  access();
470
481
  }
@@ -486,8 +497,9 @@ export function semanticEligibleSources(ctx, policy, access) {
486
497
  const orders = new Map(branch.map((entry, order) => [entry.id, order]));
487
498
  const sources = eligible.flatMap(entry => { const order = orders.get(entry.id); const row = recall.discoveryEntry(entry, attempts, links); if (!row)
488
499
  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) }) }]; });
489
- 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]) });
490
- return { id, session, project: ctx.cwd, sources, units: [], complete: false, throughEntryId: sources.at(-1)?.entry.id ?? null, viewStart: viewStart(branch), indexed: new Set() };
500
+ const pending = pendingKeptId === undefined ? -1 : branch.findIndex(e => e.id === pendingKeptId), start = Math.max(viewStart(branch), pending);
501
+ 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 } : {}) });
502
+ return { id, session, project: ctx.cwd, sources, units: [], complete: false, throughEntryId: sources.at(-1)?.entry.id ?? null, viewStart: start, indexed: new Set() };
491
503
  }
492
504
  export class SemanticRecallController {
493
505
  timing;
@@ -647,11 +659,19 @@ export class SemanticRecallController {
647
659
  return this.worker;
648
660
  }
649
661
  path(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'); }
650
- async index(action, snapshot, extra = {}) { const path = this.path(snapshot), generation = this.generation, worker = await this.runtime(snapshot.project); if (!this.enabled || generation !== this.generation)
651
- throw abort(); if (this.provider)
652
- 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)
653
- throw abort(); if (this.provider)
654
- this.recheck(this.provider, snapshot); return result; }
662
+ async index(action, snapshot, extra = {}, boundId) { const path = this.path(snapshot), generation = this.generation, worker = await this.runtime(snapshot.project); if (!this.enabled || generation !== this.generation)
663
+ throw abort(); if (this.provider) {
664
+ if (boundId !== undefined)
665
+ this.recheckGrown(this.provider, snapshot, boundId);
666
+ else
667
+ this.recheck(this.provider, snapshot);
668
+ } /* a search's scoring may read a plan its snapshot grew from */ /* 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)
669
+ throw abort(); if (this.provider) {
670
+ if (boundId !== undefined)
671
+ this.recheckGrown(this.provider, snapshot, boundId);
672
+ else
673
+ this.recheck(this.provider, snapshot);
674
+ } return result; }
655
675
  async prepare(provider, full = true) {
656
676
  if (!this.enabled)
657
677
  return;
@@ -801,6 +821,10 @@ export class SemanticRecallController {
801
821
  /** 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.
802
822
  * 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. */
803
823
  readyFor(id, forSearch) { 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))))); }
824
+ /** 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
825
+ * moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
826
+ recheckGrown(provider, snapshot, boundId) { const now = provider(); if (now.id !== boundId || !grownFrom(now, snapshot))
827
+ throw new Error('Eligible branch or source changed before semantic admission'); }
804
828
  recheck(provider, snapshot) { const now = provider(); if (now.id !== snapshot.id || now.session !== snapshot.session || now.project !== snapshot.project)
805
829
  throw new Error('Eligible branch or source changed before semantic admission'); }
806
830
  async joinPreparation(deadline, signal) {
@@ -868,22 +892,25 @@ export class SemanticRecallController {
868
892
  const prepared = !this.task || await this.joinPreparation(deadline, signal);
869
893
  if (signal?.aborted || queryGeneration !== this.generation)
870
894
  throw abort();
871
- if (!prepared || performance.now() >= deadline)
872
- return incomplete();
873
- this.error = null;
874
- this.schedule(provider);
895
+ if (prepared && performance.now() < deadline) {
896
+ this.error = null;
897
+ this.schedule(provider);
898
+ await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
899
+ }
875
900
  }
876
- else if (!this.current.complete) {
877
- this.error = null;
878
- this.schedule(provider);
901
+ else {
902
+ if (!this.current.complete) {
903
+ this.error = null;
904
+ this.schedule(provider);
905
+ }
906
+ await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
879
907
  }
880
- await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
881
908
  if (signal?.aborted || queryGeneration !== this.generation)
882
909
  throw abort();
883
910
  }
884
911
  // 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`.
885
- const snapshot = this.current;
886
- if (!snapshot || snapshot.id !== bound.id || (snapshot.units.length ? indexedPrefix(snapshot) === 0 : !snapshot.complete)) {
912
+ const snapshot = this.current, older = snapshot !== undefined && snapshot.id !== bound.id && grownFrom(bound, snapshot);
913
+ if (!snapshot || (snapshot.id !== bound.id && !older) || (snapshot.units.length ? indexedPrefix(snapshot) === 0 : !snapshot.complete)) {
887
914
  const failure = this.error ?? this.healReason;
888
915
  return failure && !CHURN.test(failure) ? unavailable() : incomplete();
889
916
  }
@@ -891,15 +918,15 @@ export class SemanticRecallController {
891
918
  return { status: 'no_matches', notice: 'No eligible original source units in this snapshot.', coverage: this.status(), method: 'embedding', groups: [] };
892
919
  try {
893
920
  const worker = await this.runtime(snapshot.project);
894
- this.recheck(provider, snapshot);
921
+ this.recheckGrown(provider, snapshot, bound.id);
895
922
  const encoded = await worker.call('embed', { texts: [query], kind: 'query' }, signal);
896
923
  this.operations.queryEmbeddings++;
897
- this.recheck(provider, snapshot);
924
+ this.recheckGrown(provider, snapshot, bound.id);
898
925
  const prefix = indexedPrefix(snapshot), stored = snapshot.units.slice(0, prefix), byKey = new Map(stored.map(u => [u.key, u])), covered = windowCoverage(snapshot, prefix);
899
926
  let ranked = [], observed = 0;
900
927
  for (let offset = 0; offset < stored.length; offset += SCORE_CHUNK) {
901
- this.recheck(provider, snapshot);
902
- const scored = await this.index('score', snapshot, { keys: stored.slice(offset, offset + SCORE_CHUNK).map(u => u.key), query: encoded.vectors[0] });
928
+ this.recheckGrown(provider, snapshot, bound.id);
929
+ const scored = await this.index('score', snapshot, { keys: stored.slice(offset, offset + SCORE_CHUNK).map(u => u.key), query: encoded.vectors[0] }, bound.id);
903
930
  observed += scored.observed;
904
931
  ranked.push(...scored.ranked.map(row => ({ unit: byKey.get(row.key), score: row.score })));
905
932
  ranked.sort((a, b) => b.score - a.score);
@@ -912,7 +939,7 @@ export class SemanticRecallController {
912
939
  throw new Error('Snapshot index coverage changed');
913
940
  if (signal?.aborted || queryGeneration !== this.generation || !this.enabled)
914
941
  throw abort();
915
- this.recheck(provider, snapshot);
942
+ this.recheckGrown(provider, snapshot, bound.id);
916
943
  const final = provider();
917
944
  const finalSources = new Map(final.sources.map(source => [source.entry.id, source.sha]));
918
945
  if (snapshot.sources.some(source => finalSources.get(source.entry.id) !== source.sha))
@@ -920,7 +947,7 @@ export class SemanticRecallController {
920
947
  // 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.
921
948
  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;
922
949
  const previous = [...usable, ...(words?.hits ?? []).map((hit) => ({ 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 }))];
923
- 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.`, coverage: { ...this.status(), window: covered }, method: 'embedding', groups: this.assemble(ranked.map(hit => hit.unit), snapshot, previous) };
950
+ 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) };
924
951
  }
925
952
  catch (error) {
926
953
  if (error instanceof Error && error.name === 'SemanticAccessError') {
@@ -16,8 +16,8 @@ const startBudget = () => {
16
16
  const deadline = Date.now() + SEARCH_BUDGET_MS;
17
17
  return () => {
18
18
  if (Date.now() > deadline) {
19
- throw new Error(`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms. Simplify the pattern — ` +
20
- "nested quantifiers such as (a+)+ can make matching blow up.");
19
+ throw new Error(`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms on this history. ` +
20
+ "Use more specific words, or read an entry by index.");
21
21
  }
22
22
  };
23
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
1
+ export const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
package/src/compaction.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { branchReplyFloor, carriesPrompt } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
@@ -11,7 +11,7 @@ import { projectedMessages } from "./omissions.js";
11
11
  import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
13
13
  type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
14
- 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";
15
15
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
17
  import { Check } from "typebox/value";
@@ -115,7 +115,7 @@ export interface CompactionOptions {
115
115
  * owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
116
116
  cutAt?: number;
117
117
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
118
- * Standalone cuts against Pi's reserveTokens, then admits up to the window less max(8192, largest branch output), capped at that reserve.
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
119
  * After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
120
120
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
121
121
  requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
@@ -133,6 +133,9 @@ export interface CompactionOptions {
133
133
  }
134
134
 
135
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);
136
139
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
137
140
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
138
141
  const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
@@ -145,7 +148,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
145
148
  const semanticPending = new Set<Promise<unknown>>();
146
149
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
147
150
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
148
- const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
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);
149
153
  const recorder = new IncidentRecorder(options.diagnostics);
150
154
  let proposalId: string | undefined;
151
155
  const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
@@ -305,9 +309,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
305
309
  // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
306
310
  const limits = (ctx: ExtensionContext) => {
307
311
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
308
- const floor = options.requestReserve ? response : branchReplyFloor(ctx.sessionManager.getBranch(), response);
309
- const hardLine = Math.min((ctx.model?.contextWindow ?? 0) - floor, lastCeiling ?? Infinity);
310
- return { response, room, hardLine };
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 };
311
315
  };
312
316
  const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
313
317
  const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
@@ -432,7 +436,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
432
436
  // Bind the actual independent lexical offer, including attribution derived
433
437
  // from custom markers and receipts excluded from semantic indexing. Rebuild
434
438
  // under current caller policy after await, when the session moved; never send this block to the encoder.
435
- const wordOffer=()=>tuckRecall(tuck,ctx.sessionManager.getEntries(),ctx.sessionManager.getBranch(),{query:input.query},options.recallPolicy);
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;}};
436
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.
437
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.
438
445
  const provider=semanticProvider(ctx),initialSnapshot=provider(),initialKey=eligibilityKey(ctx),words=wordOffer(),wordText=JSON.stringify(words);
@@ -849,11 +856,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
849
856
  const tokens = system + conversationTokens(messages);
850
857
  observeBudget(tokens, ctx);
851
858
  estimatedTokens = corrected(tokens, ctx);
852
- const { room, hardLine } = limits(ctx);
859
+ const { room, hardLine, replyFloor } = limits(ctx);
853
860
  if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
854
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");
855
862
  if (!fits(tokens, hardLine, ctx))
856
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");
857
869
  if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
858
870
  if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
859
871
  recorder.request();
@@ -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
  }
@@ -1,11 +1,29 @@
1
- import { isContextOverflow } from '@earendil-works/pi-ai';
1
+ import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
3
  import type { Messages } from './budget.js';
4
4
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
5
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
+
6
24
  /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
7
25
  export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
8
- let largest = 4_096;
26
+ let largest = REPLY_FLOOR_MIN;
9
27
  for (const entry of branch) {
10
28
  if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
11
29
  const message = entry.message;
@@ -16,6 +34,12 @@ export function branchReplyFloor(branch: readonly SessionEntry[], reserve: numbe
16
34
  return Math.min(reserve, Math.ceil(largest));
17
35
  }
18
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
+
19
43
  /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
20
44
  export function carriesPrompt(messages: Messages, prompt: string): boolean {
21
45
  if (!prompt.length) return true;
@@ -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
- export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManager'|'cwd'>,policy:RecallPolicy,access:()=>void):Snapshot {
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 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])});return{id,session,project:ctx.cwd,sources,units:[],complete:false,throughEntryId:sources.at(-1)?.entry.id??null,viewStart:viewStart(branch),indexed:new Set()};
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(!prepared||performance.now()>=deadline)return incomplete();this.error=null;this.schedule(provider);}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();
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.recheck(provider,snapshot);const encoded=await worker.call('embed',{texts:[query],kind:'query'},signal);this.operations.queryEmbeddings++;this.recheck(provider,snapshot);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.recheck(provider,snapshot);const scored=await this.index('score',snapshot,{keys:stored.slice(offset,offset+SCORE_CHUNK).map(u=>u.key),query:encoded.vectors[0]}) 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');
364
- if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.recheck(provider,snapshot);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');
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.`,coverage:{...this.status(),window:covered},method:'embedding',groups:this.assemble(ranked.map(hit=>hit.unit),snapshot,previous)};
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. Simplify the pattern — ` +
42
- "nested quantifiers such as (a+)+ can make matching blow up.",
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
  };