pi-condense 2.0.0

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 (45) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/PRUNING.md +1028 -0
  4. package/README.md +243 -0
  5. package/index.ts +858 -0
  6. package/package.json +56 -0
  7. package/src/batch-capture.ts +226 -0
  8. package/src/block-refs.test.ts +42 -0
  9. package/src/block-refs.ts +16 -0
  10. package/src/budget.test.ts +66 -0
  11. package/src/budget.ts +39 -0
  12. package/src/chain-compressor.test.ts +283 -0
  13. package/src/chain-compressor.ts +132 -0
  14. package/src/chain-detector.test.ts +302 -0
  15. package/src/chain-detector.ts +128 -0
  16. package/src/chain-range-prune.test.ts +522 -0
  17. package/src/chain-range-prune.ts +128 -0
  18. package/src/commands.test.ts +67 -0
  19. package/src/commands.ts +1207 -0
  20. package/src/config.ts +126 -0
  21. package/src/content-hash.ts +35 -0
  22. package/src/error-purge.test.ts +186 -0
  23. package/src/error-purge.ts +71 -0
  24. package/src/frontier.ts +62 -0
  25. package/src/indexer.ts +393 -0
  26. package/src/nested-placeholders.test.ts +82 -0
  27. package/src/nested-placeholders.ts +20 -0
  28. package/src/oversized-spill.integration.test.ts +73 -0
  29. package/src/protected.test.ts +62 -0
  30. package/src/protected.ts +51 -0
  31. package/src/pruner.test.ts +508 -0
  32. package/src/pruner.ts +156 -0
  33. package/src/query-tool.ts +78 -0
  34. package/src/range-compression.integration.test.ts +252 -0
  35. package/src/spill.test.ts +102 -0
  36. package/src/spill.ts +90 -0
  37. package/src/stats.test.ts +114 -0
  38. package/src/stats.ts +190 -0
  39. package/src/summarizer.test.ts +17 -0
  40. package/src/summarizer.ts +262 -0
  41. package/src/summary-refs.ts +61 -0
  42. package/src/thinking-strip.test.ts +175 -0
  43. package/src/thinking-strip.ts +42 -0
  44. package/src/tree-browser.ts +382 -0
  45. package/src/types.ts +764 -0
