@pi-vault/pi-dcp 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.1.0] - 2026-06-15
8
+
9
+ ### Added
10
+ - Dynamic context pruning for Pi sessions, including stale duplicate tool-output removal.
11
+ - Error-pruning strategies that clear old failed tool results after they stop being useful.
12
+ - A `compress` tool with range-based and message-based compression modes.
13
+ - DCP message IDs, compression block tracking, and proactive high-context nudges.
14
+ - Slash commands for operational control: `dcp:help`, `dcp:context`, `dcp:stats`, `dcp:sweep`, `dcp:manual`, `dcp:decompress`, `dcp:recompress`, and `dcp:lifetime`.
15
+ - Session-state persistence, lifetime statistics, debug logging, and status-bar reporting.
16
+ - Vitest coverage across config loading, strategies, message transforms, compression state, commands, persistence, pipeline behavior, and end-to-end extension lifecycle integration.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanh Hoang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # pi-dcp
2
+
3
+ Keep long Pi sessions usable by pruning stale tool output and nudging the model to compress old context before the window fills up.
4
+
5
+ ## Install
6
+
7
+ Install from npm with Pi:
8
+
9
+ ```bash
10
+ pi install npm:@pi-vault/pi-dcp
11
+ ```
12
+
13
+ Then restart Pi.
14
+
15
+ To try the repo locally before publishing:
16
+
17
+ ```bash
18
+ pi -e /absolute/path/to/pi-dcp
19
+ ```
20
+
21
+ ## Configure
22
+
23
+ Create `~/.pi/agent/extensions/dcp.json`:
24
+
25
+ ```json
26
+ {
27
+ "enabled": true,
28
+ "debug": false,
29
+ "compress": {
30
+ "mode": "range",
31
+ "permission": "allow",
32
+ "maxContextPercent": 80,
33
+ "minContextPercent": 50,
34
+ "nudgeFrequency": 5
35
+ },
36
+ "strategies": {
37
+ "deduplication": { "enabled": true },
38
+ "purgeErrors": { "enabled": true, "turns": 4 }
39
+ }
40
+ }
41
+ ```
42
+
43
+ All fields are optional. If the file is missing, pi-dcp uses built-in defaults.
44
+
45
+ ## What it does
46
+
47
+ - Removes stale duplicate tool outputs automatically
48
+ - Prunes older error-heavy tool results that no longer help the model
49
+ - Injects context warnings before the conversation gets too large
50
+ - Exposes a `compress` tool so the model can summarize older context instead of losing it
51
+
52
+ ## Common commands
53
+
54
+ - `dcp:help` — list available commands
55
+ - `dcp:context` — show current context usage and active compression state
56
+ - `dcp:stats` — show session token savings and compression counts
57
+ - `dcp:sweep` — force-prune all currently eligible tool outputs
58
+ - `dcp:manual on` — pause automatic compression and switch to manual control
59
+ - `dcp:manual off` — resume automatic compression
60
+ - `dcp:decompress <blockId>` — restore a compressed block
61
+ - `dcp:recompress <blockId>` — reactivate a decompressed block
62
+ - `dcp:lifetime` — show aggregate stats across saved sessions
63
+
64
+ ## Recommended usage
65
+
66
+ 1. Install the package and start with the default config.
67
+ 2. Let automatic pruning handle duplicate and stale outputs.
68
+ 3. Use `dcp:stats` when you want to confirm token savings.
69
+ 4. Turn on `dcp:manual on` if you want to decide when compression happens.
70
+ 5. Use `dcp:sweep` before a long design or debugging session if the context already contains a lot of dead tool output.
71
+
72
+ ## Debug logging
73
+
74
+ Set `"debug": true` in `~/.pi/agent/extensions/dcp.json` to write logs to:
75
+
76
+ ```text
77
+ {sessionDir}/dcp/logs/YYYY-MM-DD.log
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ pnpm install
84
+ pnpm run check
85
+ pnpm run release:check
86
+ ```
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@pi-vault/pi-dcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension for dynamic context pruning — incremental tool output pruning and conversation compression",
6
+ "author": "Lanh Hoang <lanhhoang@users.noreply.github.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/pi-vault/pi-dcp#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/pi-vault/pi-dcp.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/pi-vault/pi-dcp/issues"
15
+ },
16
+ "keywords": [
17
+ "pi",
18
+ "pi-coding-agent",
19
+ "pi-package",
20
+ "pi-extension",
21
+ "pi-dcp",
22
+ "context-pruning",
23
+ "compression"
24
+ ],
25
+ "scripts": {
26
+ "format": "biome format --write .",
27
+ "lint": "biome lint .",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "check": "biome lint . && tsc --noEmit && vitest run",
31
+ "pack:dry-run": "pnpm pack --dry-run",
32
+ "release:check": "pnpm check && pnpm run pack:dry-run"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./src/index.ts"
37
+ ]
38
+ },
39
+ "engines": {
40
+ "node": ">=22.19.0"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "CHANGELOG.md",
45
+ "LICENSE",
46
+ "README.md"
47
+ ],
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-agent-core": "*",
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "typebox": "*"
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "^2.5.0",
55
+ "@earendil-works/pi-agent-core": "^0.79.4",
56
+ "@earendil-works/pi-coding-agent": "^0.79.4",
57
+ "@types/node": "^25.9.3",
58
+ "typebox": "^1.2.12",
59
+ "typescript": "^6.0.3",
60
+ "vitest": "^4.1.8"
61
+ }
62
+ }
@@ -0,0 +1,25 @@
1
+ import type { ContextUsage, SessionState } from "../state/types.ts";
2
+
3
+ export function contextCommand(
4
+ state: SessionState,
5
+ contextUsage: ContextUsage | undefined,
6
+ ): string {
7
+ const lines: string[] = ["DCP Context Usage:"];
8
+
9
+ if (contextUsage && contextUsage.tokens != null && contextUsage.percent != null) {
10
+ lines.push(` Tokens: ${contextUsage.tokens} / ${contextUsage.contextWindow} (${contextUsage.percent.toFixed(1)}%)`);
11
+ } else if (contextUsage) {
12
+ lines.push(` Tokens: unavailable (context window: ${contextUsage.contextWindow})`);
13
+ } else {
14
+ lines.push(" Tokens: unavailable");
15
+ }
16
+
17
+ lines.push(` Pruned tool outputs: ${state.prune.tools.size}`);
18
+ lines.push(` Active compression blocks: ${state.prune.messages.activeBlockIds.size}`);
19
+ lines.push(` Total blocks: ${state.prune.messages.blocksById.size}`);
20
+ lines.push(` Tool cache entries: ${state.toolParameters.size}`);
21
+ lines.push(` Current turn: ${state.currentTurn}`);
22
+ lines.push(` Manual mode: ${state.manualMode || "off"}`);
23
+
24
+ return lines.join("\n");
25
+ }
@@ -0,0 +1,21 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+
3
+ export function decompressCommand(state: SessionState, args: string): string {
4
+ const blockIdStr = args.trim();
5
+ if (!blockIdStr) return "Usage: dcp:decompress <blockId>";
6
+
7
+ const blockId = Number.parseInt(blockIdStr, 10);
8
+ if (Number.isNaN(blockId)) return `Invalid block ID: ${blockIdStr}`;
9
+
10
+ const block = state.prune.messages.blocksById.get(blockId);
11
+ if (!block) return `Block ${blockId} not found.`;
12
+ if (!block.active) return `Block ${blockId} is already inactive.`;
13
+
14
+ block.active = false;
15
+ block.deactivatedByUser = true;
16
+ block.deactivatedAt = Date.now();
17
+ state.prune.messages.activeBlockIds.delete(blockId);
18
+ state.prune.messages.activeByAnchorIndex.delete(block.anchorIndex);
19
+
20
+ return `Block ${blockId} deactivated. Original messages will be restored on next context pass.`;
21
+ }
@@ -0,0 +1,14 @@
1
+ export function helpCommand(): string {
2
+ return [
3
+ "DCP Commands:",
4
+ "",
5
+ " dcp:help - Show this help",
6
+ " dcp:context - Show context usage breakdown",
7
+ " dcp:stats - Show compression statistics",
8
+ " dcp:sweep - Force-prune all eligible tool outputs",
9
+ " dcp:manual [on|off] - Toggle manual compression mode",
10
+ " dcp:decompress <blockId> - Deactivate a compression block",
11
+ " dcp:recompress <blockId> - Reactivate a deactivated block",
12
+ " dcp:lifetime - Show aggregate statistics across all sessions",
13
+ ].join("\n");
14
+ }
@@ -0,0 +1,13 @@
1
+ import { loadAllSessionStats } from "../state/persistence.ts";
2
+
3
+ export function lifetimeCommand(sessionsParentDir: string): string {
4
+ const stats = loadAllSessionStats(sessionsParentDir);
5
+
6
+ return [
7
+ "DCP Lifetime Statistics:",
8
+ ` Sessions tracked: ${stats.sessionCount} sessions`,
9
+ ` Total tokens saved: ${stats.totalTokensSaved}`,
10
+ ` Total tools pruned: ${stats.totalToolsPruned}`,
11
+ ` Total messages compressed: ${stats.totalMessagesCompressed}`,
12
+ ].join("\n");
13
+ }
@@ -0,0 +1,21 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+
3
+ export function manualCommand(state: SessionState, args: string): string {
4
+ const arg = args.trim().toLowerCase();
5
+
6
+ if (!arg) {
7
+ return `Manual mode: ${state.manualMode || "off"}`;
8
+ }
9
+
10
+ if (arg === "on") {
11
+ state.manualMode = "active";
12
+ return "Manual mode: on. Automatic compression is paused.";
13
+ }
14
+
15
+ if (arg === "off") {
16
+ state.manualMode = false;
17
+ return "Manual mode: off. Automatic compression resumed.";
18
+ }
19
+
20
+ return "Usage: dcp:manual [on|off]";
21
+ }
@@ -0,0 +1,22 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+
3
+ export function recompressCommand(state: SessionState, args: string): string {
4
+ const blockIdStr = args.trim();
5
+ if (!blockIdStr) return "Usage: dcp:recompress <blockId>";
6
+
7
+ const blockId = Number.parseInt(blockIdStr, 10);
8
+ if (Number.isNaN(blockId)) return `Invalid block ID: ${blockIdStr}`;
9
+
10
+ const block = state.prune.messages.blocksById.get(blockId);
11
+ if (!block) return `Block ${blockId} not found.`;
12
+ if (block.active) return `Block ${blockId} is already active.`;
13
+ if (!block.deactivatedByUser) return `Block ${blockId} was not deactivated by user. Cannot reactivate.`;
14
+
15
+ block.active = true;
16
+ block.deactivatedByUser = false;
17
+ block.deactivatedAt = undefined;
18
+ state.prune.messages.activeBlockIds.add(blockId);
19
+ state.prune.messages.activeByAnchorIndex.set(block.anchorIndex, blockId);
20
+
21
+ return `Block ${blockId} reactivated. Compression will apply on next context pass.`;
22
+ }
@@ -0,0 +1,79 @@
1
+ import * as path from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { SessionState } from "../state/types.ts";
4
+ import type { DcpConfig } from "../config.ts";
5
+ import { helpCommand } from "./help.ts";
6
+ import { contextCommand } from "./context.ts";
7
+ import { statsCommand } from "./stats.ts";
8
+ import { sweepCommand } from "./sweep.ts";
9
+ import { manualCommand } from "./manual.ts";
10
+ import { decompressCommand } from "./decompress.ts";
11
+ import { recompressCommand } from "./recompress.ts";
12
+ import { lifetimeCommand } from "./lifetime.ts";
13
+
14
+ export function registerDcpCommands(
15
+ pi: ExtensionAPI,
16
+ state: SessionState,
17
+ config: DcpConfig,
18
+ ): void {
19
+ pi.registerCommand("dcp:help", {
20
+ description: "Show DCP command help",
21
+ handler: async (_args, ctx) => {
22
+ ctx.ui.notify(helpCommand(), "info");
23
+ },
24
+ });
25
+
26
+ pi.registerCommand("dcp:context", {
27
+ description: "Show context usage breakdown",
28
+ handler: async (_args, ctx) => {
29
+ const usage = ctx.getContextUsage();
30
+ ctx.ui.notify(
31
+ contextCommand(state, usage ?? undefined),
32
+ "info",
33
+ );
34
+ },
35
+ });
36
+
37
+ pi.registerCommand("dcp:stats", {
38
+ description: "Show compression statistics",
39
+ handler: async (_args, ctx) => {
40
+ ctx.ui.notify(statsCommand(state), "info");
41
+ },
42
+ });
43
+
44
+ pi.registerCommand("dcp:sweep", {
45
+ description: "Force-prune all eligible tool outputs",
46
+ handler: async (_args, ctx) => {
47
+ ctx.ui.notify(sweepCommand(state, config), "info");
48
+ },
49
+ });
50
+
51
+ pi.registerCommand("dcp:manual", {
52
+ description: "Toggle manual compression mode",
53
+ handler: async (args, ctx) => {
54
+ ctx.ui.notify(manualCommand(state, args), "info");
55
+ },
56
+ });
57
+
58
+ pi.registerCommand("dcp:decompress", {
59
+ description: "Deactivate a compression block",
60
+ handler: async (args, ctx) => {
61
+ ctx.ui.notify(decompressCommand(state, args), "info");
62
+ },
63
+ });
64
+
65
+ pi.registerCommand("dcp:recompress", {
66
+ description: "Reactivate a deactivated compression block",
67
+ handler: async (args, ctx) => {
68
+ ctx.ui.notify(recompressCommand(state, args), "info");
69
+ },
70
+ });
71
+
72
+ pi.registerCommand("dcp:lifetime", {
73
+ description: "Show aggregate statistics across all sessions",
74
+ handler: async (_args, ctx) => {
75
+ const parentDir = path.resolve(ctx.sessionManager.getSessionDir(), "..");
76
+ ctx.ui.notify(lifetimeCommand(parentDir), "info");
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,11 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+
3
+ export function statsCommand(state: SessionState): string {
4
+ return [
5
+ "DCP Session Statistics:",
6
+ ` Tools pruned: ${state.stats.toolsPruned}`,
7
+ ` Total tokens saved (pruning): ${state.stats.totalPruneTokens}`,
8
+ ` Messages compressed: ${state.stats.messagesCompressed}`,
9
+ ` Prune token counter: ${state.stats.pruneTokenCounter}`,
10
+ ].join("\n");
11
+ }
@@ -0,0 +1,8 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+ import type { DcpConfig } from "../config.ts";
3
+ import { sweepAll } from "../strategies/runner.ts";
4
+
5
+ export function sweepCommand(state: SessionState, config: DcpConfig): string {
6
+ const result = sweepAll(state, config);
7
+ return `Sweep complete: ${result.pruned} tool outputs pruned, ~${result.tokensSaved} tokens saved.`;
8
+ }
@@ -0,0 +1,148 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState } from "../state/types.ts";
3
+ import type { DcpConfig } from "../config.ts";
4
+ import { resolveBoundaryIndex, resolveSelection } from "./search.ts";
5
+ import {
6
+ allocateBlockId,
7
+ allocateRunId,
8
+ applyCompressionState,
9
+ wrapCompressedSummary,
10
+ COMPRESSED_BLOCK_HEADER,
11
+ } from "./state.ts";
12
+ import { countTokens } from "../utils/tokens.ts";
13
+
14
+ export interface CompressArgs {
15
+ topic: string;
16
+ mode: "range" | "message";
17
+ content?: Array<{
18
+ startId: string;
19
+ endId: string;
20
+ summary: string;
21
+ }>;
22
+ targets?: Array<{
23
+ messageId: string;
24
+ summary: string;
25
+ }>;
26
+ }
27
+
28
+ interface NormalizedEntry {
29
+ startIndex: number;
30
+ endIndex: number;
31
+ summary: string;
32
+ messageCount: number;
33
+ }
34
+
35
+ /**
36
+ * Handle any compress tool call regardless of mode.
37
+ * Normalizes input, resolves boundaries, applies compression state.
38
+ */
39
+ export function handleCompress(
40
+ state: SessionState,
41
+ _config: DcpConfig,
42
+ messages: AgentMessage[],
43
+ args: CompressArgs,
44
+ ): string {
45
+ const entries = normalizeEntries(state, messages, args);
46
+ const runId = allocateRunId(state);
47
+ let totalCompressed = 0;
48
+
49
+ for (const entry of entries) {
50
+ const blockId = allocateBlockId(state);
51
+ const wrappedSummary = wrapCompressedSummary(blockId, entry.summary);
52
+ const summaryTokens = countTokens(wrappedSummary);
53
+ const compressMessageIndex = messages.length - 1;
54
+
55
+ applyCompressionState(state, {
56
+ blockId,
57
+ runId,
58
+ topic: args.topic,
59
+ batchTopic: args.topic,
60
+ mode: args.mode,
61
+ startIndex: entry.startIndex,
62
+ endIndex: entry.endIndex,
63
+ anchorIndex: entry.startIndex,
64
+ compressMessageIndex,
65
+ summary: wrappedSummary,
66
+ summaryTokens,
67
+ consumedBlockIds: [],
68
+ });
69
+
70
+ totalCompressed += entry.messageCount;
71
+ }
72
+
73
+ return `Compressed ${totalCompressed} messages into ${COMPRESSED_BLOCK_HEADER}.`;
74
+ }
75
+
76
+ /**
77
+ * Normalize range or message args into a common form.
78
+ * Validates input and resolves boundary IDs to indices.
79
+ */
80
+ function normalizeEntries(
81
+ state: SessionState,
82
+ messages: AgentMessage[],
83
+ args: CompressArgs,
84
+ ): NormalizedEntry[] {
85
+ if (args.mode === "range") {
86
+ if (!args.content || args.content.length === 0) {
87
+ throw new Error("content array is required and must not be empty");
88
+ }
89
+
90
+ return args.content.map((entry) => {
91
+ if (!entry.startId || !entry.endId || !entry.summary) {
92
+ throw new Error(
93
+ "Each content entry requires startId, endId, and summary",
94
+ );
95
+ }
96
+
97
+ const startIndex = resolveBoundaryIndex(state, entry.startId);
98
+ if (startIndex === undefined) {
99
+ throw new Error(
100
+ `startId ${entry.startId} is not available. It may have been pruned or compressed. ` +
101
+ `Choose a message ID (m0001) or block ref (b1) visible in the current context.`,
102
+ );
103
+ }
104
+
105
+ const endIndex = resolveBoundaryIndex(state, entry.endId);
106
+ if (endIndex === undefined) {
107
+ throw new Error(
108
+ `endId ${entry.endId} is not available. It may have been pruned or compressed. ` +
109
+ `Choose a message ID (m0001) or block ref (b1) visible in the current context.`,
110
+ );
111
+ }
112
+
113
+ const selection = resolveSelection(messages, startIndex, endIndex);
114
+ return {
115
+ startIndex: selection.startIndex,
116
+ endIndex: selection.endIndex,
117
+ summary: entry.summary,
118
+ messageCount: selection.messageIndices.length,
119
+ };
120
+ });
121
+ }
122
+
123
+ // mode === "message"
124
+ if (!args.targets || args.targets.length === 0) {
125
+ throw new Error("targets array is required and must not be empty");
126
+ }
127
+
128
+ return args.targets.map((target) => {
129
+ if (!target.messageId || !target.summary) {
130
+ throw new Error("Each target requires messageId and summary");
131
+ }
132
+
133
+ const index = resolveBoundaryIndex(state, target.messageId);
134
+ if (index === undefined) {
135
+ throw new Error(
136
+ `messageId ${target.messageId} is not available. It may have been pruned or compressed. ` +
137
+ `Choose a message ID (m0001) visible in the current context.`,
138
+ );
139
+ }
140
+
141
+ return {
142
+ startIndex: index,
143
+ endIndex: index,
144
+ summary: target.summary,
145
+ messageCount: 1,
146
+ };
147
+ });
148
+ }
@@ -0,0 +1,62 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState } from "../state/types.ts";
3
+ import { parseBoundaryId } from "../utils/message-ids.ts";
4
+
5
+ /**
6
+ * Resolve a boundary ID (m0001 or b1) to a message array index.
7
+ */
8
+ export function resolveBoundaryIndex(
9
+ state: SessionState,
10
+ boundaryId: string,
11
+ ): number | undefined {
12
+ const parsed = parseBoundaryId(boundaryId);
13
+ if (!parsed) return undefined;
14
+
15
+ if (parsed.type === "message") {
16
+ return state.messageIds.byRef.get(boundaryId);
17
+ }
18
+
19
+ if (parsed.type === "block") {
20
+ // Find the anchor index for this block
21
+ for (const [anchorIndex, blockId] of state.prune.messages.activeByAnchorIndex) {
22
+ if (blockId === parsed.blockId) return anchorIndex;
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ return undefined;
28
+ }
29
+
30
+ export interface SelectionResult {
31
+ messageIndices: number[];
32
+ startIndex: number;
33
+ endIndex: number;
34
+ }
35
+
36
+ /**
37
+ * Collect message indices in a range [startIndex, endIndex].
38
+ */
39
+ export function resolveSelection(
40
+ messages: AgentMessage[],
41
+ startIndex: number,
42
+ endIndex: number,
43
+ ): SelectionResult {
44
+ if (startIndex > endIndex) {
45
+ throw new Error(
46
+ `startId appears after endId in the conversation. Start must come before end.`,
47
+ );
48
+ }
49
+
50
+ if (startIndex < 0 || endIndex >= messages.length) {
51
+ throw new Error(
52
+ `Boundary indices out of range. Valid range: 0-${messages.length - 1}`,
53
+ );
54
+ }
55
+
56
+ const messageIndices: number[] = [];
57
+ for (let i = startIndex; i <= endIndex; i++) {
58
+ messageIndices.push(i);
59
+ }
60
+
61
+ return { messageIndices, startIndex, endIndex };
62
+ }