pi-tuck 1.0.15 → 1.0.17
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 +62 -0
- package/README.md +6 -4
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.js +23 -4
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/tuck.js +96 -22
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +20 -5
- package/src/tuck.ts +68 -20
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,68 @@
|
|
|
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.17] - 2026-11-08
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A host that patches a large section into the system prompt could get a refusal a cut would fit (1.0.0 to 1.0.16).** pi-tuck plans a prompt's cut when
|
|
11
|
+
the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn, as an
|
|
12
|
+
orchestrator does for its stand-off, could push that turn's first request over the room, and the final guard refused it. The guard now reduces first:
|
|
13
|
+
when the run's first request is over the room and no cut is planned, it plans the prompt's cut there, against the system prompt as finally sent, and
|
|
14
|
+
refuses only if that cut does not fit either. It works whatever order the extensions load in.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Stress cells for a host that patches an 8k then 24k system section, at 65,536 and 81,920, registered before and after pi-tuck, with the section arriving
|
|
19
|
+
on a stand-off or on the first step turn after one and removed at the next stand-off. 1.0.16 refuses in the four cells at 81,920; 1.0.17 in none.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- When queued input alone exceeds the room, the refusal names it as incoming input that cannot fit, instead of complete context. It still fails closed
|
|
24
|
+
with no cut.
|
|
25
|
+
|
|
26
|
+
### Release gates
|
|
27
|
+
|
|
28
|
+
- Replay matches 1.0.16: 0 of 13,946 requests differ on 29 recordings, 0 lost or dead tasks. Shipped without a live gate as a replay-matching patch.
|
|
29
|
+
Tests; CI; the stress test, with no cell worse than 1.0.16.
|
|
30
|
+
|
|
31
|
+
## [1.0.16] - 2026-11-08
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **A long session could lose the task it was working on (1.0.0 to 1.0.15).** A summary quoted only the session's first user turn, up to 12,000 characters, and the
|
|
36
|
+
newest one. Every other user turn competed in the shrunk dialogue, which in a long session clips user turns to a few words and drops the oldest. In a session
|
|
37
|
+
that runs task after task, the current task is an older turn followed by short steps, so it was clipped or dropped while a finished first task stayed whole,
|
|
38
|
+
and agents acted on a days-old task. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it.
|
|
39
|
+
A summary now quotes user turns from both ends: first the opening of every recent answered turn, then the first prompt's opening (up to 2,000 characters),
|
|
40
|
+
then more of the newest turns, then the rest of the first prompt. A turn that does not fit keeps its opening and a locator to the rest. The budget is 15% of
|
|
41
|
+
the room and never less than 12,000 characters. When room is tight the quotes shrink step by step: the extensions first, then the openings, then the first
|
|
42
|
+
prompt alone as before. A turn pi-tuck refused and that never got a reply is not quoted.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- The settings stress test checks that the first prompt and the newest 8 answered prompts keep their openings through every cut, with cells for a run of
|
|
47
|
+
tasks at 32,768, tasks followed by long steps, and a long spec followed by 30 steering turns, at 32,768 and 81,920.
|
|
48
|
+
|
|
49
|
+
### Known issues
|
|
50
|
+
|
|
51
|
+
- At very small free room (a few thousand tokens or less), quotes give way and an older prompt can still lose its opening: 40 findings in 32 stress cells,
|
|
52
|
+
against 447 in 168 cells on 1.0.15. One sampled cell, with about 1,000 tokens free, loses a short middle prompt that 1.0.15 kept.
|
|
53
|
+
- The known issues listed under 1.0.13 still apply.
|
|
54
|
+
|
|
55
|
+
### Cost
|
|
56
|
+
|
|
57
|
+
On replay of 17 real single-task sessions, cut rate and cost are unchanged: 7.0 cuts per 100 requests at 131,072 for both builds, and 9.3 at 81,920 (838 cuts
|
|
58
|
+
against 837); billed tokens and request time within 0.1%. Those sessions have a median of one prompt, so the new quotes rarely apply there; their cost in a
|
|
59
|
+
session of hundreds of turns is not measured.
|
|
60
|
+
|
|
61
|
+
### Release gates
|
|
62
|
+
|
|
63
|
+
- **Shipped without a live gate, as a fix for lost work.** Replay differs from 1.0.15, because summaries now quote more: 11,860 of 13,946 requests on 29
|
|
64
|
+
recordings, with 0 dead tasks, 0 refused requests and every clip read back (7 clips, where 1.0.15 had 28: summaries changed where cuts land). Tests; CI;
|
|
65
|
+
the stress test on Pi 1.2.0 and the newest Pi.
|
|
66
|
+
- The 32k regression bench makes 74 cuts in 480 rounds, up from 67: sessions with a short first prompt now quote their newest turns too.
|
|
67
|
+
|
|
6
68
|
## [1.0.15] - 2026-11-08
|
|
7
69
|
|
|
8
70
|
### 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). 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.16`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.16`.
|
|
69
69
|
|
|
70
70
|
## How it works
|
|
71
71
|
|
|
@@ -74,7 +74,7 @@ When context fills up, pi-tuck replaces the older turns with one summary. The su
|
|
|
74
74
|
- **The dialogue.** What you asked and what the agent answered, in order, word for word. If it is too long for the room, it shrinks: the oldest assistant text is clipped first, then older prompts. Clipped text says how to read it in full.
|
|
75
75
|
- **The files.** Files the agent read, wrote or edited, newest first (up to 60), each with a pointer to read it back.
|
|
76
76
|
- **The action trail.** The agent's other tool calls, such as shell commands, newest first (up to 30). The output is left out. A pointer leads to it.
|
|
77
|
-
- **Your first prompt
|
|
77
|
+
- **Your prompts, quoted from both ends** (1.0.16 and later): first the opening of every recent answered turn, then the first prompt's opening (up to 2,000 characters), then more of the newest turns, then the rest of the first prompt. A turn that does not fit keeps its opening and a locator to the rest. The budget is 15% of the room and never less than 12,000 characters. When room is tight, the quotes shrink step by step: the extensions first, then the openings, then the first prompt alone. Before 1.0.16 a summary quoted only the first prompt (up to 12,000 characters) and the newest turn; see Known limits.
|
|
78
78
|
|
|
79
79
|
Tool output stays in the session file, not in the summary. Each summary ends with a short instruction to the agent: re-read a file before you edit it, look things up, and treat anything not shown as unknown.
|
|
80
80
|
|
|
@@ -114,8 +114,10 @@ Known limits today:
|
|
|
114
114
|
- 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).
|
|
115
115
|
- 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 is 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.
|
|
116
116
|
- 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).
|
|
117
|
+
- A long session could lose the task it was working on (1.0.0 to 1.0.15; fixed in 1.0.16). A summary quoted only the session's first user turn, up to 12,000 characters, and the newest one. Every other user turn competed in the shrunk dialogue, which in a long session clips user turns to a few words and drops the oldest. In a session that runs task after task, the current task is an older turn followed by short steps, so it was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 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.16 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
|
|
118
|
+
- At very small free room (a few thousand tokens or less), the quotes give way and an older prompt can still lose its opening, in 1.0.16: 40 findings in 32 stress cells, against 447 in 168 cells on 1.0.15 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
|
|
117
119
|
- The cut point adapts by itself since 1.0.13; before it, pi-tuck cut too often at small windows or with a large setup (1.0.11 and 1.0.12). The trade, from the changelog: on windows that cut too often, each request now carries more context. Cuts, re-processing and request time fall, and billed tokens rise on paid models. On replay of real sessions at an 81,920 window, cuts went from 22.3 to 9.3 per 100 requests, request time was about 16 to 20% lower, and billed tokens were 16 to 19% higher; at a 65,536 window billed tokens rose by as much as 35%. A very large setup at a small window, such as 40k at 65,536, still leaves too little free room for a good rhythm: cuts stay frequent and free little ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
|
|
118
|
-
- Still applying (1.0.11 to 1.0.
|
|
120
|
+
- Still applying (1.0.11 to 1.0.16): a fresh assistant message larger than the free room is refused, and a short next prompt recovers (in an unattended `pi -p` run, a refusal ends the run). A reply that reports more than the window reads to Pi as a silent overflow; when nothing can be cut, pi-tuck refuses the next prompt, and the prompt after that runs. If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own: the session stays intact, and every prompt can fail until the request shrinks. Try `/compact` ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
|
|
119
121
|
- Other extensions (1.0.10): a prompt's cut no longer goes through Pi's `session_before_compact`, so another extension's veto there no longer stops it. It never stopped a cut between tool rounds. Pi's own threshold and overflow cuts, and `/compact`, still ask other extensions as before.
|
|
120
122
|
- Semantic search (`tuck_search`) needs a Mac with Metal (Apple silicon). Elsewhere, compaction and `tuck_recall`'s word search work, there is no `tuck_search`, and nothing is downloaded.
|
|
121
123
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "27bb99fa3ca595dac73089d18c4c3506073c5d3e3f778ea572708f6e7a49ffc3";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "27bb99fa3ca595dac73089d18c4c3506073c5d3e3f778ea572708f6e7a49ffc3";
|
package/dist/src/compaction.js
CHANGED
|
@@ -542,7 +542,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
542
542
|
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
543
543
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
544
544
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
545
|
-
const preflight =
|
|
545
|
+
const preflight = (ctx, incomingPart, observedPrompt, tools) => {
|
|
546
546
|
promptCut = undefined;
|
|
547
547
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
548
548
|
if (!active())
|
|
@@ -807,22 +807,41 @@ export async function installCompaction(pi, options = {}) {
|
|
|
807
807
|
throw missingModel();
|
|
808
808
|
// The complete request, counted after Pi's conversion (summary/custom/bash envelopes), with the effective system once.
|
|
809
809
|
// Pi applies a forced prompt after this hook; the public prompt already observes it.
|
|
810
|
-
|
|
810
|
+
let messages = event.messages, tokens = requestTokens(messages, ctx.getSystemPrompt());
|
|
811
|
+
// The run's first request is over the room although the prompt's decision found it fit: something changed the request after that decision
|
|
812
|
+
// (another extension patching the system prompt in before_agent_start, whatever the load order). Reduce here rather than refuse: plan the
|
|
813
|
+
// prompt's cut against the request as it now is, carry it in memory, and save it at the first boundary, as any prompt's cut is saved.
|
|
814
|
+
if (!fits(tokens, limits(ctx).room, ctx) && !promptCut) {
|
|
815
|
+
const branch = ctx.sessionManager.getBranch(), last = branch.at(-1);
|
|
816
|
+
if (last?.type === "message" && last.message.role === "user") {
|
|
817
|
+
const header = ctx.sessionManager.getHeader();
|
|
818
|
+
const before = header && SessionManager.inMemory(ctx.cwd, undefined, [header, ...branch.slice(0, -1)]);
|
|
819
|
+
if (before) {
|
|
820
|
+
preflight(Object.create(ctx, { sessionManager: { value: before } }), incomingTokens(typeof last.message.content === "string" ? last.message.content : last.message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof last.message.content === "string" ? [] : last.message.content.filter((b) => b.type === "image")), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
821
|
+
if (promptCut) {
|
|
822
|
+
messages = cutRequest(event.messages, ctx, promptCut);
|
|
823
|
+
tokens = requestTokens(messages, ctx.getSystemPrompt());
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
811
828
|
if (!fits(tokens, limits(ctx).room, ctx))
|
|
812
829
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
813
830
|
if (recorder.enabled && !recorder.waitingForCut)
|
|
814
831
|
recorder.measureInline(() => {
|
|
815
832
|
recorder.request();
|
|
816
833
|
// No system prompt or hidden assistant blocks; no provider payload/headers.
|
|
817
|
-
const permitted =
|
|
834
|
+
const permitted = messages.filter(m => m.role !== "system" &&
|
|
818
835
|
!(m.role === "assistant" && m.content.some(b => b.type === "thinking")) &&
|
|
819
836
|
!(m.role === "custom" && options.recallPolicy?.privateCustomTypes?.includes(m.customType)));
|
|
820
837
|
recorder.capture("prepared-request", "public-context-with-system-hook-before-later-transforms-not-wire", { messages: permitted, estimatedInputTokens: tokens }, permitted.length !== event.messages.length);
|
|
821
838
|
});
|
|
822
839
|
if (tokens > 0 && Number.isSafeInteger(tokens))
|
|
823
840
|
pending = {
|
|
824
|
-
identity: identityFor(ctx), tokens, messages
|
|
841
|
+
identity: identityFor(ctx), tokens, messages, snapshot: structuredClone(convertToLlm(messages)),
|
|
825
842
|
};
|
|
843
|
+
if (messages !== event.messages)
|
|
844
|
+
return { messages };
|
|
826
845
|
}
|
|
827
846
|
catch (error) {
|
|
828
847
|
fail(error, ctx);
|
|
@@ -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 = "27bb99fa3ca595dac73089d18c4c3506073c5d3e3f778ea572708f6e7a49ffc3";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
package/dist/src/tuck.js
CHANGED
|
@@ -166,22 +166,75 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
166
166
|
receiptContext.messages.push(entry.message);
|
|
167
167
|
receiptContext.sourceIndices.push(indices.get(entry.id));
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
//
|
|
169
|
+
// The newest user turns, newest first, then the initiating prompt, share one budget: in a session that runs task after task, the current task is
|
|
170
|
+
// an older user turn followed by short steps, and the dialogue clips user turns oldest first down to a few words, so quoting only the first prompt
|
|
171
|
+
// kept a finished task whole and lost the one being worked on. A turn that does not fit keeps its head and a locator; the first prompt gets what
|
|
172
|
+
// the newest turns leave, so a one-task session, whose later turns are short, still quotes it as before.
|
|
171
173
|
const initiating = firstUserPosition >= 0 && firstUserPosition < boundary && entries[firstUserPosition].id !== protectedUser?.id ? entries[firstUserPosition] : undefined;
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
const quote = (entry, budget, label) => {
|
|
175
|
+
const content = entry.type === "message" && entry.message.role === "user" ? entry.message.content : undefined;
|
|
176
|
+
if (!isTextOnly(content))
|
|
177
|
+
return [];
|
|
178
|
+
const text = textOf(content), index = indices.get(entry.id);
|
|
175
179
|
if (index === undefined)
|
|
176
|
-
throw new Error("Missing
|
|
177
|
-
const quoted = head(text,
|
|
178
|
-
if (text.trim())
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
throw new Error("Missing quoted user source index");
|
|
181
|
+
const quoted = head(text, budget), sourceTimes = [{ sourceIndex: index, ...resolveSourceTime(entry) }];
|
|
182
|
+
if (!text.trim() || !quoted.length)
|
|
183
|
+
return [];
|
|
184
|
+
return [{ kind: "excerpt", role: "user", sourceIndex: index, text: quoted, sourceTimes }, ...(quoted.length < text.length ? [{ kind: "omission", role: "user",
|
|
185
|
+
sourceIndex: index, reason: `${label} continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}`, sourceTimes }] : [])];
|
|
186
|
+
};
|
|
187
|
+
// Both ends are anchored, in this order: the heads of the newest user turns (at most a quarter of the budget), the head of the first prompt (the
|
|
188
|
+
// goal of a long session), then extensions of the newest turns, then the rest of the first prompt. Heads come first so steps after a task can't
|
|
189
|
+
// use up the budget the task turn needs; the first prompt's head comes before any extension so steering turns can't use up the goal's.
|
|
190
|
+
const quotesFor = (budget, headSize, withFirst) => {
|
|
191
|
+
const turns = [];
|
|
192
|
+
// Only answered turns: a prompt that was refused and never got a reply was never taken up, and quoting it would spend the budget for nothing.
|
|
193
|
+
let answered = false;
|
|
194
|
+
for (let at = entries.length - 1; at >= 0; at--) {
|
|
195
|
+
const entry = entries[at];
|
|
196
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
197
|
+
answered = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (entry.type !== "message" || entry.message.role !== "user")
|
|
201
|
+
continue;
|
|
202
|
+
const replied = answered;
|
|
203
|
+
answered = false;
|
|
204
|
+
if (at >= boundary || !replied || entry.id === protectedUser?.id || entry.id === firstUser?.id || !isTextOnly(entry.message.content))
|
|
205
|
+
continue;
|
|
206
|
+
const text = textOf(entry.message.content);
|
|
207
|
+
if (text.trim())
|
|
208
|
+
turns.push({ entry, text, take: 0 });
|
|
183
209
|
}
|
|
184
|
-
|
|
210
|
+
// headSize 0 quotes the first prompt alone, as before any user turn was quoted: the last step before no quote at all.
|
|
211
|
+
if (headSize === 0)
|
|
212
|
+
turns.length = 0;
|
|
213
|
+
let left = budget, heads = Math.floor(budget / 4);
|
|
214
|
+
for (const turn of turns) {
|
|
215
|
+
if (heads <= 0)
|
|
216
|
+
break;
|
|
217
|
+
turn.take = Math.min(turn.text.length, headSize, heads);
|
|
218
|
+
heads -= turn.take;
|
|
219
|
+
left -= turn.take;
|
|
220
|
+
}
|
|
221
|
+
const firstText = withFirst && initiating?.type === "message" && initiating.message.role === "user" && isTextOnly(initiating.message.content) ? textOf(initiating.message.content) : "";
|
|
222
|
+
let firstTake = Math.min(firstText.length, headSize ? Math.round(headSize * 20 / 3) : budget, Math.max(0, left));
|
|
223
|
+
left -= firstTake;
|
|
224
|
+
for (const turn of turns) {
|
|
225
|
+
if (left <= 0)
|
|
226
|
+
break;
|
|
227
|
+
const more = Math.min(turn.text.length - turn.take, left);
|
|
228
|
+
turn.take += more;
|
|
229
|
+
left -= more;
|
|
230
|
+
}
|
|
231
|
+
firstTake += Math.min(firstText.length - firstTake, Math.max(0, left));
|
|
232
|
+
const records = turns.filter(turn => turn.take > 0).reverse().flatMap(turn => quote(turn.entry, turn.take, "User turn"));
|
|
233
|
+
if (firstTake > 0 && initiating)
|
|
234
|
+
records.unshift(...quote(initiating, firstTake, "Initiating prompt"));
|
|
235
|
+
return records;
|
|
236
|
+
};
|
|
237
|
+
const mandatory = [];
|
|
185
238
|
if (protectedUser?.type === "message" && protectedUser.message.role === "user")
|
|
186
239
|
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, text: textOf(protectedUser.message.content), sourceTimes: [{ sourceIndex: currentIndex, ...resolveSourceTime(protectedUser) }] });
|
|
187
240
|
// The file ledger and the action trail of the compacted history are mandatory, so they are charged in base like the quotes above.
|
|
@@ -200,22 +253,43 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
200
253
|
};
|
|
201
254
|
const before = measure(projected.messages);
|
|
202
255
|
const model = ctx.model;
|
|
203
|
-
const floorAt = (keep) => {
|
|
256
|
+
const floorAt = (keep, choice) => {
|
|
204
257
|
const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
|
|
205
|
-
const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
|
|
206
|
-
return { ledger, messages, base: measure(messages) };
|
|
258
|
+
const messages = preview(serializeTranscript([...mandatory, ...quotesFor(choice.budget, choice.head, choice.first), ...ledger]) + DIALOGUE_SUFFIX);
|
|
259
|
+
return { ledger, messages, base: measure(messages), choice };
|
|
207
260
|
};
|
|
208
261
|
const fits = (base) => Boolean(model) && base + reserveTokens < model.contextWindow && base < before;
|
|
209
|
-
// When the
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
262
|
+
// When room is tight the newest turns win over the first prompt, which in a long-running session is often a finished task. The floor gives way
|
|
263
|
+
// in this order: the trail and ledger to their headings, the extensions (the budget halved), then the heads, the first prompt's among them
|
|
264
|
+
// (300 characters, 2,000 for the first prompt; then 100 and 667), then everything but the first prompt (as before user turns were quoted),
|
|
265
|
+
// and only then every quote. Only a floor with no quote but the current input can refuse the cut.
|
|
266
|
+
// The budget grows with the room: 15% of it, at least the 12,000 characters a first prompt always had. A long-running session's task can be tens
|
|
267
|
+
// of thousands of characters, and the room is what can afford it.
|
|
268
|
+
const taskBudget = Math.max(MAX_TASK_CONTRACT_CHARACTERS, Math.floor(Math.max(0, (model?.contextWindow ?? 0) - reserveTokens) * charsPerToken * 0.15));
|
|
269
|
+
const budgets = [taskBudget, taskBudget / 2, taskBudget / 4, taskBudget / 8, 1500].map(Math.floor).filter((b, at, all) => b >= 1500 && all.indexOf(b) === at);
|
|
270
|
+
const choices = [{ head: 300, budgets }, { head: 100, budgets: [1500, 600] }]
|
|
271
|
+
.flatMap(({ head, budgets }) => budgets.map(budget => ({ budget, head, first: true }))).concat([{ budget: MAX_TASK_CONTRACT_CHARACTERS, head: 0, first: true }, { budget: 0, head: 0, first: false }]);
|
|
272
|
+
// When even the smallest floor cannot fit, no step of the ladder can: refuse at once instead of building every preview.
|
|
273
|
+
const smallest = floorAt(0, choices[choices.length - 1]);
|
|
274
|
+
let floor = smallest;
|
|
275
|
+
if (fits(smallest.base))
|
|
276
|
+
for (const choice of choices) {
|
|
277
|
+
floor = floorAt(Math.max(items.length, actions.length), choice);
|
|
278
|
+
if (fits(floor.base))
|
|
279
|
+
break;
|
|
280
|
+
if (items.length || actions.length) {
|
|
281
|
+
floor = floorAt(0, choice);
|
|
282
|
+
if (fits(floor.base))
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
213
286
|
const { ledger, base } = floor;
|
|
287
|
+
const kept = [...mandatory, ...quotesFor(floor.choice.budget, floor.choice.head, floor.choice.first)];
|
|
214
288
|
if (!model || !fits(base))
|
|
215
289
|
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
|
|
216
290
|
const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
|
|
217
291
|
const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id))), receiptContext,
|
|
218
|
-
ranking: { maxBriefChars, mandatorySourceIndices: new Set(
|
|
292
|
+
ranking: { maxBriefChars, mandatorySourceIndices: new Set(kept.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) }, };
|
|
219
293
|
// Both views use the same parser, mandatory insertion, native preview and guard, and end with the same closing section.
|
|
220
294
|
const assemble = (compiled) => {
|
|
221
295
|
if (typeof compiled !== "string")
|
|
@@ -224,7 +298,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
224
298
|
if (!split)
|
|
225
299
|
throw new Error("pi-tuck returned an invalid summary");
|
|
226
300
|
const records = parseRecords(split.body, split.version);
|
|
227
|
-
for (const record of
|
|
301
|
+
for (const record of kept) {
|
|
228
302
|
const insertion = records.findIndex(row => row.sourceIndex !== undefined && row.sourceIndex > record.sourceIndex);
|
|
229
303
|
records.splice(insertion < 0 ? records.length : insertion, 0, record);
|
|
230
304
|
}
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "27bb99fa3ca595dac73089d18c4c3506073c5d3e3f778ea572708f6e7a49ffc3";
|
package/src/compaction.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { projectedMessages } from "./omissions.js";
|
|
|
8
8
|
import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
|
|
9
9
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
|
|
10
10
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
11
|
-
import { isContextOverflow, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
11
|
+
import { isContextOverflow, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
12
12
|
import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import { Check } from "typebox/value";
|
|
@@ -479,7 +479,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
479
479
|
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
480
480
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
481
481
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
482
|
-
const preflight =
|
|
482
|
+
const preflight = (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[]) => {
|
|
483
483
|
promptCut = undefined;
|
|
484
484
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
485
485
|
if (!active()) return;
|
|
@@ -699,20 +699,35 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
699
699
|
if (!ctx.model) throw missingModel();
|
|
700
700
|
// The complete request, counted after Pi's conversion (summary/custom/bash envelopes), with the effective system once.
|
|
701
701
|
// Pi applies a forced prompt after this hook; the public prompt already observes it.
|
|
702
|
-
|
|
702
|
+
let messages = event.messages, tokens = requestTokens(messages, ctx.getSystemPrompt());
|
|
703
|
+
// The run's first request is over the room although the prompt's decision found it fit: something changed the request after that decision
|
|
704
|
+
// (another extension patching the system prompt in before_agent_start, whatever the load order). Reduce here rather than refuse: plan the
|
|
705
|
+
// prompt's cut against the request as it now is, carry it in memory, and save it at the first boundary, as any prompt's cut is saved.
|
|
706
|
+
if (!fits(tokens, limits(ctx).room, ctx) && !promptCut) {
|
|
707
|
+
const branch = ctx.sessionManager.getBranch(), last = branch.at(-1);
|
|
708
|
+
if (last?.type === "message" && last.message.role === "user") {
|
|
709
|
+
const header = ctx.sessionManager.getHeader();
|
|
710
|
+
const before = header && SessionManager.inMemory(ctx.cwd, undefined, [header, ...branch.slice(0, -1)]);
|
|
711
|
+
if (before) {
|
|
712
|
+
preflight(Object.create(ctx, { sessionManager: { value: before } }), incomingTokens(typeof last.message.content === "string" ? last.message.content : last.message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof last.message.content === "string" ? [] : last.message.content.filter((b): b is ImageContent => b.type === "image")), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
713
|
+
if (promptCut) { messages = cutRequest(event.messages, ctx, promptCut); tokens = requestTokens(messages, ctx.getSystemPrompt()); }
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
703
717
|
if (!fits(tokens, limits(ctx).room, ctx))
|
|
704
718
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
705
719
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
706
720
|
recorder.request();
|
|
707
721
|
// No system prompt or hidden assistant blocks; no provider payload/headers.
|
|
708
|
-
const permitted =
|
|
722
|
+
const permitted = messages.filter(m => m.role !== "system" &&
|
|
709
723
|
!(m.role === "assistant" && m.content.some(b => b.type === "thinking")) &&
|
|
710
724
|
!(m.role === "custom" && options.recallPolicy?.privateCustomTypes?.includes(m.customType)));
|
|
711
725
|
recorder.capture("prepared-request", "public-context-with-system-hook-before-later-transforms-not-wire", { messages: permitted, estimatedInputTokens: tokens }, permitted.length !== event.messages.length);
|
|
712
726
|
});
|
|
713
727
|
if (tokens > 0 && Number.isSafeInteger(tokens)) pending = {
|
|
714
|
-
identity: identityFor(ctx), tokens, messages
|
|
728
|
+
identity: identityFor(ctx), tokens, messages, snapshot: structuredClone(convertToLlm(messages)),
|
|
715
729
|
};
|
|
730
|
+
if (messages !== event.messages) return { messages };
|
|
716
731
|
|
|
717
732
|
} catch (error) { fail(error, ctx); }
|
|
718
733
|
});
|
package/src/tuck.ts
CHANGED
|
@@ -187,20 +187,52 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
187
187
|
for (const entry of entries) if (entry.type === "message") {
|
|
188
188
|
receiptContext.messages.push(entry.message); receiptContext.sourceIndices.push(indices.get(entry.id));
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
//
|
|
190
|
+
// The newest user turns, newest first, then the initiating prompt, share one budget: in a session that runs task after task, the current task is
|
|
191
|
+
// an older user turn followed by short steps, and the dialogue clips user turns oldest first down to a few words, so quoting only the first prompt
|
|
192
|
+
// kept a finished task whole and lost the one being worked on. A turn that does not fit keeps its head and a locator; the first prompt gets what
|
|
193
|
+
// the newest turns leave, so a one-task session, whose later turns are short, still quotes it as before.
|
|
192
194
|
const initiating = firstUserPosition >= 0 && firstUserPosition < boundary && entries[firstUserPosition]!.id !== protectedUser?.id ? entries[firstUserPosition] : undefined;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
const quote = (entry: SessionEntry, budget: number, label: string): TranscriptRecord[] => {
|
|
196
|
+
const content = entry.type === "message" && entry.message.role === "user" ? entry.message.content : undefined;
|
|
197
|
+
if (!isTextOnly(content)) return [];
|
|
198
|
+
const text = textOf(content!), index = indices.get(entry.id);
|
|
199
|
+
if (index === undefined) throw new Error("Missing quoted user source index");
|
|
200
|
+
const quoted = head(text, budget), sourceTimes = [{ sourceIndex: index, ...resolveSourceTime(entry) }];
|
|
201
|
+
if (!text.trim() || !quoted.length) return [];
|
|
202
|
+
return [{ kind: "excerpt", role: "user", sourceIndex: index, text: quoted, sourceTimes }, ...(quoted.length < text.length ? [{ kind: "omission" as const, role: "user" as const,
|
|
203
|
+
sourceIndex: index, reason: `${label} continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}`, sourceTimes }] : [])];
|
|
204
|
+
};
|
|
205
|
+
// Both ends are anchored, in this order: the heads of the newest user turns (at most a quarter of the budget), the head of the first prompt (the
|
|
206
|
+
// goal of a long session), then extensions of the newest turns, then the rest of the first prompt. Heads come first so steps after a task can't
|
|
207
|
+
// use up the budget the task turn needs; the first prompt's head comes before any extension so steering turns can't use up the goal's.
|
|
208
|
+
const quotesFor = (budget: number, headSize: number, withFirst: boolean): TranscriptRecord[] => {
|
|
209
|
+
const turns: { entry: SessionEntry; text: string; take: number }[] = [];
|
|
210
|
+
// Only answered turns: a prompt that was refused and never got a reply was never taken up, and quoting it would spend the budget for nothing.
|
|
211
|
+
let answered = false;
|
|
212
|
+
for (let at = entries.length - 1; at >= 0; at--) {
|
|
213
|
+
const entry = entries[at]!;
|
|
214
|
+
if (entry.type === "message" && entry.message.role === "assistant") { answered = true; continue; }
|
|
215
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
216
|
+
const replied = answered;
|
|
217
|
+
answered = false;
|
|
218
|
+
if (at >= boundary || !replied || entry.id === protectedUser?.id || entry.id === firstUser?.id || !isTextOnly(entry.message.content)) continue;
|
|
219
|
+
const text = textOf(entry.message.content);
|
|
220
|
+
if (text.trim()) turns.push({ entry, text, take: 0 });
|
|
202
221
|
}
|
|
203
|
-
|
|
222
|
+
// headSize 0 quotes the first prompt alone, as before any user turn was quoted: the last step before no quote at all.
|
|
223
|
+
if (headSize === 0) turns.length = 0;
|
|
224
|
+
let left = budget, heads = Math.floor(budget / 4);
|
|
225
|
+
for (const turn of turns) { if (heads <= 0) break; turn.take = Math.min(turn.text.length, headSize, heads); heads -= turn.take; left -= turn.take; }
|
|
226
|
+
const firstText = withFirst && initiating?.type === "message" && initiating.message.role === "user" && isTextOnly(initiating.message.content) ? textOf(initiating.message.content) : "";
|
|
227
|
+
let firstTake = Math.min(firstText.length, headSize ? Math.round(headSize * 20 / 3) : budget, Math.max(0, left));
|
|
228
|
+
left -= firstTake;
|
|
229
|
+
for (const turn of turns) { if (left <= 0) break; const more = Math.min(turn.text.length - turn.take, left); turn.take += more; left -= more; }
|
|
230
|
+
firstTake += Math.min(firstText.length - firstTake, Math.max(0, left));
|
|
231
|
+
const records = turns.filter(turn => turn.take > 0).reverse().flatMap(turn => quote(turn.entry, turn.take, "User turn"));
|
|
232
|
+
if (firstTake > 0 && initiating) records.unshift(...quote(initiating, firstTake, "Initiating prompt"));
|
|
233
|
+
return records;
|
|
234
|
+
};
|
|
235
|
+
const mandatory: TranscriptRecord[] = [];
|
|
204
236
|
if (protectedUser?.type === "message" && protectedUser.message.role === "user")
|
|
205
237
|
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, text: textOf(protectedUser.message.content), sourceTimes: [{ sourceIndex: currentIndex!, ...resolveSourceTime(protectedUser) }] });
|
|
206
238
|
// The file ledger and the action trail of the compacted history are mandatory, so they are charged in base like the quotes above.
|
|
@@ -218,21 +250,37 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
218
250
|
};
|
|
219
251
|
const before = measure(projected.messages);
|
|
220
252
|
const model = ctx.model;
|
|
221
|
-
const floorAt = (keep: number) => {
|
|
253
|
+
const floorAt = (keep: number, choice: { budget: number; head: number; first: boolean }) => {
|
|
222
254
|
const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
|
|
223
|
-
const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
|
|
224
|
-
return { ledger, messages, base: measure(messages) };
|
|
255
|
+
const messages = preview(serializeTranscript([...mandatory, ...quotesFor(choice.budget, choice.head, choice.first), ...ledger]) + DIALOGUE_SUFFIX);
|
|
256
|
+
return { ledger, messages, base: measure(messages), choice };
|
|
225
257
|
};
|
|
226
258
|
const fits = (base: number) => Boolean(model) && base + reserveTokens < model!.contextWindow && base < before;
|
|
227
|
-
// When the
|
|
228
|
-
|
|
229
|
-
|
|
259
|
+
// When room is tight the newest turns win over the first prompt, which in a long-running session is often a finished task. The floor gives way
|
|
260
|
+
// in this order: the trail and ledger to their headings, the extensions (the budget halved), then the heads, the first prompt's among them
|
|
261
|
+
// (300 characters, 2,000 for the first prompt; then 100 and 667), then everything but the first prompt (as before user turns were quoted),
|
|
262
|
+
// and only then every quote. Only a floor with no quote but the current input can refuse the cut.
|
|
263
|
+
// The budget grows with the room: 15% of it, at least the 12,000 characters a first prompt always had. A long-running session's task can be tens
|
|
264
|
+
// of thousands of characters, and the room is what can afford it.
|
|
265
|
+
const taskBudget = Math.max(MAX_TASK_CONTRACT_CHARACTERS, Math.floor(Math.max(0, (model?.contextWindow ?? 0) - reserveTokens) * charsPerToken * 0.15));
|
|
266
|
+
const budgets = [taskBudget, taskBudget / 2, taskBudget / 4, taskBudget / 8, 1500].map(Math.floor).filter((b, at, all) => b >= 1500 && all.indexOf(b) === at);
|
|
267
|
+
const choices = [{ head: 300, budgets }, { head: 100, budgets: [1500, 600] }]
|
|
268
|
+
.flatMap(({ head, budgets }) => budgets.map(budget => ({ budget, head, first: true }))).concat([{ budget: MAX_TASK_CONTRACT_CHARACTERS, head: 0, first: true }, { budget: 0, head: 0, first: false }]);
|
|
269
|
+
// When even the smallest floor cannot fit, no step of the ladder can: refuse at once instead of building every preview.
|
|
270
|
+
const smallest = floorAt(0, choices[choices.length - 1]!);
|
|
271
|
+
let floor = smallest;
|
|
272
|
+
if (fits(smallest.base)) for (const choice of choices) {
|
|
273
|
+
floor = floorAt(Math.max(items.length, actions.length), choice);
|
|
274
|
+
if (fits(floor.base)) break;
|
|
275
|
+
if (items.length || actions.length) { floor = floorAt(0, choice); if (fits(floor.base)) break; }
|
|
276
|
+
}
|
|
230
277
|
const { ledger, base } = floor;
|
|
278
|
+
const kept = [...mandatory, ...quotesFor(floor.choice.budget, floor.choice.head, floor.choice.first)];
|
|
231
279
|
if (!model || !fits(base))
|
|
232
280
|
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
|
|
233
281
|
const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
|
|
234
282
|
const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id)!)), receiptContext,
|
|
235
|
-
ranking: { maxBriefChars, mandatorySourceIndices: new Set(
|
|
283
|
+
ranking: { maxBriefChars, mandatorySourceIndices: new Set(kept.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) },
|
|
236
284
|
};
|
|
237
285
|
// Both views use the same parser, mandatory insertion, native preview and guard, and end with the same closing section.
|
|
238
286
|
const assemble = (compiled: string) => {
|
|
@@ -240,7 +288,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
240
288
|
const split = compiled ? splitSummary(compiled) : { body: "", version: 2 as const };
|
|
241
289
|
if (!split) throw new Error("pi-tuck returned an invalid summary");
|
|
242
290
|
const records = parseRecords(split.body, split.version);
|
|
243
|
-
for (const record of
|
|
291
|
+
for (const record of kept) {
|
|
244
292
|
const insertion = records.findIndex(row => row.sourceIndex !== undefined && row.sourceIndex > record.sourceIndex!);
|
|
245
293
|
records.splice(insertion < 0 ? records.length : insertion, 0, record);
|
|
246
294
|
}
|