@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 +16 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/package.json +62 -0
- package/src/commands/context.ts +25 -0
- package/src/commands/decompress.ts +21 -0
- package/src/commands/help.ts +14 -0
- package/src/commands/lifetime.ts +13 -0
- package/src/commands/manual.ts +21 -0
- package/src/commands/recompress.ts +22 -0
- package/src/commands/register.ts +79 -0
- package/src/commands/stats.ts +11 -0
- package/src/commands/sweep.ts +8 -0
- package/src/compress/handler.ts +148 -0
- package/src/compress/search.ts +62 -0
- package/src/compress/state.ts +140 -0
- package/src/config.ts +253 -0
- package/src/index.ts +217 -0
- package/src/logger.ts +49 -0
- package/src/messages/inject.ts +149 -0
- package/src/messages/priority.ts +75 -0
- package/src/messages/prune.ts +103 -0
- package/src/messages/strip.ts +23 -0
- package/src/messages/sync.ts +55 -0
- package/src/pipeline.ts +64 -0
- package/src/prompts/compress-message.ts +19 -0
- package/src/prompts/nudges.ts +44 -0
- package/src/prompts/system.ts +37 -0
- package/src/state/persistence.ts +111 -0
- package/src/state/state.ts +102 -0
- package/src/state/tool-cache.ts +87 -0
- package/src/state/types.ts +138 -0
- package/src/strategies/deduplication.ts +30 -0
- package/src/strategies/protected-patterns.ts +77 -0
- package/src/strategies/purge-errors.ts +18 -0
- package/src/strategies/runner.ts +149 -0
- package/src/utils/message-content.ts +85 -0
- package/src/utils/message-ids.ts +45 -0
- package/src/utils/tokens.ts +54 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { SessionState, CompressionBlock } from "../state/types.ts";
|
|
2
|
+
import { formatBlockRef } from "../utils/message-ids.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefix used in wrapped summary headers/footers.
|
|
6
|
+
* Format: `[Compressed Block b{id}]\n{summary}\n[End Block b{id}]`
|
|
7
|
+
*
|
|
8
|
+
* The block ref (e.g. "b1") serves as the model-visible anchor for
|
|
9
|
+
* referencing compressed content in subsequent compress calls.
|
|
10
|
+
*/
|
|
11
|
+
export const COMPRESSED_BLOCK_HEADER = "Compressed Block";
|
|
12
|
+
|
|
13
|
+
export function allocateBlockId(state: SessionState): number {
|
|
14
|
+
const id = state.prune.messages.nextBlockId;
|
|
15
|
+
state.prune.messages.nextBlockId = id + 1;
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function allocateRunId(state: SessionState): number {
|
|
20
|
+
const id = state.prune.messages.nextRunId;
|
|
21
|
+
state.prune.messages.nextRunId = id + 1;
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a summary with block delimiters visible to the model.
|
|
27
|
+
* The delimiters let the model reference this block by its ref (e.g. "b1")
|
|
28
|
+
* as a boundary in future compress calls.
|
|
29
|
+
*/
|
|
30
|
+
export function wrapCompressedSummary(blockId: number, summary: string): string {
|
|
31
|
+
const ref = formatBlockRef(blockId);
|
|
32
|
+
return `[${COMPRESSED_BLOCK_HEADER} ${ref}]\n${summary}\n[End Block ${ref}]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApplyCompressionParams {
|
|
36
|
+
blockId: number;
|
|
37
|
+
runId: number;
|
|
38
|
+
topic: string;
|
|
39
|
+
batchTopic?: string;
|
|
40
|
+
mode: "range" | "message";
|
|
41
|
+
startIndex: number;
|
|
42
|
+
endIndex: number;
|
|
43
|
+
anchorIndex: number;
|
|
44
|
+
compressMessageIndex: number;
|
|
45
|
+
summary: string;
|
|
46
|
+
summaryTokens: number;
|
|
47
|
+
consumedBlockIds: number[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyCompressionState(
|
|
51
|
+
state: SessionState,
|
|
52
|
+
params: ApplyCompressionParams,
|
|
53
|
+
): { messageIndices: number[] } {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const messageIndices: number[] = [];
|
|
56
|
+
|
|
57
|
+
// Create the block
|
|
58
|
+
const block: CompressionBlock = {
|
|
59
|
+
blockId: params.blockId,
|
|
60
|
+
runId: params.runId,
|
|
61
|
+
active: true,
|
|
62
|
+
deactivatedByUser: false,
|
|
63
|
+
compressedTokens: 0,
|
|
64
|
+
summaryTokens: params.summaryTokens,
|
|
65
|
+
durationMs: 0,
|
|
66
|
+
mode: params.mode,
|
|
67
|
+
topic: params.topic,
|
|
68
|
+
batchTopic: params.batchTopic,
|
|
69
|
+
startIndex: params.startIndex,
|
|
70
|
+
endIndex: params.endIndex,
|
|
71
|
+
anchorIndex: params.anchorIndex,
|
|
72
|
+
compressMessageIndex: params.compressMessageIndex,
|
|
73
|
+
includedBlockIds: [],
|
|
74
|
+
consumedBlockIds: params.consumedBlockIds,
|
|
75
|
+
parentBlockIds: [],
|
|
76
|
+
directMessageIndices: [],
|
|
77
|
+
directToolIds: [],
|
|
78
|
+
effectiveMessageIndices: [],
|
|
79
|
+
effectiveToolIds: [],
|
|
80
|
+
createdAt: now,
|
|
81
|
+
deactivatedAt: undefined,
|
|
82
|
+
deactivatedByBlockId: undefined,
|
|
83
|
+
summary: params.summary,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Mark messages in range
|
|
87
|
+
let totalTokens = 0;
|
|
88
|
+
for (let i = params.startIndex; i <= params.endIndex; i++) {
|
|
89
|
+
messageIndices.push(i);
|
|
90
|
+
|
|
91
|
+
let entry = state.prune.messages.byMessageIndex.get(i);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
entry = {
|
|
94
|
+
tokenCount: 0,
|
|
95
|
+
blockIds: [],
|
|
96
|
+
activeBlockIds: [],
|
|
97
|
+
};
|
|
98
|
+
state.prune.messages.byMessageIndex.set(i, entry);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!entry.blockIds.includes(params.blockId)) {
|
|
102
|
+
entry.blockIds.push(params.blockId);
|
|
103
|
+
}
|
|
104
|
+
if (!entry.activeBlockIds.includes(params.blockId)) {
|
|
105
|
+
entry.activeBlockIds.push(params.blockId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
totalTokens += entry.tokenCount;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
block.compressedTokens = totalTokens;
|
|
112
|
+
block.directMessageIndices = messageIndices;
|
|
113
|
+
block.effectiveMessageIndices = messageIndices;
|
|
114
|
+
|
|
115
|
+
// Deactivate consumed blocks
|
|
116
|
+
for (const consumedId of params.consumedBlockIds) {
|
|
117
|
+
const consumed = state.prune.messages.blocksById.get(consumedId);
|
|
118
|
+
if (consumed) {
|
|
119
|
+
consumed.active = false;
|
|
120
|
+
consumed.deactivatedAt = now;
|
|
121
|
+
consumed.deactivatedByBlockId = params.blockId;
|
|
122
|
+
state.prune.messages.activeBlockIds.delete(consumedId);
|
|
123
|
+
|
|
124
|
+
// Find and remove anchor mapping
|
|
125
|
+
for (const [anchorIdx, bId] of state.prune.messages.activeByAnchorIndex) {
|
|
126
|
+
if (bId === consumedId) {
|
|
127
|
+
state.prune.messages.activeByAnchorIndex.delete(anchorIdx);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
block.includedBlockIds.push(consumedId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Store the block
|
|
135
|
+
state.prune.messages.blocksById.set(params.blockId, block);
|
|
136
|
+
state.prune.messages.activeBlockIds.add(params.blockId);
|
|
137
|
+
state.prune.messages.activeByAnchorIndex.set(params.anchorIndex, params.blockId);
|
|
138
|
+
|
|
139
|
+
return { messageIndices };
|
|
140
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface DcpConfig {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
debug: boolean;
|
|
6
|
+
compress: CompressConfig;
|
|
7
|
+
manualMode: ManualModeConfig;
|
|
8
|
+
strategies: StrategiesConfig;
|
|
9
|
+
protectedFilePatterns: string[];
|
|
10
|
+
nudgeNotification: "off" | "minimal" | "detailed";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CompressConfig {
|
|
14
|
+
mode: "range" | "message";
|
|
15
|
+
permission: "allow" | "deny";
|
|
16
|
+
maxContextPercent: number;
|
|
17
|
+
minContextPercent: number;
|
|
18
|
+
nudgeFrequency: number;
|
|
19
|
+
iterationNudgeThreshold: number;
|
|
20
|
+
nudgeForce: "strong" | "soft";
|
|
21
|
+
protectedTools: string[];
|
|
22
|
+
protectUserMessages: boolean;
|
|
23
|
+
protectTags: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ManualModeConfig {
|
|
27
|
+
default: false | "active";
|
|
28
|
+
automaticStrategies: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StrategiesConfig {
|
|
32
|
+
deduplication: DeduplicationConfig;
|
|
33
|
+
purgeErrors: PurgeErrorsConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeduplicationConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
protectedTools: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PurgeErrorsConfig {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
turns: number;
|
|
44
|
+
protectedTools: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_CONFIG: DcpConfig = {
|
|
48
|
+
enabled: true,
|
|
49
|
+
debug: false,
|
|
50
|
+
compress: {
|
|
51
|
+
mode: "range",
|
|
52
|
+
permission: "allow",
|
|
53
|
+
maxContextPercent: 80,
|
|
54
|
+
minContextPercent: 50,
|
|
55
|
+
nudgeFrequency: 5,
|
|
56
|
+
iterationNudgeThreshold: 15,
|
|
57
|
+
nudgeForce: "soft",
|
|
58
|
+
protectedTools: ["compress"],
|
|
59
|
+
protectUserMessages: false,
|
|
60
|
+
protectTags: false,
|
|
61
|
+
},
|
|
62
|
+
manualMode: {
|
|
63
|
+
default: false,
|
|
64
|
+
automaticStrategies: true,
|
|
65
|
+
},
|
|
66
|
+
strategies: {
|
|
67
|
+
deduplication: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
protectedTools: [],
|
|
70
|
+
},
|
|
71
|
+
purgeErrors: {
|
|
72
|
+
enabled: true,
|
|
73
|
+
turns: 4,
|
|
74
|
+
protectedTools: [],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
protectedFilePatterns: [],
|
|
78
|
+
nudgeNotification: "minimal",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tool names always protected from pruning strategies.
|
|
83
|
+
* Pi's core tools that should never have their outputs removed.
|
|
84
|
+
*/
|
|
85
|
+
export const BASE_PROTECTED_TOOLS = [
|
|
86
|
+
"compress",
|
|
87
|
+
"bash",
|
|
88
|
+
"read",
|
|
89
|
+
"write",
|
|
90
|
+
"edit",
|
|
91
|
+
"grep",
|
|
92
|
+
"find",
|
|
93
|
+
"ls",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
97
|
+
"enabled", "debug", "compress", "manualMode", "strategies",
|
|
98
|
+
"protectedFilePatterns", "nudgeNotification",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const KNOWN_COMPRESS_KEYS = new Set([
|
|
102
|
+
"mode", "permission", "maxContextPercent", "minContextPercent",
|
|
103
|
+
"nudgeFrequency", "iterationNudgeThreshold", "nudgeForce",
|
|
104
|
+
"protectedTools", "protectUserMessages", "protectTags",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
export interface LoadConfigResult {
|
|
108
|
+
config: DcpConfig;
|
|
109
|
+
warnings: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load DCP configuration from a single JSON file.
|
|
114
|
+
* Falls back to defaults on missing file, parse error, or invalid content.
|
|
115
|
+
* Returns warnings for unknown keys and out-of-range values.
|
|
116
|
+
*
|
|
117
|
+
* @param configFilePath - Absolute path to dcp.json (typically resolved via getAgentDir())
|
|
118
|
+
*/
|
|
119
|
+
export function loadConfig(configFilePath: string): LoadConfigResult {
|
|
120
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
121
|
+
const warnings: string[] = [];
|
|
122
|
+
|
|
123
|
+
const parsed = parseConfigFile(configFilePath);
|
|
124
|
+
if (parsed) {
|
|
125
|
+
// Check for unknown top-level keys
|
|
126
|
+
for (const key of Object.keys(parsed)) {
|
|
127
|
+
if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
|
|
128
|
+
warnings.push(`Unknown config key "${key}" — ignored`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for unknown compress keys
|
|
133
|
+
if (parsed.compress && typeof parsed.compress === "object") {
|
|
134
|
+
for (const key of Object.keys(parsed.compress as object)) {
|
|
135
|
+
if (!KNOWN_COMPRESS_KEYS.has(key)) {
|
|
136
|
+
warnings.push(`Unknown compress key "${key}" — ignored`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mergeConfig(config, parsed);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate ranges
|
|
145
|
+
if (config.compress.maxContextPercent > 100) {
|
|
146
|
+
warnings.push(`maxContextPercent (${config.compress.maxContextPercent}) exceeds 100, reset to default`);
|
|
147
|
+
config.compress.maxContextPercent = DEFAULT_CONFIG.compress.maxContextPercent;
|
|
148
|
+
}
|
|
149
|
+
if (config.compress.minContextPercent > 100) {
|
|
150
|
+
warnings.push(`minContextPercent (${config.compress.minContextPercent}) exceeds 100, reset to default`);
|
|
151
|
+
config.compress.minContextPercent = DEFAULT_CONFIG.compress.minContextPercent;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (config.compress.maxContextPercent <= config.compress.minContextPercent) {
|
|
155
|
+
config.compress.maxContextPercent = DEFAULT_CONFIG.compress.maxContextPercent;
|
|
156
|
+
config.compress.minContextPercent = DEFAULT_CONFIG.compress.minContextPercent;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { config, warnings };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseConfigFile(
|
|
163
|
+
filePath: string,
|
|
164
|
+
): Record<string, unknown> | undefined {
|
|
165
|
+
try {
|
|
166
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
167
|
+
const parsed = JSON.parse(content);
|
|
168
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
169
|
+
return parsed as Record<string, unknown>;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mergeConfig(target: DcpConfig, source: Record<string, unknown>): void {
|
|
178
|
+
if (typeof source.enabled === "boolean") target.enabled = source.enabled;
|
|
179
|
+
if (typeof source.debug === "boolean") target.debug = source.debug;
|
|
180
|
+
if (typeof source.nudgeNotification === "string") {
|
|
181
|
+
if (["off", "minimal", "detailed"].includes(source.nudgeNotification)) {
|
|
182
|
+
target.nudgeNotification = source.nudgeNotification as
|
|
183
|
+
| "off"
|
|
184
|
+
| "minimal"
|
|
185
|
+
| "detailed";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(source.protectedFilePatterns)) {
|
|
189
|
+
target.protectedFilePatterns = source.protectedFilePatterns.filter(
|
|
190
|
+
(p): p is string => typeof p === "string",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (source.compress && typeof source.compress === "object") {
|
|
195
|
+
const c = source.compress as Record<string, unknown>;
|
|
196
|
+
if (c.mode === "range" || c.mode === "message")
|
|
197
|
+
target.compress.mode = c.mode;
|
|
198
|
+
if (c.permission === "allow" || c.permission === "deny")
|
|
199
|
+
target.compress.permission = c.permission;
|
|
200
|
+
if (typeof c.maxContextPercent === "number" && c.maxContextPercent > 0)
|
|
201
|
+
target.compress.maxContextPercent = c.maxContextPercent;
|
|
202
|
+
if (typeof c.minContextPercent === "number" && c.minContextPercent > 0)
|
|
203
|
+
target.compress.minContextPercent = c.minContextPercent;
|
|
204
|
+
if (typeof c.nudgeFrequency === "number" && c.nudgeFrequency >= 1)
|
|
205
|
+
target.compress.nudgeFrequency = c.nudgeFrequency;
|
|
206
|
+
if (
|
|
207
|
+
typeof c.iterationNudgeThreshold === "number" &&
|
|
208
|
+
c.iterationNudgeThreshold >= 1
|
|
209
|
+
)
|
|
210
|
+
target.compress.iterationNudgeThreshold = c.iterationNudgeThreshold;
|
|
211
|
+
if (c.nudgeForce === "strong" || c.nudgeForce === "soft")
|
|
212
|
+
target.compress.nudgeForce = c.nudgeForce;
|
|
213
|
+
if (Array.isArray(c.protectedTools))
|
|
214
|
+
target.compress.protectedTools = c.protectedTools.filter(
|
|
215
|
+
(t): t is string => typeof t === "string",
|
|
216
|
+
);
|
|
217
|
+
if (typeof c.protectUserMessages === "boolean")
|
|
218
|
+
target.compress.protectUserMessages = c.protectUserMessages;
|
|
219
|
+
if (typeof c.protectTags === "boolean")
|
|
220
|
+
target.compress.protectTags = c.protectTags;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (source.manualMode && typeof source.manualMode === "object") {
|
|
224
|
+
const m = source.manualMode as Record<string, unknown>;
|
|
225
|
+
if (m.default === false || m.default === "active")
|
|
226
|
+
target.manualMode.default = m.default;
|
|
227
|
+
if (typeof m.automaticStrategies === "boolean")
|
|
228
|
+
target.manualMode.automaticStrategies = m.automaticStrategies;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (source.strategies && typeof source.strategies === "object") {
|
|
232
|
+
const s = source.strategies as Record<string, unknown>;
|
|
233
|
+
if (s.deduplication && typeof s.deduplication === "object") {
|
|
234
|
+
const d = s.deduplication as Record<string, unknown>;
|
|
235
|
+
if (typeof d.enabled === "boolean")
|
|
236
|
+
target.strategies.deduplication.enabled = d.enabled;
|
|
237
|
+
if (Array.isArray(d.protectedTools))
|
|
238
|
+
target.strategies.deduplication.protectedTools =
|
|
239
|
+
d.protectedTools.filter((t): t is string => typeof t === "string");
|
|
240
|
+
}
|
|
241
|
+
if (s.purgeErrors && typeof s.purgeErrors === "object") {
|
|
242
|
+
const p = s.purgeErrors as Record<string, unknown>;
|
|
243
|
+
if (typeof p.enabled === "boolean")
|
|
244
|
+
target.strategies.purgeErrors.enabled = p.enabled;
|
|
245
|
+
if (typeof p.turns === "number" && p.turns >= 1)
|
|
246
|
+
target.strategies.purgeErrors.turns = p.turns;
|
|
247
|
+
if (Array.isArray(p.protectedTools))
|
|
248
|
+
target.strategies.purgeErrors.protectedTools = p.protectedTools.filter(
|
|
249
|
+
(t): t is string => typeof t === "string",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { loadConfig } from "./config.ts";
|
|
8
|
+
import { handleCompress, type CompressArgs } from "./compress/handler.ts";
|
|
9
|
+
import { COMPRESS_MESSAGE_PROMPT } from "./prompts/compress-message.ts";
|
|
10
|
+
import { Logger } from "./logger.ts";
|
|
11
|
+
import { DCP_SYSTEM_PROMPT } from "./prompts/system.ts";
|
|
12
|
+
import { createSessionState, resetSessionState } from "./state/state.ts";
|
|
13
|
+
import type { SessionState } from "./state/types.ts";
|
|
14
|
+
import { registerDcpCommands } from "./commands/register.ts";
|
|
15
|
+
import { saveSessionState, loadSessionState } from "./state/persistence.ts";
|
|
16
|
+
import { runPipeline } from "./pipeline.ts";
|
|
17
|
+
|
|
18
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
19
|
+
const agentDir = getAgentDir();
|
|
20
|
+
const configFilePath = path.join(agentDir, "extensions", "dcp.json");
|
|
21
|
+
|
|
22
|
+
let { config } = loadConfig(configFilePath);
|
|
23
|
+
let logger: Logger = new Logger(config.debug);
|
|
24
|
+
const state: SessionState = createSessionState();
|
|
25
|
+
let latestMessages: AgentMessage[] = [];
|
|
26
|
+
let sessionDir: string = "";
|
|
27
|
+
|
|
28
|
+
function reloadConfig(logDir?: string): void {
|
|
29
|
+
const result = loadConfig(configFilePath);
|
|
30
|
+
config = result.config;
|
|
31
|
+
logger = new Logger(config.debug, logDir);
|
|
32
|
+
for (const w of result.warnings) {
|
|
33
|
+
logger.info("config", w);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!config.enabled) return;
|
|
38
|
+
|
|
39
|
+
registerDcpCommands(pi, state, config);
|
|
40
|
+
|
|
41
|
+
if (config.compress.mode === "message") {
|
|
42
|
+
pi.registerTool({
|
|
43
|
+
name: "compress",
|
|
44
|
+
label: "Compress",
|
|
45
|
+
description: COMPRESS_MESSAGE_PROMPT,
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
topic: Type.String({
|
|
48
|
+
description: "Short label (3-5 words) for display",
|
|
49
|
+
}),
|
|
50
|
+
targets: Type.Array(
|
|
51
|
+
Type.Object({
|
|
52
|
+
messageId: Type.String({
|
|
53
|
+
description: "Message ID to compress (e.g. m0001)",
|
|
54
|
+
}),
|
|
55
|
+
summary: Type.String({
|
|
56
|
+
description: "Complete technical summary replacing message content",
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
{ description: "Messages to compress" },
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
63
|
+
const resultText = handleCompress(state, config, latestMessages, {
|
|
64
|
+
...(params as Record<string, unknown>),
|
|
65
|
+
mode: "message",
|
|
66
|
+
} as CompressArgs);
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
69
|
+
details: {},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
pi.registerTool({
|
|
75
|
+
name: "compress",
|
|
76
|
+
label: "Compress",
|
|
77
|
+
description:
|
|
78
|
+
"Compress conversation ranges into summaries. Use message IDs (m0001, m0002...) visible in context as boundaries.",
|
|
79
|
+
parameters: Type.Object({
|
|
80
|
+
topic: Type.String({ description: "Short label (3-5 words) for display" }),
|
|
81
|
+
content: Type.Array(
|
|
82
|
+
Type.Object({
|
|
83
|
+
startId: Type.String({
|
|
84
|
+
description: "Message or block ID marking range start (e.g. m0001, b2)",
|
|
85
|
+
}),
|
|
86
|
+
endId: Type.String({
|
|
87
|
+
description: "Message or block ID marking range end (e.g. m0012, b5)",
|
|
88
|
+
}),
|
|
89
|
+
summary: Type.String({
|
|
90
|
+
description: "Complete technical summary replacing all content in range",
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
{ description: "Ranges to compress, each with start/end boundaries and summary" },
|
|
94
|
+
),
|
|
95
|
+
}),
|
|
96
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
97
|
+
const resultText = handleCompress(state, config, latestMessages, {
|
|
98
|
+
...(params as Record<string, unknown>),
|
|
99
|
+
mode: "range",
|
|
100
|
+
} as CompressArgs);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
103
|
+
details: {},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
110
|
+
if (!config.enabled) return;
|
|
111
|
+
if (config.compress.permission === "deny") return;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
systemPrompt: (event.systemPrompt ?? "") + DCP_SYSTEM_PROMPT,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_start", async (event, ctx) => {
|
|
119
|
+
sessionDir = ctx.sessionManager.getSessionDir();
|
|
120
|
+
const logDir = path.join(sessionDir, "dcp", "logs");
|
|
121
|
+
reloadConfig(logDir);
|
|
122
|
+
if (!config.enabled) return;
|
|
123
|
+
|
|
124
|
+
resetSessionState(state);
|
|
125
|
+
state.sessionId = `pi-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
126
|
+
state.manualMode = config.manualMode.default;
|
|
127
|
+
|
|
128
|
+
// Load persisted state if resuming
|
|
129
|
+
if (event.reason === "resume") {
|
|
130
|
+
const persisted = loadSessionState(sessionDir);
|
|
131
|
+
if (persisted) {
|
|
132
|
+
state.currentTurn = persisted.currentTurn;
|
|
133
|
+
state.stats = persisted.stats;
|
|
134
|
+
state.lastCompaction = persisted.lastCompaction;
|
|
135
|
+
logger.info("dcp", "resumed persisted state", { turn: state.currentTurn });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const usage = ctx.getContextUsage();
|
|
140
|
+
if (usage) {
|
|
141
|
+
state.modelContextWindow = usage.contextWindow;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
logger.info("dcp", "session started", {
|
|
145
|
+
sessionId: state.sessionId,
|
|
146
|
+
reason: event.reason,
|
|
147
|
+
mode: config.compress.mode,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_compact", async (_event, _ctx) => {
|
|
152
|
+
state.prune.tools.clear();
|
|
153
|
+
state.prune.messages.byMessageIndex.clear();
|
|
154
|
+
state.prune.messages.blocksById.clear();
|
|
155
|
+
state.prune.messages.activeBlockIds.clear();
|
|
156
|
+
state.prune.messages.activeByAnchorIndex.clear();
|
|
157
|
+
state.messageIds.byIndex.clear();
|
|
158
|
+
state.messageIds.byRef.clear();
|
|
159
|
+
state.messageIds.nextRefIndex = 1;
|
|
160
|
+
state.lastCompaction = Date.now();
|
|
161
|
+
logger.info("dcp", "compaction detected, pruning state reset");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
165
|
+
if (sessionDir) {
|
|
166
|
+
try {
|
|
167
|
+
saveSessionState(state, sessionDir);
|
|
168
|
+
logger.info("dcp", "session shutdown, state saved");
|
|
169
|
+
} catch (err) {
|
|
170
|
+
logger.info("dcp", "session shutdown, failed to save state", { error: String(err) });
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
logger.info("dcp", "session shutdown");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
pi.on("turn_end", async (_event, _ctx) => {
|
|
178
|
+
state.currentTurn++;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.on("context", async (event, ctx) => {
|
|
182
|
+
if (!config.enabled) return;
|
|
183
|
+
|
|
184
|
+
const usage = ctx.getContextUsage();
|
|
185
|
+
if (usage) state.modelContextWindow = usage.contextWindow;
|
|
186
|
+
latestMessages = event.messages;
|
|
187
|
+
|
|
188
|
+
const result = runPipeline(
|
|
189
|
+
state,
|
|
190
|
+
config,
|
|
191
|
+
event.messages,
|
|
192
|
+
usage
|
|
193
|
+
? {
|
|
194
|
+
tokens: usage.tokens,
|
|
195
|
+
contextWindow: usage.contextWindow,
|
|
196
|
+
percent: usage.percent,
|
|
197
|
+
}
|
|
198
|
+
: undefined,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (result.strategyResult.pruned > 0) {
|
|
202
|
+
logger.info("strategies", "pruned tool outputs", {
|
|
203
|
+
count: result.strategyResult.pruned,
|
|
204
|
+
tokens: result.strategyResult.tokensSaved,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ctx.hasUI && state.stats.totalPruneTokens > 0) {
|
|
209
|
+
ctx.ui.setStatus(
|
|
210
|
+
"dcp",
|
|
211
|
+
`DCP: ${state.stats.totalPruneTokens} tokens saved`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { messages: result.messages };
|
|
216
|
+
});
|
|
217
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class Logger {
|
|
5
|
+
private enabled: boolean;
|
|
6
|
+
private logDir: string | undefined;
|
|
7
|
+
|
|
8
|
+
constructor(enabled: boolean, logDir?: string) {
|
|
9
|
+
this.enabled = enabled;
|
|
10
|
+
this.logDir = logDir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
info(source: string, message: string, data?: Record<string, unknown>): void {
|
|
14
|
+
this.write("INFO", source, message, data);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
warn(source: string, message: string, data?: Record<string, unknown>): void {
|
|
18
|
+
this.write("WARN", source, message, data);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
error(source: string, message: string, data?: Record<string, unknown>): void {
|
|
22
|
+
this.write("ERROR", source, message, data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private write(
|
|
26
|
+
level: string,
|
|
27
|
+
source: string,
|
|
28
|
+
message: string,
|
|
29
|
+
data?: Record<string, unknown>,
|
|
30
|
+
): void {
|
|
31
|
+
if (!this.enabled || !this.logDir) return;
|
|
32
|
+
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const timestamp = now.toISOString();
|
|
35
|
+
const dateStr = timestamp.slice(0, 10);
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
let line = `${timestamp} ${level.padEnd(5)} ${source}: ${message}`;
|
|
40
|
+
if (data) {
|
|
41
|
+
const pairs = Object.entries(data)
|
|
42
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? `"${v}"` : String(v)}`)
|
|
43
|
+
.join(" ");
|
|
44
|
+
line += ` | ${pairs}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fs.appendFileSync(path.join(this.logDir, `${dateStr}.log`), `${line}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|