pi-tuck 1.0.22 → 1.0.24
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 +61 -0
- package/README.md +9 -9
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +1 -1
- package/dist/src/compaction.js +34 -10
- 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 +8 -0
- package/dist/src/request-capacity.js +27 -2
- 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/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +27 -11
- package/src/eligibility-cache.ts +3 -3
- package/src/request-capacity.ts +26 -2
- package/src/semantic-recall.ts +19 -8
- package/src/tuck/core/search-entries.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,67 @@
|
|
|
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.24] - 2026-11-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A prompt's cut could reach a run it was not planned for.** pi-tuck plans a prompt's cut when the prompt arrives. When another extension
|
|
11
|
+
handled that prompt, no run started, and the plan waited for the next run:
|
|
12
|
+
- **A run started by a custom message** carried a cut sized for the prompt that never ran: deeper than needed.
|
|
13
|
+
- **After a move in the session tree (`/tree`),** the plan was applied on the new branch. Its kept entry was not on that branch, so the request
|
|
14
|
+
lost the custom message that started the run, even on a branch that needed no cut.
|
|
15
|
+
|
|
16
|
+
Now a run started by a custom message plans afresh, and a move in the tree drops any waiting plan.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Lifecycle tests: a handled prompt, then a custom-message run, and the same with a move in the tree first.
|
|
21
|
+
|
|
22
|
+
### Release gates
|
|
23
|
+
|
|
24
|
+
- **Shipped without a live gate, because its replay matches 1.0.23:** 13,946 requests on 29 recordings, read per request, 0 differ beyond the
|
|
25
|
+
base's own run-to-run noise; clips 17 → 17; 0 lost and 0 dead tasks.
|
|
26
|
+
- **Stress, 310 cells:** findings the same as 1.0.23, cell for cell. Tests (715) and CI.
|
|
27
|
+
|
|
28
|
+
## [1.0.23] - 2026-11-10
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **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
|
|
33
|
+
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:
|
|
34
|
+
- reads Pi's margin from Pi itself;
|
|
35
|
+
- sends a request above the room only if Pi's own cap still leaves the reply floor;
|
|
36
|
+
- never draws the line below the room, where 1.0.21 also sends.
|
|
37
|
+
|
|
38
|
+
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
|
|
39
|
+
refused for context overflow, it is the reserve.
|
|
40
|
+
- **`tuck_search` said semantic recall was still indexing when it was fully indexed.** After the session grew, the next index plan could still be
|
|
41
|
+
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
|
|
42
|
+
still being indexed.
|
|
43
|
+
- **`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
|
|
44
|
+
only at the turn's end. Search now uses the pending cut.
|
|
45
|
+
- **A keyword-search timeout on a very long history failed the whole `tuck_search`.** The keyword half now reports `keyword_timeout`, and semantic
|
|
46
|
+
results still answer. The timeout message names the history's size, not a pattern.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- 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
|
|
51
|
+
searches an ~85 MB session that grows while the next index plan prepares.
|
|
52
|
+
|
|
53
|
+
### Release gates
|
|
54
|
+
|
|
55
|
+
- **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
|
|
56
|
+
differing requests matched exactly in two more runs of each build. 0 lost and 0 dead tasks.
|
|
57
|
+
- **Stress on Pi 1.2.0 and 1.5.0, 310 cells, through pi-ai's request path:**
|
|
58
|
+
- no cell answers fewer than 1.0.21;
|
|
59
|
+
- replies cut off below the floor 11 → 0;
|
|
60
|
+
- native-parity findings all inside its four exceptions;
|
|
61
|
+
- the 85 MB test passes.
|
|
62
|
+
|
|
63
|
+
Tests (712) and CI.
|
|
64
|
+
- **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.
|
|
65
|
+
- Sessions that are open keep the build they started with until they are restarted.
|
|
66
|
+
|
|
6
67
|
## [1.0.22] - 2026-11-10
|
|
7
68
|
|
|
8
69
|
### Fixed
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
64
64
|
|
|
65
65
|
- Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
|
|
66
66
|
- On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
67
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
68
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
67
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.23`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.23`.
|
|
69
69
|
|
|
70
70
|
## Uninstall
|
|
71
71
|
|
|
@@ -125,11 +125,15 @@ 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
|
|
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
|
+
- Near the 1.0.22 refusal line, replies were cut to a few hundred tokens (1.0.22; fixed in 1.0.23). 1.0.22 left only its reply floor and did not count Pi's 4,096-token margin on the reply cap ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
|
|
135
|
+
- Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
|
|
136
|
+
- Four `tuck_search` faults, fixed in 1.0.23: it said semantic recall was still indexing when the history was fully indexed; in the turn after a prompt's cut it ranked nothing ("nothing is out of view"); a keyword-search timeout on a very long history failed the whole search, where now the semantic results still answer; and the timeout message named a pattern instead of the history's size ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
|
|
133
137
|
- 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
138
|
- 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
139
|
- 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 +192,9 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
|
|
|
188
192
|
|
|
189
193
|
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
194
|
|
|
191
|
-
pi-tuck refuses a request
|
|
195
|
+
Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.23 pi-tuck reads that margin from Pi. A request above the room goes out only if Pi's own cap still leaves the reply floor. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
|
|
192
196
|
|
|
193
|
-
|
|
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.
|
|
197
|
+
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
198
|
|
|
199
199
|
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
200
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "c3eefa3bb76e52635b1e8394a025d3ffd6a056fc5636ed9b35f89dfddc1a322a";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "c3eefa3bb76e52635b1e8394a025d3ffd6a056fc5636ed9b35f89dfddc1a322a";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -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(
|
|
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) => {
|
package/dist/src/compaction.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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);
|
|
@@ -587,7 +602,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
587
602
|
const syncHistoryTools = (ctx) => { if (active())
|
|
588
603
|
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
589
604
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
590
|
-
|
|
605
|
+
// A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
|
|
606
|
+
// may not be on this one.
|
|
607
|
+
pi.on("session_tree", (_event, ctx) => { promptCut = undefined; phase = "idle"; syncHistoryTools(ctx); });
|
|
591
608
|
pi.on("session_compact", (_event, ctx) => { if (!admittedRun)
|
|
592
609
|
syncHistoryTools(ctx); });
|
|
593
610
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -666,7 +683,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
666
683
|
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
667
684
|
};
|
|
668
685
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
669
|
-
|
|
686
|
+
// A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
|
|
687
|
+
// input, so no run started for it): plan afresh.
|
|
688
|
+
const prompted = phase === "decided" && event.messageType !== "custom";
|
|
670
689
|
phase = "starting";
|
|
671
690
|
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
672
691
|
try {
|
|
@@ -977,11 +996,16 @@ export async function installCompaction(pi, options = {}) {
|
|
|
977
996
|
const tokens = system + conversationTokens(messages);
|
|
978
997
|
observeBudget(tokens, ctx);
|
|
979
998
|
estimatedTokens = corrected(tokens, ctx);
|
|
980
|
-
const { room, hardLine } = limits(ctx);
|
|
999
|
+
const { room, hardLine, replyFloor } = limits(ctx);
|
|
981
1000
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
982
1001
|
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
1002
|
if (!fits(tokens, hardLine, ctx))
|
|
984
1003
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
1004
|
+
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
1005
|
+
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
1006
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
1007
|
+
piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
|
|
1008
|
+
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
985
1009
|
if (estimatedTokens > room)
|
|
986
1010
|
receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
987
1011
|
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 = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "c3eefa3bb76e52635b1e8394a025d3ffd6a056fc5636ed9b35f89dfddc1a322a";
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
490
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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 (
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
877
|
-
this.
|
|
878
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
20
|
-
"
|
|
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
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "c3eefa3bb76e52635b1e8394a025d3ffd6a056fc5636ed9b35f89dfddc1a322a";
|
package/src/compaction.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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);
|
|
@@ -520,7 +527,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
520
527
|
const historyToolsEnabled = (ctx: ExtensionContext) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
|
|
521
528
|
const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
522
529
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
523
|
-
|
|
530
|
+
// A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
|
|
531
|
+
// may not be on this one.
|
|
532
|
+
pi.on("session_tree", (_event, ctx) => { promptCut = undefined; phase = "idle"; syncHistoryTools(ctx); });
|
|
524
533
|
pi.on("session_compact", (_event, ctx) => { if (!admittedRun) syncHistoryTools(ctx); });
|
|
525
534
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
526
535
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -589,7 +598,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
589
598
|
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
590
599
|
};
|
|
591
600
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
592
|
-
|
|
601
|
+
// A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
|
|
602
|
+
// input, so no run started for it): plan afresh.
|
|
603
|
+
const prompted = phase === "decided" && event.messageType !== "custom";
|
|
593
604
|
phase = "starting";
|
|
594
605
|
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
595
606
|
try {
|
|
@@ -849,11 +860,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
849
860
|
const tokens = system + conversationTokens(messages);
|
|
850
861
|
observeBudget(tokens, ctx);
|
|
851
862
|
estimatedTokens = corrected(tokens, ctx);
|
|
852
|
-
const { room, hardLine } = limits(ctx);
|
|
863
|
+
const { room, hardLine, replyFloor } = limits(ctx);
|
|
853
864
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
854
865
|
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
866
|
if (!fits(tokens, hardLine, ctx))
|
|
856
867
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
868
|
+
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
869
|
+
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
870
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
871
|
+
piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
|
|
872
|
+
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
857
873
|
if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
858
874
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
859
875
|
recorder.request();
|
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
|
}
|
package/src/request-capacity.ts
CHANGED
|
@@ -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 =
|
|
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;
|
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
|
};
|