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.
Files changed (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -5
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +7 -4
  5. package/dist/src/budget.d.ts +17 -4
  6. package/dist/src/budget.js +33 -10
  7. package/dist/src/build-id.d.ts +1 -1
  8. package/dist/src/build-id.js +1 -1
  9. package/dist/src/compaction.d.ts +4 -0
  10. package/dist/src/compaction.js +8 -5
  11. package/dist/src/diagnostics.d.ts +2 -1
  12. package/dist/src/omissions.d.ts +1 -1
  13. package/dist/src/omissions.js +8 -8
  14. package/dist/src/tuck/core/brief.d.ts +13 -43
  15. package/dist/src/tuck/core/brief.js +39 -255
  16. package/dist/src/tuck/core/format.d.ts +2 -6
  17. package/dist/src/tuck/core/format.js +4 -10
  18. package/dist/src/tuck/core/normalize.js +20 -43
  19. package/dist/src/tuck/core/shrink.d.ts +33 -0
  20. package/dist/src/tuck/core/shrink.js +221 -0
  21. package/dist/src/tuck/core/summarize.d.ts +15 -7
  22. package/dist/src/tuck/core/summarize.js +10 -23
  23. package/dist/src/tuck/core/transcript.d.ts +24 -13
  24. package/dist/src/tuck/core/transcript.js +243 -83
  25. package/dist/src/tuck/types.d.ts +4 -14
  26. package/dist/src/tuck.d.ts +8 -10
  27. package/dist/src/tuck.js +23 -25
  28. package/index.ts +8 -5
  29. package/package.json +1 -1
  30. package/src/budget.ts +29 -9
  31. package/src/build-id.ts +1 -1
  32. package/src/compaction.ts +11 -5
  33. package/src/diagnostics.ts +1 -1
  34. package/src/omissions.ts +8 -8
  35. package/src/tuck/core/brief.ts +46 -265
  36. package/src/tuck/core/format.ts +4 -11
  37. package/src/tuck/core/normalize.ts +20 -43
  38. package/src/tuck/core/shrink.ts +218 -0
  39. package/src/tuck/core/summarize.ts +20 -25
  40. package/src/tuck/core/transcript.ts +234 -82
  41. package/src/tuck/types.ts +7 -19
  42. package/src/tuck.ts +30 -32
  43. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  44. package/dist/src/tuck/core/brief-meter.js +0 -273
  45. package/dist/src/tuck/core/rank.d.ts +0 -42
  46. package/dist/src/tuck/core/rank.js +0 -292
  47. package/src/tuck/core/brief-meter.ts +0 -235
  48. 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")) return value;
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.1",
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
- /** The limit a cut is triggered at and preferably planned to; the exact room stays the limit of every throw and fault. */
20
- export const bandOf = (room: number): number => room - Math.ceil(Math.max(0, room) * TRIGGER_MARGIN_FRACTION);
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 ranked). */
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 2% smaller. Only
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 exact = plan("room");
120
- if (exact.draft && exact.after < before && fits(exact.after + mandatory, band)) return { kind: "cut", target: "room", plan: exact };
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.draft && preferred.after < before && fits(preferred.after + mandatory, band))) return { kind: "none" };
124
- return exact.draft && exact.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
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 || (exact.draft && exact.mode !== preferred.mode)) { chosen = exact; target = "room"; }
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 = "2a0ebe00760f2226d888fbca503dbde44d32623cb644186a6dd2eef59b65fbe5";
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).
@@ -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 { TRANSCRIPT_HEADER, parseRecords, splitSummary } from './tuck/core/transcript';
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 TRANSCRIPT_HEADER + '\n' + retained.join('\n') + closing;
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 TRANSCRIPT_HEADER + '\n' + retained.join('\n') + closing;
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. */