package/src/config.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
2
+ import { randomBytes } from "node:crypto";
3
+ import { join, dirname } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { ContextPruneConfig, PruneOn, SummarizerThinking } from "./types.js";
6
+ import { DEFAULT_CONFIG, PRUNE_ON_MODES, SUMMARIZER_THINKING_LEVELS } from "./types.js";
7
+
8
+ /**
9
+ * Settings location: the active pi agent's main `settings.json` under the
10
+ * `contextPrune` namespace, mirroring pi's own conventions for `compaction`,
11
+ * `retry`, `branchSummary`, etc. Pi's SettingsManager preserves unknown
12
+ * top-level keys when it rewrites settings, so the namespace coexists safely
13
+ * with pi's own settings.
14
+ *
15
+ * Resolved against `getAgentDir()` so it honors `PI_CODING_AGENT_DIR`
16
+ * (defaults to `~/.pi/agent`). Each pi preset directory therefore gets its
17
+ * own context-prune config — including its own summarizer model.
18
+ */
19
+ export const SETTINGS_PATH = join(getAgentDir(), "settings.json");
20
+
21
+ /** Top-level key under which context-prune state lives in `settings.json`. */
22
+ export const SETTINGS_KEY = "contextPrune" as const;
23
+
24
+ function isPruneOn(value: unknown): value is PruneOn {
25
+ return typeof value === "string" && PRUNE_ON_MODES.some((mode) => mode.value === value);
26
+ }
27
+
28
+ function isSummarizerThinking(value: unknown): value is SummarizerThinking {
29
+ return typeof value === "string" && SUMMARIZER_THINKING_LEVELS.some((level) => level.value === value);
30
+ }
31
+
32
+ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
33
+ const merged = { ...DEFAULT_CONFIG, ...existing };
34
+ return {
35
+ ...merged,
36
+ enabled: typeof merged.enabled === "boolean" ? merged.enabled : DEFAULT_CONFIG.enabled,
37
+ showPruneStatusLine:
38
+ typeof merged.showPruneStatusLine === "boolean"
39
+ ? merged.showPruneStatusLine
40
+ : DEFAULT_CONFIG.showPruneStatusLine,
41
+ pruneOn: isPruneOn(merged.pruneOn) ? merged.pruneOn : DEFAULT_CONFIG.pruneOn,
42
+ summarizerThinking: isSummarizerThinking(merged.summarizerThinking)
43
+ ? merged.summarizerThinking
44
+ : DEFAULT_CONFIG.summarizerThinking,
45
+ quietOversizedSkips:
46
+ typeof merged.quietOversizedSkips === "boolean"
47
+ ? merged.quietOversizedSkips
48
+ : DEFAULT_CONFIG.quietOversizedSkips,
49
+ minBatchChars:
50
+ typeof merged.minBatchChars === "number" &&
51
+ Number.isFinite(merged.minBatchChars) &&
52
+ merged.minBatchChars >= 0
53
+ ? Math.floor(merged.minBatchChars)
54
+ : DEFAULT_CONFIG.minBatchChars,
55
+ dedupByContentHash:
56
+ typeof merged.dedupByContentHash === "boolean"
57
+ ? merged.dedupByContentHash
58
+ : DEFAULT_CONFIG.dedupByContentHash,
59
+ autoBudgetThreshold:
60
+ typeof merged.autoBudgetThreshold === "number" &&
61
+ Number.isFinite(merged.autoBudgetThreshold) &&
62
+ merged.autoBudgetThreshold > 0 &&
63
+ merged.autoBudgetThreshold <= 1
64
+ ? merged.autoBudgetThreshold
65
+ : DEFAULT_CONFIG.autoBudgetThreshold,
66
+ spillThreshold:
67
+ typeof merged.spillThreshold === "number" &&
68
+ Number.isFinite(merged.spillThreshold) &&
69
+ merged.spillThreshold > 0
70
+ ? Math.floor(merged.spillThreshold)
71
+ : DEFAULT_CONFIG.spillThreshold,
72
+ spillPreviewBytes:
73
+ typeof merged.spillPreviewBytes === "number" &&
74
+ Number.isFinite(merged.spillPreviewBytes) &&
75
+ merged.spillPreviewBytes >= 0
76
+ ? Math.floor(merged.spillPreviewBytes)
77
+ : DEFAULT_CONFIG.spillPreviewBytes,
78
+ budgetTurnDelta:
79
+ typeof merged.budgetTurnDelta === "number" &&
80
+ Number.isFinite(merged.budgetTurnDelta) &&
81
+ merged.budgetTurnDelta > 0 &&
82
+ merged.budgetTurnDelta <= 1
83
+ ? merged.budgetTurnDelta
84
+ : DEFAULT_CONFIG.budgetTurnDelta,
85
+ };
86
+ }
87
+
88
+ async function readJsonObject(path: string): Promise<Record<string, unknown> | undefined> {
89
+ try {
90
+ const raw = await readFile(path, "utf-8");
91
+ const parsed = JSON.parse(raw);
92
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
93
+ return parsed as Record<string, unknown>;
94
+ }
95
+ return undefined;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or defaults. */
102
+ export async function loadConfig(): Promise<ContextPruneConfig> {
103
+ const main = await readJsonObject(SETTINGS_PATH);
104
+ const namespaced = main?.[SETTINGS_KEY];
105
+ if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
106
+ return normalize(namespaced as Partial<ContextPruneConfig>);
107
+ }
108
+ return { ...DEFAULT_CONFIG };
109
+ }
110
+
111
+ /**
112
+ * Writes the full config back to `<agent-dir>/settings.json` under
113
+ * {@link SETTINGS_KEY}, preserving every other top-level key in the file. Uses
114
+ * a tmp-file + atomic rename so concurrent pi writes (e.g. theme changes via
115
+ * `/settings`) cannot observe a partial file. We do not coordinate with pi's
116
+ * own internal lock since both writers do whole-file replacements and a
117
+ * last-write-wins race only loses a single change, never corrupts the file.
118
+ */
119
+ export async function saveConfig(config: ContextPruneConfig): Promise<void> {
120
+ const current = (await readJsonObject(SETTINGS_PATH)) ?? {};
121
+ const next = { ...current, [SETTINGS_KEY]: config };
122
+ await mkdir(dirname(SETTINGS_PATH), { recursive: true });
123
+ const tmpPath = `${SETTINGS_PATH}.${randomBytes(8).toString("hex")}.tmp`;
124
+ await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
125
+ await rename(tmpPath, SETTINGS_PATH);
126
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content-hash helper for the dedup pre-flush pass.
3
+ *
4
+ * Hashes (toolName, normalize(resultText)) via SHA-1 into a hex digest.
5
+ * Normalization is intentionally conservative: line-ending normalization
6
+ * (`\r\n` → `\n`), per-line trailing whitespace stripping, and a final
7
+ * whole-string trim. Internal whitespace, tabs, and capitalization are
8
+ * preserved so two genuinely different outputs do NOT collide.
9
+ *
10
+ * SHA-1 is overkill at this scale (~10⁵ records per long session at most)
11
+ * but is fast, built into Node's `crypto` module, and trivially readable.
12
+ *
13
+ * The `\0` separator between toolName and resultText prevents pathological
14
+ * collisions where two `(name, payload)` pairs concatenate to the same
15
+ * string (e.g. `("ab", "c")` vs `("a", "bc")`).
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+
20
+ function normalize(resultText: string): string {
21
+ const lf = resultText.replace(/\r\n/g, "\n");
22
+ return lf
23
+ .split("\n")
24
+ .map((line) => line.replace(/[ \t]+$/g, ""))
25
+ .join("\n")
26
+ .trim();
27
+ }
28
+
29
+ export function hashToolResult(toolName: string, resultText: string): string {
30
+ return createHash("sha1")
31
+ .update(toolName)
32
+ .update("\0")
33
+ .update(normalize(resultText))
34
+ .digest("hex");
35
+ }
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { purgeErroredArgs } from "./error-purge.js";
3
+ import type { ErrorPurgeConfig } from "./types.js";
4
+
5
+ const defaultConfig: ErrorPurgeConfig = {
6
+ enabled: true,
7
+ cooldownTurns: 2,
8
+ minArgChars: 10,
9
+ };
10
+
11
+ function makeAssistant(toolCallId: string, argsObj: Record<string, any>, turnN?: number) {
12
+ return {
13
+ role: "assistant",
14
+ content: [
15
+ {
16
+ type: "toolCall",
17
+ id: toolCallId,
18
+ name: "bash",
19
+ arguments: argsObj,
20
+ },
21
+ ],
22
+ timestamp: turnN ?? 1,
23
+ };
24
+ }
25
+
26
+ function makeToolResult(toolCallId: string, isError: boolean) {
27
+ return {
28
+ role: "toolResult",
29
+ toolCallId,
30
+ toolName: "bash",
31
+ content: [{ type: "text", text: isError ? "Error: file not found" : "ok" }],
32
+ isError,
33
+ timestamp: 2,
34
+ };
35
+ }
36
+
37
+ describe("purgeErroredArgs", () => {
38
+ it("returns input array reference unchanged when no errored tool results", () => {
39
+ const messages = [
40
+ makeAssistant("tc1", { cmd: "ls" }),
41
+ makeToolResult("tc1", false),
42
+ ];
43
+ const result = purgeErroredArgs(messages, defaultConfig);
44
+ expect(result).toBe(messages);
45
+ });
46
+
47
+ it("does not purge while still within cooldown", () => {
48
+ // Error at turn 1, current = turn 2, age = 1 < cooldownTurns 2
49
+ const messages = [
50
+ makeAssistant("tc1", { content: "a very long argument body here" }),
51
+ makeToolResult("tc1", true),
52
+ makeAssistant("tc2", { cmd: "ls" }),
53
+ makeToolResult("tc2", false),
54
+ ];
55
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2 });
56
+ expect(result).toBe(messages);
57
+ const asstMsg = result[0] as any;
58
+ expect(asstMsg.content[0].arguments).toEqual({ content: "a very long argument body here" });
59
+ });
60
+
61
+ it("purges args after cooldown when args meet minArgChars", () => {
62
+ const largeArgs = { content: "a very long argument body here" };
63
+ const messages = [
64
+ makeAssistant("tc1", largeArgs),
65
+ makeToolResult("tc1", true),
66
+ makeAssistant("tc2", { cmd: "ls" }),
67
+ makeToolResult("tc2", false),
68
+ makeAssistant("tc3", { cmd: "pwd" }),
69
+ makeToolResult("tc3", false),
70
+ ];
71
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 });
72
+ expect(result).not.toBe(messages);
73
+ const purgedAsst = result[0] as any;
74
+ const originalArgLen = JSON.stringify(largeArgs).length;
75
+ expect(purgedAsst.content[0].arguments).toEqual({
76
+ _purged: `<purged-errored-args size="${originalArgLen}"/>`,
77
+ });
78
+ // toolResult stays unchanged
79
+ expect((result[1] as any).content[0].text).toBe("Error: file not found");
80
+ });
81
+
82
+ it("does not purge when args are below minArgChars", () => {
83
+ const messages = [
84
+ makeAssistant("tc1", { x: "hi" }), // JSON is only ~10 chars
85
+ makeToolResult("tc1", true),
86
+ makeAssistant("tc2", { cmd: "ls" }),
87
+ makeToolResult("tc2", false),
88
+ makeAssistant("tc3", { cmd: "pwd" }),
89
+ makeToolResult("tc3", false),
90
+ ];
91
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 1000 });
92
+ expect(result).toBe(messages);
93
+ });
94
+
95
+ it("does not purge when isError is false", () => {
96
+ const messages = [
97
+ makeAssistant("tc1", { content: "a very long argument body here" }),
98
+ makeToolResult("tc1", false), // NOT an error
99
+ makeAssistant("tc2", { cmd: "ls" }),
100
+ makeToolResult("tc2", false),
101
+ makeAssistant("tc3", { cmd: "pwd" }),
102
+ makeToolResult("tc3", false),
103
+ ];
104
+ const result = purgeErroredArgs(messages, defaultConfig);
105
+ expect(result).toBe(messages);
106
+ });
107
+
108
+ it("only purges errored toolCalls in a multi-toolCall assistant message", () => {
109
+ const largeArgs = { content: "a very long argument body here that is big" };
110
+ const okArgs = { cmd: "ls" };
111
+ const messages = [
112
+ // One assistant with two toolCalls: tc-err is errored, tc-ok is not
113
+ {
114
+ role: "assistant",
115
+ content: [
116
+ { type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs },
117
+ { type: "toolCall", id: "tc-ok", name: "bash", arguments: okArgs },
118
+ ],
119
+ timestamp: 1,
120
+ },
121
+ makeToolResult("tc-err", true),
122
+ makeToolResult("tc-ok", false),
123
+ makeAssistant("tc2", { cmd: "pwd" }),
124
+ makeToolResult("tc2", false),
125
+ makeAssistant("tc3", { cmd: "date" }),
126
+ makeToolResult("tc3", false),
127
+ ];
128
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 });
129
+ expect(result).not.toBe(messages);
130
+ const asst = result[0] as any;
131
+ // errored one is purged
132
+ const originalArgLen = JSON.stringify(largeArgs).length;
133
+ expect(asst.content[0].arguments).toEqual({
134
+ _purged: `<purged-errored-args size="${originalArgLen}"/>`,
135
+ });
136
+ // non-errored one is untouched
137
+ expect(asst.content[1].arguments).toEqual(okArgs);
138
+ });
139
+
140
+ it("does not mutate the input messages array or any message object", () => {
141
+ const largeArgs = { content: "a very long argument body here" };
142
+ const messages = [
143
+ makeAssistant("tc1", largeArgs),
144
+ makeToolResult("tc1", true),
145
+ makeAssistant("tc2", { cmd: "ls" }),
146
+ makeToolResult("tc2", false),
147
+ makeAssistant("tc3", { cmd: "pwd" }),
148
+ makeToolResult("tc3", false),
149
+ ];
150
+ const originalAsst = messages[0];
151
+ const originalArgs = (messages[0] as any).content[0].arguments;
152
+ purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 });
153
+ // Input array unchanged
154
+ expect(messages[0]).toBe(originalAsst);
155
+ expect((messages[0] as any).content[0].arguments).toBe(originalArgs);
156
+ });
157
+
158
+ it("exactly-at-cooldown boundary: age === cooldownTurns is purged", () => {
159
+ // Error at turn 1, 2 more assistant turns → age = 2 = cooldownTurns (should purge)
160
+ const largeArgs = { content: "argument body that is long enough to purge" };
161
+ const messages = [
162
+ makeAssistant("tc1", largeArgs),
163
+ makeToolResult("tc1", true),
164
+ makeAssistant("tc2", { cmd: "ls" }),
165
+ makeToolResult("tc2", false),
166
+ makeAssistant("tc3", { cmd: "pwd" }),
167
+ makeToolResult("tc3", false),
168
+ ];
169
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 });
170
+ expect(result).not.toBe(messages);
171
+ expect((result[0] as any).content[0].arguments._purged).toBeDefined();
172
+ });
173
+
174
+ it("one-below-cooldown boundary: age === cooldownTurns - 1 is NOT purged", () => {
175
+ // Error at turn 1, 1 more assistant turn → age = 1 < cooldownTurns 2
176
+ const largeArgs = { content: "argument body that is long enough to purge" };
177
+ const messages = [
178
+ makeAssistant("tc1", largeArgs),
179
+ makeToolResult("tc1", true),
180
+ makeAssistant("tc2", { cmd: "ls" }),
181
+ makeToolResult("tc2", false),
182
+ ];
183
+ const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 });
184
+ expect(result).toBe(messages);
185
+ });
186
+ });
@@ -0,0 +1,71 @@
1
+ import type { ErrorPurgeConfig } from "./types.js";
2
+
3
+ /**
4
+ * Replaces the `arguments` body of failed toolCall blocks with a compact stub
5
+ * once the error is old enough to be beyond the cooldown window.
6
+ *
7
+ * Why only the arguments, not the whole toolCall or its toolResult:
8
+ * - The toolResult content (e.g. "Error: file not found") is small and carries
9
+ * the failure signal the model needs to understand what went wrong.
10
+ * - The toolCall block itself must remain so the provider can pair it with its
11
+ * result and avoid injecting a synthetic "No result provided" error.
12
+ * - The arguments body is what grows large — failed `write` / `edit` calls
13
+ * embed the full file content that will never be acted on again.
14
+ *
15
+ * Why the cooldown:
16
+ * - Gives the model 1–2 turns to retry before context is mutated. Purging
17
+ * immediately would remove the call detail before the model has had a
18
+ * chance to see the error and adapt.
19
+ *
20
+ * Turn index is computed internally by counting AssistantMessages in the input.
21
+ * This avoids threading a turn counter through index.ts.
22
+ */
23
+ export function purgeErroredArgs(messages: any[], config: ErrorPurgeConfig): any[] {
24
+ // Pass 1: collect errored toolCallIds → the turn index at which the error occurred.
25
+ // Turn index = number of AssistantMessages seen up to and including the one that
26
+ // issued the tool call (ToolResultMessages follow immediately after).
27
+ const erroredAtTurn = new Map<string, number>();
28
+ let turnCount = 0;
29
+ for (const msg of messages) {
30
+ if (msg.role === "assistant") {
31
+ // Count each assistant turn; toolResults referencing the turn come next.
32
+ turnCount++;
33
+ } else if (msg.role === "toolResult" && msg.isError === true) {
34
+ // Record the turn this errored call belongs to for cooldown comparison.
35
+ erroredAtTurn.set(msg.toolCallId, turnCount);
36
+ }
37
+ }
38
+
39
+ if (erroredAtTurn.size === 0) return messages;
40
+
41
+ const currentTurnIndex = turnCount;
42
+
43
+ // Pass 2: rewrite AssistantMessages whose toolCall args should be purged.
44
+ let anyModified = false;
45
+ const result = messages.map((msg) => {
46
+ if (msg.role !== "assistant") return msg;
47
+
48
+ let contentModified = false;
49
+ const newContent = (msg.content as any[]).map((block) => {
50
+ if (block.type !== "toolCall") return block;
51
+
52
+ const errorTurn = erroredAtTurn.get(block.id);
53
+ if (errorTurn === undefined) return block;
54
+
55
+ const age = currentTurnIndex - errorTurn;
56
+ if (age < config.cooldownTurns) return block;
57
+
58
+ const argBody = JSON.stringify(block.arguments);
59
+ if (argBody.length < config.minArgChars) return block;
60
+
61
+ contentModified = true;
62
+ return { ...block, arguments: { _purged: `<purged-errored-args size="${argBody.length}"/>` } };
63
+ });
64
+
65
+ if (!contentModified) return msg;
66
+ anyModified = true;
67
+ return { ...msg, content: newContent };
68
+ });
69
+
70
+ return anyModified ? result : messages;
71
+ }
@@ -0,0 +1,62 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { PruneFrontier } from "./types.js";
3
+ import { CUSTOM_TYPE_FRONTIER } from "./types.js";
4
+
5
+ /**
6
+ * Tracks the most recent completed prune-attempt boundary.
7
+ *
8
+ * The frontier advances when a prune attempt finishes, regardless of whether it
9
+ * produced a persisted summary or was skipped because the summary was larger
10
+ * than the raw tool outputs. It does not advance on operational failures.
11
+ */
12
+ export class PruneFrontierTracker {
13
+ private frontier: PruneFrontier | null = null;
14
+
15
+ reset(): void {
16
+ this.frontier = null;
17
+ }
18
+
19
+ get(): PruneFrontier | null {
20
+ return this.frontier ? { ...this.frontier } : null;
21
+ }
22
+
23
+ fromJSON(data: PruneFrontier): void {
24
+ if (!data?.lastAttemptedToolCallId) return;
25
+ this.frontier = {
26
+ lastAttemptedToolCallId: data.lastAttemptedToolCallId,
27
+ lastAttemptedToolName: data.lastAttemptedToolName ?? "unknown",
28
+ lastAttemptedTurnIndex: data.lastAttemptedTurnIndex ?? 0,
29
+ lastAttemptedTimestamp: data.lastAttemptedTimestamp ?? 0,
30
+ attemptedBatchCount: data.attemptedBatchCount ?? 0,
31
+ attemptedToolCallCount: data.attemptedToolCallCount ?? 0,
32
+ rawCharCount: data.rawCharCount ?? 0,
33
+ summaryCharCount: data.summaryCharCount ?? 0,
34
+ outcome: data.outcome ?? "summarized",
35
+ };
36
+ }
37
+
38
+ reconstructFromSession(ctx: ExtensionContext): void {
39
+ this.reset();
40
+ const branch = ctx.sessionManager.getBranch();
41
+ for (const entry of branch) {
42
+ if (
43
+ entry.type === "custom" &&
44
+ (entry as any).customType === CUSTOM_TYPE_FRONTIER
45
+ ) {
46
+ const data = (entry as any).data as PruneFrontier;
47
+ if (data) {
48
+ this.fromJSON(data);
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ advance(frontier: PruneFrontier): void {
55
+ this.frontier = { ...frontier };
56
+ }
57
+
58
+ persist(pi: ExtensionAPI): void {
59
+ if (!this.frontier) return;
60
+ pi.appendEntry(CUSTOM_TYPE_FRONTIER, this.frontier);
61
+ }
62
+ }