@zhushanwen/pi-context-engineering 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/index.ts +1 -0
- package/package.json +23 -0
- package/src/__tests__/compressor.test.ts +911 -0
- package/src/__tests__/frozen-fresh.test.ts +44 -0
- package/src/__tests__/integration.test.ts +440 -0
- package/src/commands.ts +154 -0
- package/src/compressor.ts +798 -0
- package/src/config.ts +172 -0
- package/src/frozen-fresh.ts +36 -0
- package/src/index.ts +105 -0
- package/src/recall-store.ts +63 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// ── 配置嵌套类型 ──
|
|
6
|
+
|
|
7
|
+
export interface L0Config {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
expireMinutes: number;
|
|
10
|
+
bashTruncateChars: number;
|
|
11
|
+
thinkingExpireMinutes: number;
|
|
12
|
+
protectRecentTurns: number;
|
|
13
|
+
keepRecent: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface L1Config {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
summaryThresholdChars: number;
|
|
19
|
+
keepHeadLines: number;
|
|
20
|
+
keepTailLines: number;
|
|
21
|
+
protectRecentTurns: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface L2Config {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
emergencyThreshold: number;
|
|
27
|
+
protectRecentTurns: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface McConfig {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
gapThresholdMinutes: number;
|
|
33
|
+
keepRecent: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BudgetConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
maxToolResultCharsPerMessage: number;
|
|
39
|
+
previewSize: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ContextEngineeringConfig {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
l0: L0Config;
|
|
45
|
+
l1: L1Config;
|
|
46
|
+
l2: L2Config;
|
|
47
|
+
mc: McConfig;
|
|
48
|
+
budget: BudgetConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 默认配置 ──
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_CONFIG: ContextEngineeringConfig = {
|
|
54
|
+
enabled: true,
|
|
55
|
+
l0: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
expireMinutes: 30,
|
|
58
|
+
bashTruncateChars: 4000,
|
|
59
|
+
thinkingExpireMinutes: 5,
|
|
60
|
+
protectRecentTurns: 2,
|
|
61
|
+
keepRecent: 5,
|
|
62
|
+
},
|
|
63
|
+
l1: {
|
|
64
|
+
enabled: true,
|
|
65
|
+
summaryThresholdChars: 8000,
|
|
66
|
+
keepHeadLines: 10,
|
|
67
|
+
keepTailLines: 5,
|
|
68
|
+
protectRecentTurns: 2,
|
|
69
|
+
},
|
|
70
|
+
l2: {
|
|
71
|
+
enabled: true,
|
|
72
|
+
emergencyThreshold: 0.9,
|
|
73
|
+
protectRecentTurns: 3,
|
|
74
|
+
},
|
|
75
|
+
mc: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
gapThresholdMinutes: 60,
|
|
78
|
+
keepRecent: 5,
|
|
79
|
+
},
|
|
80
|
+
budget: {
|
|
81
|
+
enabled: true,
|
|
82
|
+
maxToolResultCharsPerMessage: 200_000,
|
|
83
|
+
previewSize: 2000,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── 深合并工具 ──
|
|
88
|
+
|
|
89
|
+
function deepMerge<T>(
|
|
90
|
+
base: T,
|
|
91
|
+
override: Record<string, unknown>,
|
|
92
|
+
): T {
|
|
93
|
+
const result = { ...base } as Record<string, unknown>;
|
|
94
|
+
for (const key of Object.keys(override)) {
|
|
95
|
+
const baseVal = result[key];
|
|
96
|
+
const overVal = override[key];
|
|
97
|
+
if (
|
|
98
|
+
baseVal != null &&
|
|
99
|
+
overVal != null &&
|
|
100
|
+
typeof baseVal === "object" &&
|
|
101
|
+
typeof overVal === "object" &&
|
|
102
|
+
!Array.isArray(baseVal) &&
|
|
103
|
+
!Array.isArray(overVal)
|
|
104
|
+
) {
|
|
105
|
+
result[key] = deepMerge(
|
|
106
|
+
baseVal as Record<string, unknown>,
|
|
107
|
+
overVal as Record<string, unknown>,
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
result[key] = overVal;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 读取配置 ──
|
|
117
|
+
|
|
118
|
+
export function loadConfig(
|
|
119
|
+
settingsPath?: string,
|
|
120
|
+
): ContextEngineeringConfig {
|
|
121
|
+
const filePath =
|
|
122
|
+
settingsPath ?? join(homedir(), ".pi", "agent", "settings.json");
|
|
123
|
+
|
|
124
|
+
let raw: string;
|
|
125
|
+
try {
|
|
126
|
+
raw = readFileSync(filePath, "utf-8");
|
|
127
|
+
} catch {
|
|
128
|
+
return { ...DEFAULT_CONFIG };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let parsed: Record<string, unknown>;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
134
|
+
} catch {
|
|
135
|
+
return { ...DEFAULT_CONFIG };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const override = parsed["context-engineering"];
|
|
139
|
+
if (override == null || typeof override !== "object") {
|
|
140
|
+
return { ...DEFAULT_CONFIG };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return deepMerge<ContextEngineeringConfig>(
|
|
144
|
+
DEFAULT_CONFIG,
|
|
145
|
+
override as Record<string, unknown>,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 命令参数解析 ──
|
|
150
|
+
|
|
151
|
+
export function parseLevelArgs(
|
|
152
|
+
args: string,
|
|
153
|
+
): { target: "global" | "l0" | "l1" | "l2" | "mc" | "budget"; action: "on" | "off" } | null {
|
|
154
|
+
const tokens = args.trim().split(/\s+/);
|
|
155
|
+
if (tokens.length < 2) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const [rawTarget, rawAction] = tokens;
|
|
160
|
+
|
|
161
|
+
const validTargets = new Set(["global", "l0", "l1", "l2", "mc", "budget"]);
|
|
162
|
+
const validActions = new Set(["on", "off"]);
|
|
163
|
+
|
|
164
|
+
if (!validTargets.has(rawTarget) || !validActions.has(rawAction)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
target: rawTarget as "global" | "l0" | "l1" | "l2" | "mc" | "budget",
|
|
170
|
+
action: rawAction as "on" | "off",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// frozen-fresh.ts — Frozen/Fresh 状态跟踪,用于 Budget 工具结果管理
|
|
2
|
+
|
|
3
|
+
export interface FrozenFreshState {
|
|
4
|
+
isFrozen(toolUseId: string): boolean;
|
|
5
|
+
markFrozen(toolUseId: string, replacement: string): void;
|
|
6
|
+
getReplacement(toolUseId: string): string | undefined;
|
|
7
|
+
getAllFrozenIds(): Set<string>;
|
|
8
|
+
reset(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createFrozenFreshState(): FrozenFreshState {
|
|
12
|
+
// toolCallId → replacement text
|
|
13
|
+
const frozen = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
isFrozen(toolUseId: string): boolean {
|
|
17
|
+
return frozen.has(toolUseId);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
markFrozen(toolUseId: string, replacement: string): void {
|
|
21
|
+
frozen.set(toolUseId, replacement);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
getReplacement(toolUseId: string): string | undefined {
|
|
25
|
+
return frozen.get(toolUseId);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
getAllFrozenIds(): Set<string> {
|
|
29
|
+
return new Set(frozen.keys());
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
reset(): void {
|
|
33
|
+
frozen.clear();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
import { loadConfig, type ContextEngineeringConfig } from "./config";
|
|
8
|
+
import { createRecallStore, type RecallStore } from "./recall-store";
|
|
9
|
+
import { createFrozenFreshState, type FrozenFreshState } from "./frozen-fresh";
|
|
10
|
+
import {
|
|
11
|
+
compressContext,
|
|
12
|
+
type CompressionStats,
|
|
13
|
+
type AgentMessage as CompressorMessage,
|
|
14
|
+
} from "./compressor";
|
|
15
|
+
import { handleContextEngineeringCommand, handleContextStatsCommand } from "./commands";
|
|
16
|
+
|
|
17
|
+
const RecallParams = Type.Object({
|
|
18
|
+
id: Type.String({ description: "Context ID (ctx-xxxxxxxx) to recall" }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function zeroStats(): CompressionStats {
|
|
22
|
+
return { l0Expired: 0, l0Truncated: 0, l0ThinkingCleared: 0, l1Condensed: 0, l2Triggered: false, validationFailed: false, mcTriggered: false, mcCleared: 0, budgetPersisted: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function addStats(target: CompressionStats, delta: CompressionStats): void {
|
|
26
|
+
target.l0Expired += delta.l0Expired;
|
|
27
|
+
target.l0Truncated += delta.l0Truncated;
|
|
28
|
+
target.l0ThinkingCleared += delta.l0ThinkingCleared;
|
|
29
|
+
target.l1Condensed += delta.l1Condensed;
|
|
30
|
+
target.mcCleared += delta.mcCleared;
|
|
31
|
+
target.budgetPersisted += delta.budgetPersisted;
|
|
32
|
+
if (delta.l2Triggered) target.l2Triggered = true;
|
|
33
|
+
if (delta.validationFailed) target.validationFailed = true;
|
|
34
|
+
if (delta.mcTriggered) target.mcTriggered = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function recallResult(id: string, store: RecallStore) {
|
|
38
|
+
const stored = store.recall(id);
|
|
39
|
+
if (!stored) return {
|
|
40
|
+
content: [{ type: "text" as const, text: `[recall_context] ID "${id}" not found. Content may have been lost on session reload.` }],
|
|
41
|
+
details: { found: false, id },
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text" as const, text: `[Recalled content (${stored.level}, ${new Date(stored.compressedAt).toISOString()})]\n\n${stored.original}` }],
|
|
45
|
+
details: { found: true, id, level: stored.level },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Extension entry — handlers close over mutable `config`/`store`/`cumulativeStats`
|
|
50
|
+
// so session_start reassignment is visible to every registered handler.
|
|
51
|
+
|
|
52
|
+
export default function contextEngineeringExtension(pi: ExtensionAPI): void {
|
|
53
|
+
let config: ContextEngineeringConfig = loadConfig();
|
|
54
|
+
let store: RecallStore = createRecallStore();
|
|
55
|
+
let cumulativeStats: CompressionStats = zeroStats();
|
|
56
|
+
let frozenFreshState: FrozenFreshState = createFrozenFreshState();
|
|
57
|
+
|
|
58
|
+
pi.on("session_start", () => {
|
|
59
|
+
config = loadConfig();
|
|
60
|
+
store = createRecallStore();
|
|
61
|
+
cumulativeStats = zeroStats();
|
|
62
|
+
frozenFreshState = createFrozenFreshState();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("context", (event, ctx) => {
|
|
66
|
+
try {
|
|
67
|
+
// Pi Extension API types differ from our internal message types.
|
|
68
|
+
// Both sides define the same shape but TypeScript can't verify across packages.
|
|
69
|
+
// If Pi's message format changes, compressContext will gracefully fail via the catch below.
|
|
70
|
+
const msgs = event.messages as unknown as CompressorMessage[];
|
|
71
|
+
const result = compressContext(msgs, config, store, ctx.getContextUsage() as unknown as Parameters<typeof compressContext>[3], frozenFreshState);
|
|
72
|
+
addStats(cumulativeStats, result.stats);
|
|
73
|
+
return { messages: result.messages as unknown as (typeof event.messages)[number][] };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// Silently degrade to original messages, but log for debuggability
|
|
76
|
+
if (process.env.DEBUG_CONTEXT_ENGINEERING) {
|
|
77
|
+
console.error("[context-engineering] compressContext failed:", err);
|
|
78
|
+
}
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerTool({
|
|
84
|
+
name: "recall_context",
|
|
85
|
+
label: "Recall Compressed Context",
|
|
86
|
+
description: "Recall original content compressed by context engineering. Use when you need the full content of an expired, truncated, or condensed tool result.",
|
|
87
|
+
promptSnippet: "recall_context(id) — retrieve original content compressed by context engineering",
|
|
88
|
+
parameters: RecallParams,
|
|
89
|
+
execute: async (_tcId, params, _sig, _upd, _ctx) => recallResult(params.id, store),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.registerCommand("context-engineering", {
|
|
93
|
+
description: "View/modify context compression settings",
|
|
94
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
95
|
+
ctx.ui.notify(handleContextEngineeringCommand(_args || undefined, config, cumulativeStats), "info");
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
pi.registerCommand("context-stats", {
|
|
100
|
+
description: "View context compression statistics",
|
|
101
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
102
|
+
ctx.ui.notify(handleContextStatsCommand(cumulativeStats), "info");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// ── 类型 ──
|
|
4
|
+
|
|
5
|
+
export interface StoredContent {
|
|
6
|
+
id: string;
|
|
7
|
+
original: string;
|
|
8
|
+
compressedAt: number;
|
|
9
|
+
level: "l0-expired" | "l0-truncated" | "l1-condensed" | "l2-emergency" | "mc-cleared" | "budget-persisted";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RecallStore {
|
|
13
|
+
store: (content: string, level: StoredContent["level"]) => string;
|
|
14
|
+
recall: (id: string) => StoredContent | undefined;
|
|
15
|
+
clear: () => void;
|
|
16
|
+
size: () => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── 常量 ──
|
|
20
|
+
|
|
21
|
+
/** UUID 前 12 字符 = 48 bit 熵,碰撞阈值约 16M 条。远超单 session 需求。 */
|
|
22
|
+
const ID_CHARS = 12;
|
|
23
|
+
|
|
24
|
+
/** 内存保护上限。超过时淘汰最早存入的条目。 */
|
|
25
|
+
const MAX_ENTRIES = 500;
|
|
26
|
+
|
|
27
|
+
// ── 工厂函数 ──
|
|
28
|
+
|
|
29
|
+
export function createRecallStore(): RecallStore {
|
|
30
|
+
const entries = new Map<string, StoredContent>();
|
|
31
|
+
|
|
32
|
+
function store(content: string, level: StoredContent["level"]): string {
|
|
33
|
+
// LRU 淘汰:超过上限时删除最早存入的条目
|
|
34
|
+
if (entries.size >= MAX_ENTRIES) {
|
|
35
|
+
const oldest = entries.keys().next().value;
|
|
36
|
+
if (oldest !== undefined) entries.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const idSuffix = randomUUID().replace(/-/g, '').slice(0, ID_CHARS);
|
|
40
|
+
const id = `ctx-${idSuffix}`;
|
|
41
|
+
entries.set(id, {
|
|
42
|
+
id,
|
|
43
|
+
original: content,
|
|
44
|
+
compressedAt: Date.now(),
|
|
45
|
+
level,
|
|
46
|
+
});
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function recall(id: string): StoredContent | undefined {
|
|
51
|
+
return entries.get(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clear(): void {
|
|
55
|
+
entries.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function size(): number {
|
|
59
|
+
return entries.size;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { store, recall, clear, size };
|
|
63
|
+
}
|