@mariozechner/pi-coding-agent 0.12.5 → 0.12.7

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 CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.12.7] - 2025-12-04
4
+
5
+ ### Added
6
+
7
+ - **Context Compaction**: Long sessions can now be compacted to reduce context usage while preserving recent conversation history. ([#92](https://github.com/badlogic/pi-mono/issues/92), [docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md#context-compaction))
8
+ - `/compact [instructions]`: Manually compact context with optional custom instructions for the summary
9
+ - `/autocompact`: Toggle automatic compaction when context exceeds threshold
10
+ - Compaction summarizes older messages while keeping recent messages (default 20k tokens) verbatim
11
+ - Auto-compaction triggers when context reaches `contextWindow - reserveTokens` (default 16k reserve)
12
+ - Compacted sessions show a collapsible summary in the TUI (toggle with `o` key)
13
+ - HTML exports include compaction summaries as collapsible sections
14
+ - RPC mode supports `{"type":"compact"}` command and auto-compaction (emits compaction events)
15
+ - **Branch Source Tracking**: Branched sessions now store `branchedFrom` in the session header, containing the path to the original session file. Useful for tracing session lineage.
4
16
 
5
17
  ## [0.12.5] - 2025-12-03
6
18
 
package/README.md CHANGED
@@ -17,6 +17,7 @@ Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the
17
17
  - [Project Context Files](#project-context-files)
18
18
  - [Image Support](#image-support)
19
19
  - [Session Management](#session-management)
20
+ - [Context Compaction](#context-compaction)
20
21
  - [CLI Options](#cli-options)
21
22
  - [Tools](#tools)
22
23
  - [Usage](#usage)
@@ -25,7 +26,6 @@ Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the
25
26
  - [To-Dos](#to-dos)
26
27
  - [Planning](#planning)
27
28
  - [Background Bash](#background-bash)
28
- - [Planned Features](#planned-features)
29
29
  - [License](#license)
30
30
  - [See Also](#see-also)
31
31
 
@@ -502,6 +502,27 @@ Clear the conversation context and start a fresh session:
502
502
 
503
503
  Aborts any in-flight agent work, clears all messages, and creates a new session file.
504
504
 
505
+ ### /compact
506
+
507
+ Manually compact the conversation context to reduce token usage:
508
+
509
+ ```
510
+ /compact # Use default summary instructions
511
+ /compact Focus on the API changes # Custom instructions for summary
512
+ ```
513
+
514
+ Creates a summary of the conversation so far, replacing the message history with a condensed version. See [Context Compaction](#context-compaction) for details.
515
+
516
+ ### /autocompact
517
+
518
+ Toggle automatic context compaction:
519
+
520
+ ```
521
+ /autocompact
522
+ ```
523
+
524
+ When enabled, the agent automatically compacts context when usage exceeds the configured threshold. The current state (enabled/disabled) is shown after toggling. See [Context Compaction](#context-compaction) for details.
525
+
505
526
  ### Custom Slash Commands
506
527
 
507
528
  Define reusable prompt templates as Markdown files that appear in the `/` autocomplete.
@@ -758,6 +779,70 @@ To use a specific session file instead of auto-generating one:
758
779
  pi --session /path/to/my-session.jsonl
759
780
  ```
760
781
 
782
+ ## Context Compaction
783
+
784
+ > **Note:** Compaction is lossy and should generally be avoided. The agent loses access to the full conversation after compaction. Size your tasks to avoid hitting context limits. Alternatively, when context usage approaches 85-90%, ask the agent to write a summary to a markdown file, iterate until it captures everything important, then start a new session with that file.
785
+ >
786
+ > That said, compaction does not destroy history. The full session is preserved in the session file with compaction events as markers. You can branch (`/branch`) from any previous message, and branched sessions include the complete history. If compaction missed something, you can ask the agent to read the session file directly.
787
+
788
+ Long sessions can exhaust the model's context window. Context compaction summarizes older conversation history while preserving recent messages, allowing sessions to continue indefinitely.
789
+
790
+ ### How It Works
791
+
792
+ When compaction runs (manually via `/compact` or automatically):
793
+
794
+ 1. A **cut point** is calculated to keep approximately `keepRecentTokens` (default: 20k) worth of recent messages
795
+ 2. Messages **before** the cut point are sent to the model for summarization
796
+ 3. Messages **after** the cut point are kept verbatim
797
+ 4. The summary replaces the older messages as a "context handoff" message
798
+ 5. If there was a previous compaction, its summary is included as context for the new summary (chaining)
799
+
800
+ Cut points are always placed at user message boundaries to preserve turn integrity.
801
+
802
+ The summary is displayed in the TUI as a collapsible block (toggle with `o` key). HTML exports also show compaction summaries as collapsible sections.
803
+
804
+ ### Manual Compaction
805
+
806
+ Use `/compact` to manually trigger compaction at any time:
807
+
808
+ ```
809
+ /compact # Default summary
810
+ /compact Focus on the API changes # Custom instructions guide what to emphasize
811
+ ```
812
+
813
+ Custom instructions are appended to the default summary prompt, letting you focus the summary on specific aspects of the conversation.
814
+
815
+ ### Automatic Compaction
816
+
817
+ Enable auto-compaction with `/autocompact`. When enabled, compaction triggers automatically when context usage exceeds `contextWindow - reserveTokens`.
818
+
819
+ The context percentage is shown in the footer. When it approaches 100%, auto-compaction kicks in (if enabled) or you should manually compact.
820
+
821
+ ### Configuration
822
+
823
+ Power users can tune compaction behavior in `~/.pi/agent/settings.json`:
824
+
825
+ ```json
826
+ {
827
+ "compaction": {
828
+ "enabled": true,
829
+ "reserveTokens": 16384,
830
+ "keepRecentTokens": 20000
831
+ }
832
+ }
833
+ ```
834
+
835
+ - **enabled**: Whether auto-compaction is active (toggle with `/autocompact`)
836
+ - **reserveTokens**: Token buffer to keep free (default: 16,384). Auto-compaction triggers when `contextTokens > contextWindow - reserveTokens`
837
+ - **keepRecentTokens**: How many tokens worth of recent messages to preserve verbatim (default: 20,000). Older messages are summarized.
838
+
839
+ ### Supported Modes
840
+
841
+ Context compaction works in both interactive and RPC modes:
842
+
843
+ - **Interactive**: Use `/compact` and `/autocompact` commands
844
+ - **RPC**: Send `{"type":"compact"}` for manual compaction. Auto-compaction emits `{"type":"compaction","auto":true}` events. See [RPC documentation](docs/rpc.md) for details.
845
+
761
846
  ## CLI Options
762
847
 
763
848
  ```bash
@@ -1130,15 +1215,6 @@ The agent can read, update, and reference the plan as it works. Unlike ephemeral
1130
1215
 
1131
1216
  **pi does not and will not implement background bash execution.** Instead, tell the agent to use `tmux` or something like [tterminal-cp](https://mariozechner.at/posts/2025-08-15-mcp-vs-cli/). Bonus points: you can watch the agent interact with a CLI like a debugger and even intervene if necessary.
1132
1217
 
1133
- ## Planned Features
1134
-
1135
- Things that might happen eventually:
1136
-
1137
- - **Auto-compaction**: Currently, watch the context percentage at the bottom. When it approaches 80%, either:
1138
- - Ask the agent to write a summary .md file you can load in a new session
1139
- - Switch to a model with bigger context (e.g., Gemini) using `/model` and either continue with that model, or let it summarize the session to a .md file to be loaded in a new session
1140
- - **Better RPC mode docs**: It works, you'll figure it out (see `test/rpc-example.ts`)
1141
-
1142
1218
  ## Development
1143
1219
 
1144
1220
  ### Forking / Rebranding
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Context compaction for long sessions.
3
+ *
4
+ * Pure functions for compaction logic. The session manager handles I/O,
5
+ * and after compaction the session is reloaded.
6
+ */
7
+ import type { AppMessage } from "@mariozechner/pi-agent-core";
8
+ import type { Model, Usage } from "@mariozechner/pi-ai";
9
+ import type { CompactionEntry, SessionEntry } from "./session-manager.js";
10
+ export interface CompactionSettings {
11
+ enabled: boolean;
12
+ reserveTokens: number;
13
+ keepRecentTokens: number;
14
+ }
15
+ export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
16
+ /**
17
+ * Calculate total context tokens from usage.
18
+ */
19
+ export declare function calculateContextTokens(usage: Usage): number;
20
+ /**
21
+ * Find the last non-aborted assistant message usage from session entries.
22
+ */
23
+ export declare function getLastAssistantUsage(entries: SessionEntry[]): Usage | null;
24
+ /**
25
+ * Check if compaction should trigger based on context usage.
26
+ */
27
+ export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean;
28
+ /**
29
+ * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
30
+ * Returns the entry index of the first message to keep (a user message for turn integrity).
31
+ *
32
+ * Only considers entries between `startIndex` and `endIndex` (exclusive).
33
+ */
34
+ export declare function findCutPoint(entries: SessionEntry[], startIndex: number, endIndex: number, keepRecentTokens: number): number;
35
+ /**
36
+ * Generate a summary of the conversation using the LLM.
37
+ */
38
+ export declare function generateSummary(currentMessages: AppMessage[], model: Model<any>, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string): Promise<string>;
39
+ /**
40
+ * Calculate compaction and generate summary.
41
+ * Returns the CompactionEntry to append to the session file.
42
+ *
43
+ * @param entries - All session entries
44
+ * @param model - Model to use for summarization
45
+ * @param settings - Compaction settings
46
+ * @param apiKey - API key for LLM
47
+ * @param signal - Optional abort signal
48
+ * @param customInstructions - Optional custom focus for the summary
49
+ */
50
+ export declare function compact(entries: SessionEntry[], model: Model<any>, settings: CompactionSettings, apiKey: string, signal?: AbortSignal, customInstructions?: string): Promise<CompactionEntry>;
51
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAoB,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAE1E,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAM1E,MAAM,WAAW,kBAAkB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;CACzB;AAED,eAAO,MAAM,2BAA2B,EAAE,kBAIzC,CAAC;AAMF;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAE3D;AAeD;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,KAAK,GAAG,IAAI,CAS3E;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAGjH;AAoBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,YAAY,EAAE,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,GACtB,MAAM,CAgDR;AAiBD;;GAEG;AACH,wBAAsB,eAAe,CACpC,eAAe,EAAE,UAAU,EAAE,EAC7B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,MAAM,CAAC,CAwBjB;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,OAAO,CAC5B,OAAO,EAAE,YAAY,EAAE,EACvB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,QAAQ,EAAE,kBAAkB,EAC5B,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,eAAe,CAAC,CA6D1B","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\tconst summarizationMessages = [\n\t\t...currentMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: prompt,\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Context compaction for long sessions.
3
+ *
4
+ * Pure functions for compaction logic. The session manager handles I/O,
5
+ * and after compaction the session is reloaded.
6
+ */
7
+ import { complete } from "@mariozechner/pi-ai";
8
+ export const DEFAULT_COMPACTION_SETTINGS = {
9
+ enabled: true,
10
+ reserveTokens: 16384,
11
+ keepRecentTokens: 20000,
12
+ };
13
+ // ============================================================================
14
+ // Token calculation
15
+ // ============================================================================
16
+ /**
17
+ * Calculate total context tokens from usage.
18
+ */
19
+ export function calculateContextTokens(usage) {
20
+ return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
21
+ }
22
+ /**
23
+ * Get usage from an assistant message if available.
24
+ */
25
+ function getAssistantUsage(msg) {
26
+ if (msg.role === "assistant" && "usage" in msg) {
27
+ const assistantMsg = msg;
28
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.usage) {
29
+ return assistantMsg.usage;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Find the last non-aborted assistant message usage from session entries.
36
+ */
37
+ export function getLastAssistantUsage(entries) {
38
+ for (let i = entries.length - 1; i >= 0; i--) {
39
+ const entry = entries[i];
40
+ if (entry.type === "message") {
41
+ const usage = getAssistantUsage(entry.message);
42
+ if (usage)
43
+ return usage;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Check if compaction should trigger based on context usage.
50
+ */
51
+ export function shouldCompact(contextTokens, contextWindow, settings) {
52
+ if (!settings.enabled)
53
+ return false;
54
+ return contextTokens > contextWindow - settings.reserveTokens;
55
+ }
56
+ // ============================================================================
57
+ // Cut point detection
58
+ // ============================================================================
59
+ /**
60
+ * Find indices of message entries that are user messages (turn boundaries).
61
+ */
62
+ function findTurnBoundaries(entries, startIndex, endIndex) {
63
+ const boundaries = [];
64
+ for (let i = startIndex; i < endIndex; i++) {
65
+ const entry = entries[i];
66
+ if (entry.type === "message" && entry.message.role === "user") {
67
+ boundaries.push(i);
68
+ }
69
+ }
70
+ return boundaries;
71
+ }
72
+ /**
73
+ * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
74
+ * Returns the entry index of the first message to keep (a user message for turn integrity).
75
+ *
76
+ * Only considers entries between `startIndex` and `endIndex` (exclusive).
77
+ */
78
+ export function findCutPoint(entries, startIndex, endIndex, keepRecentTokens) {
79
+ const boundaries = findTurnBoundaries(entries, startIndex, endIndex);
80
+ if (boundaries.length === 0) {
81
+ return startIndex; // No user messages, keep everything in range
82
+ }
83
+ // Collect assistant usages walking backwards from endIndex
84
+ const assistantUsages = [];
85
+ for (let i = endIndex - 1; i >= startIndex; i--) {
86
+ const entry = entries[i];
87
+ if (entry.type === "message") {
88
+ const usage = getAssistantUsage(entry.message);
89
+ if (usage) {
90
+ assistantUsages.push({
91
+ index: i,
92
+ tokens: calculateContextTokens(usage),
93
+ });
94
+ }
95
+ }
96
+ }
97
+ if (assistantUsages.length === 0) {
98
+ // No usage info, keep last turn only
99
+ return boundaries[boundaries.length - 1];
100
+ }
101
+ // Walk through and find where cumulative token difference exceeds keepRecentTokens
102
+ const newestTokens = assistantUsages[0].tokens;
103
+ let cutIndex = startIndex; // Default: keep everything in range
104
+ for (let i = 1; i < assistantUsages.length; i++) {
105
+ const tokenDiff = newestTokens - assistantUsages[i].tokens;
106
+ if (tokenDiff >= keepRecentTokens) {
107
+ // Find the turn boundary at or before the assistant we want to keep
108
+ const lastKeptAssistantIndex = assistantUsages[i - 1].index;
109
+ for (let b = boundaries.length - 1; b >= 0; b--) {
110
+ if (boundaries[b] <= lastKeptAssistantIndex) {
111
+ cutIndex = boundaries[b];
112
+ break;
113
+ }
114
+ }
115
+ break;
116
+ }
117
+ }
118
+ return cutIndex;
119
+ }
120
+ // ============================================================================
121
+ // Summarization
122
+ // ============================================================================
123
+ const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
124
+
125
+ Include:
126
+ - Current progress and key decisions made
127
+ - Important context, constraints, or user preferences
128
+ - Absolute file paths of any relevant files that were read or modified
129
+ - What remains to be done (clear next steps)
130
+ - Any critical data, examples, or references needed to continue
131
+
132
+ Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
133
+ /**
134
+ * Generate a summary of the conversation using the LLM.
135
+ */
136
+ export async function generateSummary(currentMessages, model, reserveTokens, apiKey, signal, customInstructions) {
137
+ const maxTokens = Math.floor(0.8 * reserveTokens);
138
+ const prompt = customInstructions
139
+ ? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
140
+ : SUMMARIZATION_PROMPT;
141
+ const summarizationMessages = [
142
+ ...currentMessages,
143
+ {
144
+ role: "user",
145
+ content: prompt,
146
+ timestamp: Date.now(),
147
+ },
148
+ ];
149
+ const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
150
+ const textContent = response.content
151
+ .filter((c) => c.type === "text")
152
+ .map((c) => c.text)
153
+ .join("\n");
154
+ return textContent;
155
+ }
156
+ // ============================================================================
157
+ // Main compaction function
158
+ // ============================================================================
159
+ /**
160
+ * Calculate compaction and generate summary.
161
+ * Returns the CompactionEntry to append to the session file.
162
+ *
163
+ * @param entries - All session entries
164
+ * @param model - Model to use for summarization
165
+ * @param settings - Compaction settings
166
+ * @param apiKey - API key for LLM
167
+ * @param signal - Optional abort signal
168
+ * @param customInstructions - Optional custom focus for the summary
169
+ */
170
+ export async function compact(entries, model, settings, apiKey, signal, customInstructions) {
171
+ // Don't compact if the last entry is already a compaction
172
+ if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
173
+ throw new Error("Already compacted");
174
+ }
175
+ // Find previous compaction boundary
176
+ let prevCompactionIndex = -1;
177
+ for (let i = entries.length - 1; i >= 0; i--) {
178
+ if (entries[i].type === "compaction") {
179
+ prevCompactionIndex = i;
180
+ break;
181
+ }
182
+ }
183
+ const boundaryStart = prevCompactionIndex + 1;
184
+ const boundaryEnd = entries.length;
185
+ // Get token count before compaction
186
+ const lastUsage = getLastAssistantUsage(entries);
187
+ const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
188
+ // Find cut point (entry index) within the valid range
189
+ const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
190
+ // Extract messages to summarize (before the cut point)
191
+ const messagesToSummarize = [];
192
+ for (let i = boundaryStart; i < firstKeptEntryIndex; i++) {
193
+ const entry = entries[i];
194
+ if (entry.type === "message") {
195
+ messagesToSummarize.push(entry.message);
196
+ }
197
+ }
198
+ // Also include the previous summary if there was a compaction
199
+ if (prevCompactionIndex >= 0) {
200
+ const prevCompaction = entries[prevCompactionIndex];
201
+ // Prepend the previous summary as context
202
+ messagesToSummarize.unshift({
203
+ role: "user",
204
+ content: `Previous session summary:\n${prevCompaction.summary}`,
205
+ timestamp: Date.now(),
206
+ });
207
+ }
208
+ // Generate summary from messages before the cut point
209
+ const summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens, apiKey, signal, customInstructions);
210
+ return {
211
+ type: "compaction",
212
+ timestamp: new Date().toISOString(),
213
+ summary,
214
+ firstKeptEntryIndex,
215
+ tokensBefore,
216
+ };
217
+ }
218
+ //# sourceMappingURL=compaction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.js","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\tconst summarizationMessages = [\n\t\t...currentMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: prompt,\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
@@ -1,12 +1,14 @@
1
1
  import type { AgentState } from "@mariozechner/pi-agent-core";
2
2
  import type { SessionManager } from "./session-manager.js";
3
3
  /**
4
- * Export session to a self-contained HTML file matching TUI visual style
4
+ * Export session to HTML using SessionManager and AgentState.
5
+ * Used by TUI's /export command.
5
6
  */
6
7
  export declare function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string;
7
8
  /**
8
- * Export a session file to HTML (standalone, without AgentState or SessionManager)
9
- * Auto-detects format: session manager format or streaming event format
9
+ * Export session file to HTML (standalone, without AgentState).
10
+ * Auto-detects format: session manager format or streaming event format.
11
+ * Used by CLI for exporting arbitrary session files.
10
12
  */
11
13
  export declare function exportFromFile(inputPath: string, outputPath?: string): string;
12
14
  //# sourceMappingURL=export-html.d.ts.map