pi-tuck 1.0.1 → 1.0.3
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 +43 -0
- package/README.md +8 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -4
- package/dist/src/budget.d.ts +17 -4
- package/dist/src/budget.js +33 -10
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +4 -0
- package/dist/src/compaction.js +8 -5
- package/dist/src/diagnostics.d.ts +2 -1
- package/dist/src/omissions.d.ts +1 -1
- package/dist/src/omissions.js +8 -8
- package/dist/src/tuck/core/brief.d.ts +13 -43
- package/dist/src/tuck/core/brief.js +39 -255
- package/dist/src/tuck/core/format.d.ts +2 -6
- package/dist/src/tuck/core/format.js +4 -10
- package/dist/src/tuck/core/normalize.js +20 -43
- package/dist/src/tuck/core/shrink.d.ts +33 -0
- package/dist/src/tuck/core/shrink.js +221 -0
- package/dist/src/tuck/core/summarize.d.ts +15 -7
- package/dist/src/tuck/core/summarize.js +10 -23
- package/dist/src/tuck/core/transcript.d.ts +24 -13
- package/dist/src/tuck/core/transcript.js +243 -83
- package/dist/src/tuck/types.d.ts +4 -14
- package/dist/src/tuck.d.ts +8 -10
- package/dist/src/tuck.js +23 -25
- package/index.ts +8 -5
- package/package.json +1 -1
- package/src/budget.ts +29 -9
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +11 -5
- package/src/diagnostics.ts +1 -1
- package/src/omissions.ts +8 -8
- package/src/tuck/core/brief.ts +46 -265
- package/src/tuck/core/format.ts +4 -11
- package/src/tuck/core/normalize.ts +20 -43
- package/src/tuck/core/shrink.ts +218 -0
- package/src/tuck/core/summarize.ts +20 -25
- package/src/tuck/core/transcript.ts +234 -82
- package/src/tuck/types.ts +7 -19
- package/src/tuck.ts +30 -32
- package/dist/src/tuck/core/brief-meter.d.ts +0 -42
- package/dist/src/tuck/core/brief-meter.js +0 -273
- package/dist/src/tuck/core/rank.d.ts +0 -42
- package/dist/src/tuck/core/rank.js +0 -292
- package/src/tuck/core/brief-meter.ts +0 -235
- package/src/tuck/core/rank.ts +0 -331
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { installCompaction } from "./src/compaction";
|
|
5
|
+
import { validCutAt } from "./src/budget";
|
|
5
6
|
import { createSemanticRecall } from "./src/semantic-recall";
|
|
6
7
|
import { defaultRoot } from "./src/assets";
|
|
7
8
|
import { ensureSemanticRuntime } from "./src/setup";
|
|
@@ -14,13 +15,14 @@ export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/sem
|
|
|
14
15
|
export { ensureSemanticRuntime } from "./src/setup";
|
|
15
16
|
|
|
16
17
|
/** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
|
|
17
|
-
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts.
|
|
18
|
+
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
|
|
19
|
+
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 1 by default. */
|
|
18
20
|
export default async function (pi: ExtensionAPI) {
|
|
19
21
|
const semanticRecall = createSemanticRecall();
|
|
20
22
|
// `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
|
|
21
23
|
let off = false;
|
|
22
|
-
const handle = await installCompaction(pi, { semanticRecall, onSemanticSetting: async enabled => { off = !enabled; } });
|
|
23
24
|
const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
|
|
25
|
+
const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
|
|
24
26
|
if (settings.semanticRecall === false) return;
|
|
25
27
|
let session = 0, said = "", setup: Promise<unknown> | undefined;
|
|
26
28
|
// A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
|
|
@@ -55,14 +57,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
function readSettings(path: string): { semanticRecall?: boolean; error?: string } {
|
|
60
|
+
function readSettings(path: string): { semanticRecall?: boolean; cutAt?: number; error?: string } {
|
|
59
61
|
let text: string;
|
|
60
62
|
try { text = readFileSync(path, "utf8"); } catch { return {}; }
|
|
61
63
|
try {
|
|
62
64
|
const value = JSON.parse(text);
|
|
63
|
-
if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean")
|
|
65
|
+
if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean") &&
|
|
66
|
+
(value.cutAt === undefined || validCutAt(value.cutAt))) return value;
|
|
64
67
|
} catch { /* reported below */ }
|
|
65
|
-
return { error: `${path} should be a JSON object like {"semanticRecall": false}` };
|
|
68
|
+
return { error: `${path} should be a JSON object like {"semanticRecall": false, "cutAt": 0.8}, with cutAt a number above 0 and at most 1` };
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const reason = (error: unknown) => error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tuck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/budget.ts
CHANGED
|
@@ -16,8 +16,20 @@ export interface ToolShape { name: string; description: string; parameters: unkn
|
|
|
16
16
|
|
|
17
17
|
/** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
|
|
18
18
|
export const TRIGGER_MARGIN_FRACTION = 0.02;
|
|
19
|
-
/**
|
|
20
|
-
export const
|
|
19
|
+
/** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
|
|
20
|
+
export const validCutAt = (value: unknown): value is number => typeof value === "number" && value > 0 && value <= 1;
|
|
21
|
+
/**
|
|
22
|
+
* The one owner of the band: the limit a cut is triggered at and preferably planned to. It is the room less the margin, or the cut point
|
|
23
|
+
* `cutAt` (a fraction of the room) when that is lower. The exact room stays the limit of every throw, fault and the complete-context guard,
|
|
24
|
+
* whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
|
|
25
|
+
*/
|
|
26
|
+
/** The default cut point: a cut once a request passes 60% of the room. Measured against 1 with paired replays (no answer loss) and billed
|
|
27
|
+
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
28
|
+
export const DEFAULT_CUT_AT = 0.6;
|
|
29
|
+
export const bandOf = (room: number, cutAt = 1): number => {
|
|
30
|
+
const positive = Math.max(0, room);
|
|
31
|
+
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
|
|
32
|
+
};
|
|
21
33
|
|
|
22
34
|
/** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
|
|
23
35
|
const REPLAYING_APIS: ReadonlySet<string> = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
|
|
@@ -78,7 +90,7 @@ export const planningReserve = (window: number, targetRoom: number, factor: numb
|
|
|
78
90
|
window - Math.floor(targetRoom / factor) + mandatory;
|
|
79
91
|
|
|
80
92
|
export type Target = "band" | "room";
|
|
81
|
-
/** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or
|
|
93
|
+
/** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or shrunk). */
|
|
82
94
|
export type Plan<D extends object = {}> = ({ draft: true; after: number; mode: string } & D) | { draft: false; capacityRejected: boolean; reason: string };
|
|
83
95
|
export type Decision<D extends object = {}> =
|
|
84
96
|
/** The request fits below the band, or an opportunistic cut cannot help: no cut and no fault; the request goes to the guard. */
|
|
@@ -105,26 +117,34 @@ export interface CutInput<D extends object = {}> {
|
|
|
105
117
|
export type Verdict<D extends object = {}> = Exclude<Decision<D>, { kind: "defer" }>;
|
|
106
118
|
/**
|
|
107
119
|
* Rule 4. The exact room is planned first. When its plan already lands below the band it is taken as it is (target "room"): it keeps the
|
|
108
|
-
* room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is
|
|
120
|
+
* room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is smaller. Only
|
|
109
121
|
* otherwise is the band planned. Inside the band a cut is opportunistic, so a failed plan is never a fault and never falls back to the
|
|
110
122
|
* room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
|
|
111
123
|
* taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
|
|
112
124
|
* no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
|
|
113
125
|
* view), and a band plan stands when the room has none (a cut instead of a fault).
|
|
126
|
+
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
127
|
+
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
128
|
+
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
129
|
+
* and a cut is due. With the default cut point the band is never lean and the order and the decisions are those above.
|
|
114
130
|
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
115
131
|
* boundaries inside the band too, where no planner ran before the margin.
|
|
116
132
|
*/
|
|
117
133
|
export function chooseCut<D extends object = {}>(input: CutInput<D> & { opportunistic: boolean }): Verdict<D> {
|
|
118
134
|
const { before, mandatory, room, band, fits, plan, unreduced } = input;
|
|
119
|
-
const
|
|
120
|
-
|
|
135
|
+
const lands = (p: Plan<D>): p is Extract<Plan<D>, { draft: true }> => p.draft && p.after < before && fits(p.after + mandatory, band);
|
|
136
|
+
let roomPlan: Plan<D> | undefined;
|
|
137
|
+
const exact = () => roomPlan ??= plan("room");
|
|
138
|
+
if (band >= bandOf(room)) { const e = exact(); if (lands(e)) return { kind: "cut", target: "room", plan: e }; }
|
|
121
139
|
const preferred = plan("band");
|
|
122
140
|
if (input.opportunistic) {
|
|
123
|
-
if (!(preferred
|
|
124
|
-
|
|
141
|
+
if (!lands(preferred)) return { kind: "none" };
|
|
142
|
+
const e = exact();
|
|
143
|
+
return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
|
|
125
144
|
}
|
|
145
|
+
const e = exact();
|
|
126
146
|
let chosen: Plan<D> = preferred, target: Target = "band";
|
|
127
|
-
if (!preferred.draft || (
|
|
147
|
+
if (!preferred.draft || (e.draft && e.mode !== preferred.mode)) { chosen = e; target = "room"; }
|
|
128
148
|
if (!chosen.draft) return { kind: "fault", capacity: chosen.capacityRejected, message: chosen.reason };
|
|
129
149
|
if (chosen.after >= before || !fits(chosen.after + mandatory, room))
|
|
130
150
|
return { kind: "fault", capacity: !fits(chosen.after + mandatory, room), message: unreduced };
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "72d28833b7180debd3ea6a7ca0308b000f8d27671b42c4d2b73dfaaa7bbdad59";
|
package/src/compaction.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { exportSource } from "./source-export.js";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
|
-
import { bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart,
|
|
8
|
+
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
|
|
9
9
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
10
10
|
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
11
|
import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
@@ -83,6 +83,10 @@ export interface CompactionOptions {
|
|
|
83
83
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
84
84
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
85
85
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
86
|
+
/** The cut point: a fraction of the input room, above 0 and at most 1 (default 1). A cut is triggered at, and preferably planned to, the band,
|
|
87
|
+
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The exact room
|
|
88
|
+
* stays the limit of every throw, fault and the complete-context guard. Anything else is rejected when the compactor is installed. */
|
|
89
|
+
cutAt?: number;
|
|
86
90
|
/** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
|
|
87
91
|
* catalogue maximum output, because a transport may omit the output cap and the reply can then grow to that maximum. A host that knows its
|
|
88
92
|
* transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */
|
|
@@ -101,6 +105,8 @@ export interface CompactionOptions {
|
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
108
|
+
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
109
|
+
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
104
110
|
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
105
111
|
let semanticTransition: Promise<void> = Promise.resolve();
|
|
106
112
|
let semanticRequest = 0;
|
|
@@ -115,7 +121,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
115
121
|
if (found) { recorder.capture("observed-entry", "current-native-branch-memory-not-durable", { entryId: found.id, diagnosticReceiptId: proposalId, details: found.type === "compaction" ? found.details : undefined }); recorder.cut(); proposalId = undefined; }
|
|
116
122
|
});
|
|
117
123
|
const captureDraft = (draft: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>, ctx: ExtensionContext, reserve: number, reason: string, tokensBefore: number, retainCurrentInput: boolean, operatorManual: boolean, previousSummary?: string, budget?: { response: number; host: number; mandatory: number; target: Target; margin: number }) => recorder.measureInline(() => {
|
|
118
|
-
const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
|
|
124
|
+
const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
|
|
119
125
|
proposalId = receiptId(attached);
|
|
120
126
|
if (recorder.enabled) { try {
|
|
121
127
|
const filtered = permittedEntries(ctx.sessionManager.getEntries(), options.recallPolicy);
|
|
@@ -186,7 +192,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
186
192
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
187
193
|
const planReserve = (target: Target, room: number, mandatory: number, ctx: ExtensionContext) => {
|
|
188
194
|
identityFor(ctx);
|
|
189
|
-
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room) : room, factor, mandatory);
|
|
195
|
+
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room, cutAt) : room, factor, mandatory);
|
|
190
196
|
};
|
|
191
197
|
pi.on("message_end", (event, ctx) => {
|
|
192
198
|
if (event.message.role !== "assistant") return;
|
|
@@ -237,10 +243,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
237
243
|
throw new Error("Invalid active-input token ceiling");
|
|
238
244
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
239
245
|
};
|
|
240
|
-
// The exact room, and the band a cut is triggered at (budget.ts); every throw and every fault uses the exact room.
|
|
246
|
+
// The exact room, and the band a cut is triggered at (budget.ts, from this session's cutAt); every throw and every fault uses the exact room.
|
|
241
247
|
const limits = (ctx: ExtensionContext) => {
|
|
242
248
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
243
|
-
return { response, room, band: bandOf(room) };
|
|
249
|
+
return { response, room, band: bandOf(room, cutAt) };
|
|
244
250
|
};
|
|
245
251
|
const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
|
|
246
252
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
package/src/diagnostics.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { createRecall, type RecallPolicy } from './recall.js';
|
|
8
8
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
9
9
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
10
|
-
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
10
|
+
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
11
11
|
export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
|
|
12
12
|
const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
|
|
13
13
|
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
package/src/omissions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { buildGlobalIndexById } from './tuck/core/global-indices';
|
|
3
|
-
import {
|
|
3
|
+
import { parseRecords, splitSummary, transcriptHeader } from './tuck/core/transcript';
|
|
4
4
|
import { createRecall, type RecallPolicy } from './recall';
|
|
5
5
|
/** Native branch edits supply exclusion; original global indices supply provenance. */
|
|
6
6
|
export function omittedSourceIndices(entries: readonly SessionEntry[], branch: readonly SessionEntry[]): ReadonlySet<number> {
|
|
@@ -12,30 +12,30 @@ export function omittedSourceIndices(entries: readonly SessionEntry[], branch: r
|
|
|
12
12
|
}
|
|
13
13
|
return new Set([...edits].flatMap(([id, omitted]) => omitted && indices.has(id) ? [indices.get(id)!] : []));
|
|
14
14
|
}
|
|
15
|
-
/** Preserve surviving record bytes/order. No text matching or semantic inference. */
|
|
15
|
+
/** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
|
|
16
16
|
export function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>): string {
|
|
17
17
|
if (!omitted.size) return summary;
|
|
18
18
|
const split = splitSummary(summary);
|
|
19
19
|
if (!split) throw new Error('Cannot apply native source omission to an unsupported compaction summary');
|
|
20
|
-
const { body, closing } = split;
|
|
21
|
-
const records = parseRecords(body), lines = body ? body.split('\n') : [];
|
|
20
|
+
const { body, closing, version } = split;
|
|
21
|
+
const records = parseRecords(body, version), lines = body ? body.split('\n') : [];
|
|
22
22
|
const retained = records.flatMap((record, i) =>
|
|
23
23
|
(record.sourceIndex !== undefined && omitted.has(record.sourceIndex)) || record.sourceIndices?.some(index => omitted.has(index)) ? [] : [lines[i]!]);
|
|
24
|
-
return
|
|
24
|
+
return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Custom quotes carry native entry identity; no text matching or legacy fallback. */
|
|
28
28
|
export function filterCustomQuotes(summary: string, entries: readonly SessionEntry[], branch: readonly SessionEntry[], policy: RecallPolicy = {}): string {
|
|
29
29
|
const split = splitSummary(summary);
|
|
30
30
|
if (!split) throw new Error('Cannot resolve custom sources in an unsupported compaction summary');
|
|
31
|
-
const { body, closing } = split;
|
|
31
|
+
const { body, closing, version } = split;
|
|
32
32
|
const omitted = new Set(branch.flatMap(entry => entry.type === 'context_edit' && entry.replacement === null ? [entry.targetId] : []));
|
|
33
33
|
const eligible = createRecall(policy).isHistorySource, indices = buildGlobalIndexById(entries);
|
|
34
34
|
const globalCounts = new Map<string, number>(), branchSources = new Map<string, SessionEntry[]>();
|
|
35
35
|
for (const entry of entries) globalCounts.set(entry.id, (globalCounts.get(entry.id) ?? 0) + 1);
|
|
36
36
|
for (const entry of branch) { const found = branchSources.get(entry.id) ?? []; found.push(entry); branchSources.set(entry.id, found); }
|
|
37
37
|
const lines = body ? body.split('\n') : [];
|
|
38
|
-
const records = parseRecords(body);
|
|
38
|
+
const records = parseRecords(body, version);
|
|
39
39
|
const retained = records.flatMap((record, i) => {
|
|
40
40
|
if (record.role !== 'custom') return [lines[i]!];
|
|
41
41
|
const id = record.sourceEntryId, source = id === undefined ? undefined : branchSources.get(id);
|
|
@@ -45,7 +45,7 @@ export function filterCustomQuotes(summary: string, entries: readonly SessionEnt
|
|
|
45
45
|
if (!(entry.type === 'custom_message' || entry.type === 'message' && entry.message.role === 'custom') || !eligible(entry)) return [];
|
|
46
46
|
return [lines[i]!];
|
|
47
47
|
});
|
|
48
|
-
return
|
|
48
|
+
return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
|
|
49
49
|
}
|
|
50
50
|
type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
|
|
51
51
|
/** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
|