@jetcrab/pi-desk-ctx 0.0.0-stage → 1.0.1-dev.9001
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/LICENSE +201 -0
- package/README.md +4 -2
- package/dist/auto-compaction.d.ts +21 -0
- package/dist/auto-compaction.js +31 -0
- package/dist/auto-ignore.d.ts +9 -0
- package/dist/auto-ignore.js +7 -0
- package/dist/browser/chunks/browser-DJVEFCDQ.js +72 -0
- package/dist/browser/entry.js +1 -0
- package/dist/context-analysis.d.ts +21 -0
- package/dist/context-analysis.js +194 -0
- package/dist/context-compaction.d.ts +5 -0
- package/dist/context-compaction.js +14 -0
- package/dist/context-history-tool.d.ts +12 -0
- package/dist/context-history-tool.js +90 -0
- package/dist/context-settings-store.d.ts +4 -0
- package/dist/context-settings-store.js +51 -0
- package/dist/context-settings.d.ts +23 -0
- package/dist/context-settings.js +146 -0
- package/dist/context-skill-path.d.ts +17 -0
- package/dist/context-skill-path.js +64 -0
- package/dist/context-state.d.ts +5 -0
- package/dist/context-state.js +104 -0
- package/dist/context-token-estimate.d.ts +9 -0
- package/dist/context-token-estimate.js +14 -0
- package/dist/context-transform.d.ts +24 -0
- package/dist/context-transform.js +198 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +503 -0
- package/dist/l2-context-settings-biz.d.ts +54 -0
- package/dist/l2-context-settings-biz.js +314 -0
- package/dist/l2-context-settings-view.d.ts +6 -0
- package/dist/l2-context-settings-view.js +128 -0
- package/dist/pi-desk.d.ts +2 -0
- package/dist/pi-desk.js +32 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +2 -0
- package/package.json +56 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
export const HISTORY_TOOL_RESULT_NAME = 'history_tool_result';
|
|
3
|
+
function findHistoricalToolMatch(entries, toolCallId) {
|
|
4
|
+
let toolName;
|
|
5
|
+
let result;
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
if (entry.type !== 'message')
|
|
8
|
+
continue;
|
|
9
|
+
const message = entry.message;
|
|
10
|
+
if (message.role === 'assistant') {
|
|
11
|
+
for (const block of message.content) {
|
|
12
|
+
if (block.type === 'toolCall' && block.id === toolCallId) {
|
|
13
|
+
toolName = block.name;
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (message.role === 'toolResult' && message.toolCallId === toolCallId) {
|
|
20
|
+
result = message;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (toolName === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
return { toolCallId, toolName, result };
|
|
26
|
+
}
|
|
27
|
+
function formatTimestamp(timestamp) {
|
|
28
|
+
const date = new Date(timestamp);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? String(timestamp) : date.toISOString();
|
|
30
|
+
}
|
|
31
|
+
function createHistoryHeader(ref, match, status) {
|
|
32
|
+
const timestamp = match.result ? `,timestamp=${formatTimestamp(match.result.timestamp)}` : '';
|
|
33
|
+
return `历史 ToolResult:ref=${ref},tool=${match.toolName},status=${status}${timestamp}。这是当时快照,不代表当前状态。`;
|
|
34
|
+
}
|
|
35
|
+
export function registerHistoryToolResult(pi, getToolReferences) {
|
|
36
|
+
pi.registerTool({
|
|
37
|
+
name: HISTORY_TOOL_RESULT_NAME,
|
|
38
|
+
label: 'History Tool Result',
|
|
39
|
+
description: '仅在确实需要当时原始结果时,按当前 compressed_tool_context 中的 ref 只读返回完整历史 ToolResult。该结果不是当前状态;需要最新结果时重新调用原工具。',
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
ref: Type.Integer({
|
|
42
|
+
minimum: 1,
|
|
43
|
+
description: 'compressed_tool_context 记录中的历史工具引用序号'
|
|
44
|
+
})
|
|
45
|
+
}, { additionalProperties: false }),
|
|
46
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
47
|
+
const historicalToolCallId = getToolReferences().get(params.ref);
|
|
48
|
+
if (historicalToolCallId === undefined) {
|
|
49
|
+
throw new Error(`当前模型上下文中不存在历史工具引用 ref=${params.ref},请使用当前 compressed_tool_context 中的 ref。`);
|
|
50
|
+
}
|
|
51
|
+
const match = findHistoricalToolMatch(ctx.sessionManager.getBranch(), historicalToolCallId);
|
|
52
|
+
if (match === undefined) {
|
|
53
|
+
throw new Error(`历史工具引用 ref=${params.ref} 已不属于当前 Session 分支。`);
|
|
54
|
+
}
|
|
55
|
+
if (match.result === undefined) {
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: createHistoryHeader(params.ref, match, 'missing')
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
details: {
|
|
64
|
+
ref: params.ref,
|
|
65
|
+
toolCallId: match.toolCallId,
|
|
66
|
+
toolName: match.toolName,
|
|
67
|
+
historicalStatus: 'missing'
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const historicalStatus = match.result.isError ? 'error' : 'ok';
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: 'text',
|
|
76
|
+
text: createHistoryHeader(params.ref, match, historicalStatus)
|
|
77
|
+
},
|
|
78
|
+
...match.result.content.map((block) => ({ ...block }))
|
|
79
|
+
],
|
|
80
|
+
details: {
|
|
81
|
+
ref: params.ref,
|
|
82
|
+
toolCallId: match.toolCallId,
|
|
83
|
+
toolName: match.toolName,
|
|
84
|
+
historicalStatus,
|
|
85
|
+
timestamp: match.result.timestamp
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ContextIgnoreSettings } from './context-settings.js';
|
|
2
|
+
export declare function defaultContextIgnoreSettingsPath(): string;
|
|
3
|
+
export declare function readContextIgnoreSettings(path?: string): Promise<ContextIgnoreSettings>;
|
|
4
|
+
export declare function writeContextIgnoreSettings(value: unknown, path?: string): Promise<ContextIgnoreSettings>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { defaultContextIgnoreSettings, parseContextIgnoreSettings } from './context-settings.js';
|
|
7
|
+
const MAX_CONFIG_BYTES = 128 * 1024;
|
|
8
|
+
export function defaultContextIgnoreSettingsPath() {
|
|
9
|
+
const agentDir = getAgentDir();
|
|
10
|
+
const path = join(agentDir, 'pi-desk-ctx.json');
|
|
11
|
+
const previous = join(agentDir, 'pi-super-ctx.json');
|
|
12
|
+
if (!existsSync(path) && existsSync(previous))
|
|
13
|
+
copyFileSync(previous, path);
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
export async function readContextIgnoreSettings(path = defaultContextIgnoreSettingsPath()) {
|
|
17
|
+
try {
|
|
18
|
+
const metadata = await lstat(path);
|
|
19
|
+
if (!metadata.isFile())
|
|
20
|
+
throw new Error('上下文忽略配置必须是普通文件');
|
|
21
|
+
if (metadata.size > MAX_CONFIG_BYTES) {
|
|
22
|
+
throw new Error(`上下文忽略配置不能超过 ${Math.round(MAX_CONFIG_BYTES / 1024)}KB`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT')
|
|
27
|
+
return defaultContextIgnoreSettings();
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
let value;
|
|
31
|
+
try {
|
|
32
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new Error(`读取上下文忽略配置失败:${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
return parseContextIgnoreSettings(value);
|
|
38
|
+
}
|
|
39
|
+
export async function writeContextIgnoreSettings(value, path = defaultContextIgnoreSettingsPath()) {
|
|
40
|
+
const settings = parseContextIgnoreSettings(value);
|
|
41
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
42
|
+
await mkdir(dirname(path), { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
45
|
+
await rename(temporaryPath, path);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await rm(temporaryPath, { force: true });
|
|
49
|
+
}
|
|
50
|
+
return settings;
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ContextIgnoreRule {
|
|
2
|
+
currentTokensAtLeast?: number;
|
|
3
|
+
projectedTokensAtMost?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ContextIgnoreProfile {
|
|
6
|
+
/** Undefined is the lowest-priority fallback for any model context window. */
|
|
7
|
+
maxContextTokens?: number;
|
|
8
|
+
checkpointTokens?: number;
|
|
9
|
+
keepRecentUserTurns: number;
|
|
10
|
+
processTokenBudget: number;
|
|
11
|
+
rules: ContextIgnoreRule[];
|
|
12
|
+
}
|
|
13
|
+
export interface ContextIgnoreSettings {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
profiles: ContextIgnoreProfile[];
|
|
16
|
+
}
|
|
17
|
+
export declare const RECOMMENDED_CONTEXT_IGNORE_SETTINGS: ContextIgnoreSettings;
|
|
18
|
+
export declare function normalizeContextIgnoreSettings(settings: ContextIgnoreSettings): ContextIgnoreSettings;
|
|
19
|
+
export declare function parseContextIgnoreSettings(value: unknown): ContextIgnoreSettings;
|
|
20
|
+
export declare function defaultContextIgnoreSettings(): ContextIgnoreSettings;
|
|
21
|
+
export declare function selectContextIgnoreProfile(settings: ContextIgnoreSettings, contextWindow: number): ContextIgnoreProfile;
|
|
22
|
+
export declare function matchesContextIgnoreRule(rule: ContextIgnoreRule, currentTokens: number, projectedTokens: number): boolean;
|
|
23
|
+
export declare function findMatchingContextIgnoreRule(rules: readonly ContextIgnoreRule[], currentTokens: number, projectedTokens: number): number | undefined;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export const RECOMMENDED_CONTEXT_IGNORE_SETTINGS = {
|
|
2
|
+
enabled: true,
|
|
3
|
+
profiles: [
|
|
4
|
+
{
|
|
5
|
+
maxContextTokens: 400_000,
|
|
6
|
+
checkpointTokens: 320_000,
|
|
7
|
+
keepRecentUserTurns: 3,
|
|
8
|
+
processTokenBudget: 30_000,
|
|
9
|
+
rules: [{ currentTokensAtLeast: 280_000, projectedTokensAtMost: 50_000 }]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
maxContextTokens: 600_000,
|
|
13
|
+
checkpointTokens: 480_000,
|
|
14
|
+
keepRecentUserTurns: 3,
|
|
15
|
+
processTokenBudget: 40_000,
|
|
16
|
+
rules: [
|
|
17
|
+
{ currentTokensAtLeast: 400_000, projectedTokensAtMost: 50_000 },
|
|
18
|
+
{ currentTokensAtLeast: 500_000, projectedTokensAtMost: 100_000 }
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
checkpointTokens: 800_000,
|
|
23
|
+
keepRecentUserTurns: 3,
|
|
24
|
+
processTokenBudget: 40_000,
|
|
25
|
+
rules: [{ currentTokensAtLeast: 800_000, projectedTokensAtMost: 150_000 }]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
const MAX_PROFILES = 20;
|
|
30
|
+
const MAX_RULES_PER_PROFILE = 50;
|
|
31
|
+
const MAX_TOKEN_VALUE = 100_000_000;
|
|
32
|
+
const MAX_KEEP_TURNS = 100;
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function requiredInteger(value, field, minimum, maximum) {
|
|
37
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
38
|
+
throw new Error(`${field} 必须是 ${minimum} 到 ${maximum} 的整数`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function optionalToken(value, field) {
|
|
43
|
+
if (value === undefined || value === null)
|
|
44
|
+
return undefined;
|
|
45
|
+
return requiredInteger(value, field, 0, MAX_TOKEN_VALUE);
|
|
46
|
+
}
|
|
47
|
+
function parseRule(value, path) {
|
|
48
|
+
if (!isRecord(value))
|
|
49
|
+
throw new Error(`${path} 必须是对象`);
|
|
50
|
+
const currentTokensAtLeast = optionalToken(value.currentTokensAtLeast, `${path}.currentTokensAtLeast`);
|
|
51
|
+
const projectedTokensAtMost = optionalToken(value.projectedTokensAtMost, `${path}.projectedTokensAtMost`);
|
|
52
|
+
if (currentTokensAtLeast === undefined && projectedTokensAtMost === undefined) {
|
|
53
|
+
throw new Error(`${path} 至少需要一个判断条件`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...(currentTokensAtLeast === undefined ? {} : { currentTokensAtLeast }),
|
|
57
|
+
...(projectedTokensAtMost === undefined ? {} : { projectedTokensAtMost })
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function parseProfile(value, index) {
|
|
61
|
+
const path = `profiles.${index}`;
|
|
62
|
+
if (!isRecord(value))
|
|
63
|
+
throw new Error(`${path} 必须是对象`);
|
|
64
|
+
const maxContextTokens = optionalToken(value.maxContextTokens, `${path}.maxContextTokens`);
|
|
65
|
+
if (maxContextTokens !== undefined && maxContextTokens === 0) {
|
|
66
|
+
throw new Error(`${path}.maxContextTokens 必须大于 0`);
|
|
67
|
+
}
|
|
68
|
+
const checkpointTokens = optionalToken(value.checkpointTokens, `${path}.checkpointTokens`);
|
|
69
|
+
if (checkpointTokens !== undefined && checkpointTokens === 0) {
|
|
70
|
+
throw new Error(`${path}.checkpointTokens 必须大于 0`);
|
|
71
|
+
}
|
|
72
|
+
const keepRecentUserTurns = requiredInteger(value.keepRecentUserTurns, `${path}.keepRecentUserTurns`, 1, MAX_KEEP_TURNS);
|
|
73
|
+
const processTokenBudget = requiredInteger(value.processTokenBudget, `${path}.processTokenBudget`, 0, MAX_TOKEN_VALUE);
|
|
74
|
+
if (!Array.isArray(value.rules) || value.rules.length > MAX_RULES_PER_PROFILE) {
|
|
75
|
+
throw new Error(`${path}.rules 必须是最多 ${MAX_RULES_PER_PROFILE} 条规则的数组`);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
...(maxContextTokens === undefined ? {} : { maxContextTokens }),
|
|
79
|
+
...(checkpointTokens === undefined ? {} : { checkpointTokens }),
|
|
80
|
+
keepRecentUserTurns,
|
|
81
|
+
processTokenBudget,
|
|
82
|
+
rules: value.rules.map((rule, ruleIndex) => parseRule(rule, `${path}.rules.${ruleIndex}`))
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function normalizeContextIgnoreSettings(settings) {
|
|
86
|
+
const profiles = settings.profiles
|
|
87
|
+
.map((profile) => ({
|
|
88
|
+
...(profile.maxContextTokens === undefined
|
|
89
|
+
? {}
|
|
90
|
+
: { maxContextTokens: profile.maxContextTokens }),
|
|
91
|
+
...(profile.checkpointTokens === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { checkpointTokens: profile.checkpointTokens }),
|
|
94
|
+
keepRecentUserTurns: profile.keepRecentUserTurns,
|
|
95
|
+
processTokenBudget: profile.processTokenBudget,
|
|
96
|
+
rules: profile.rules.map((rule) => ({ ...rule }))
|
|
97
|
+
}))
|
|
98
|
+
.sort((left, right) => {
|
|
99
|
+
if (left.maxContextTokens === undefined)
|
|
100
|
+
return 1;
|
|
101
|
+
if (right.maxContextTokens === undefined)
|
|
102
|
+
return -1;
|
|
103
|
+
return left.maxContextTokens - right.maxContextTokens;
|
|
104
|
+
});
|
|
105
|
+
return { enabled: settings.enabled, profiles };
|
|
106
|
+
}
|
|
107
|
+
export function parseContextIgnoreSettings(value) {
|
|
108
|
+
if (!isRecord(value))
|
|
109
|
+
throw new Error('上下文忽略配置必须是对象');
|
|
110
|
+
if (typeof value.enabled !== 'boolean')
|
|
111
|
+
throw new Error('enabled 必须是布尔值');
|
|
112
|
+
if (!Array.isArray(value.profiles) ||
|
|
113
|
+
value.profiles.length === 0 ||
|
|
114
|
+
value.profiles.length > MAX_PROFILES) {
|
|
115
|
+
throw new Error(`profiles 必须包含 1 到 ${MAX_PROFILES} 个模型配置`);
|
|
116
|
+
}
|
|
117
|
+
const profiles = value.profiles.map(parseProfile);
|
|
118
|
+
const fallbackCount = profiles.filter((profile) => profile.maxContextTokens === undefined).length;
|
|
119
|
+
if (fallbackCount !== 1)
|
|
120
|
+
throw new Error('必须且只能有一个不限制模型窗口的通用配置');
|
|
121
|
+
const boundedLimits = profiles
|
|
122
|
+
.map((profile) => profile.maxContextTokens)
|
|
123
|
+
.filter((limit) => limit !== undefined);
|
|
124
|
+
if (new Set(boundedLimits).size !== boundedLimits.length) {
|
|
125
|
+
throw new Error('模型窗口上限不能重复');
|
|
126
|
+
}
|
|
127
|
+
return normalizeContextIgnoreSettings({ enabled: value.enabled, profiles });
|
|
128
|
+
}
|
|
129
|
+
export function defaultContextIgnoreSettings() {
|
|
130
|
+
return normalizeContextIgnoreSettings(RECOMMENDED_CONTEXT_IGNORE_SETTINGS);
|
|
131
|
+
}
|
|
132
|
+
export function selectContextIgnoreProfile(settings, contextWindow) {
|
|
133
|
+
const normalized = normalizeContextIgnoreSettings(settings);
|
|
134
|
+
const fallback = normalized.profiles.find((profile) => profile.maxContextTokens === undefined);
|
|
135
|
+
if (contextWindow <= 0)
|
|
136
|
+
return fallback;
|
|
137
|
+
return (normalized.profiles.find((profile) => profile.maxContextTokens === undefined || contextWindow <= profile.maxContextTokens) ?? fallback);
|
|
138
|
+
}
|
|
139
|
+
export function matchesContextIgnoreRule(rule, currentTokens, projectedTokens) {
|
|
140
|
+
return ((rule.currentTokensAtLeast === undefined || currentTokens >= rule.currentTokensAtLeast) &&
|
|
141
|
+
(rule.projectedTokensAtMost === undefined || projectedTokens <= rule.projectedTokensAtMost));
|
|
142
|
+
}
|
|
143
|
+
export function findMatchingContextIgnoreRule(rules, currentTokens, projectedTokens) {
|
|
144
|
+
const index = rules.findIndex((rule) => matchesContextIgnoreRule(rule, currentTokens, projectedTokens));
|
|
145
|
+
return index < 0 ? undefined : index;
|
|
146
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
2
|
+
import type { ToolCall } from '@earendil-works/pi-ai';
|
|
3
|
+
export interface ContextIgnoreTransformOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
/** Pi 已发现的目录型 Skill 根目录。 */
|
|
6
|
+
skillBaseDirs?: string[];
|
|
7
|
+
/** Pi 已发现的根目录直放 Markdown Skill 文件。 */
|
|
8
|
+
skillFilePaths?: string[];
|
|
9
|
+
/** 从完整会话投影到压缩切片的严格消息身份。 */
|
|
10
|
+
ignoredMessages?: WeakSet<AgentMessage>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Uses Pi's discovered Skill roots for active sessions. Restored sessions can only
|
|
14
|
+
* recover a root when their own read result proves that SKILL.md was a valid skill.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getSkillBaseDirs(messages: AgentMessage[], options?: ContextIgnoreTransformOptions): string[];
|
|
17
|
+
export declare function isSkillContentRead(toolCall: ToolCall, skillBaseDirs: string[], options?: ContextIgnoreTransformOptions): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
2
|
+
function normalizePath(path) {
|
|
3
|
+
const normalized = resolve(path).replace(/[\\/]+$/, '');
|
|
4
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
5
|
+
}
|
|
6
|
+
function resolveReadPath(path, cwd) {
|
|
7
|
+
return resolve(cwd, path.startsWith('@') ? path.slice(1) : path);
|
|
8
|
+
}
|
|
9
|
+
function isWithinPath(path, root) {
|
|
10
|
+
const pathRelative = relative(normalizePath(root), normalizePath(path));
|
|
11
|
+
return (pathRelative === '' || (!pathRelative.startsWith('..') && !pathRelative.includes(`..${sep}`)));
|
|
12
|
+
}
|
|
13
|
+
function getReadPath(toolCall, cwd) {
|
|
14
|
+
if (toolCall.name !== 'read')
|
|
15
|
+
return undefined;
|
|
16
|
+
const path = toolCall.arguments.path;
|
|
17
|
+
return typeof path === 'string' && path.trim() ? resolveReadPath(path, cwd) : undefined;
|
|
18
|
+
}
|
|
19
|
+
function isSkillDefinitionResult(result) {
|
|
20
|
+
if (result === undefined || result.isError)
|
|
21
|
+
return false;
|
|
22
|
+
const text = result.content
|
|
23
|
+
.filter((block) => block.type === 'text')
|
|
24
|
+
.map((block) => block.text)
|
|
25
|
+
.join('\n');
|
|
26
|
+
return /^---\s*$/m.test(text) && /^name:\s*\S+/m.test(text) && /^description:\s*\S+/m.test(text);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Uses Pi's discovered Skill roots for active sessions. Restored sessions can only
|
|
30
|
+
* recover a root when their own read result proves that SKILL.md was a valid skill.
|
|
31
|
+
*/
|
|
32
|
+
export function getSkillBaseDirs(messages, options = {}) {
|
|
33
|
+
const cwd = options.cwd ?? process.cwd();
|
|
34
|
+
const roots = new Set((options.skillBaseDirs ?? []).map(normalizePath));
|
|
35
|
+
const toolResults = new Map();
|
|
36
|
+
for (const message of messages) {
|
|
37
|
+
if (message.role === 'toolResult')
|
|
38
|
+
toolResults.set(message.toolCallId, message);
|
|
39
|
+
}
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (message.role !== 'assistant')
|
|
42
|
+
continue;
|
|
43
|
+
for (const block of message.content) {
|
|
44
|
+
if (block.type !== 'toolCall')
|
|
45
|
+
continue;
|
|
46
|
+
const path = getReadPath(block, cwd);
|
|
47
|
+
if (path !== undefined &&
|
|
48
|
+
basename(path).toLowerCase() === 'skill.md' &&
|
|
49
|
+
isSkillDefinitionResult(toolResults.get(block.id))) {
|
|
50
|
+
roots.add(normalizePath(dirname(path)));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...roots];
|
|
55
|
+
}
|
|
56
|
+
export function isSkillContentRead(toolCall, skillBaseDirs, options = {}) {
|
|
57
|
+
const path = getReadPath(toolCall, options.cwd ?? process.cwd());
|
|
58
|
+
if (path === undefined)
|
|
59
|
+
return false;
|
|
60
|
+
if ((options.skillFilePaths ?? []).some((filePath) => normalizePath(filePath) === normalizePath(path))) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return skillBaseDirs.some((root) => isWithinPath(path, root));
|
|
64
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { type ContextIgnoreState } from './types.js';
|
|
3
|
+
export declare function defaultContextIgnoreState(): ContextIgnoreState;
|
|
4
|
+
export declare function loadContextIgnoreState(sessionManager: ExtensionContext['sessionManager']): ContextIgnoreState;
|
|
5
|
+
export declare function persistContextIgnoreState(pi: ExtensionAPI, state: ContextIgnoreState): void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CONTEXT_IGNORE_ENTRY_TYPE, DEFAULT_KEEP_TURNS } from './types.js';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function parseState(value) {
|
|
6
|
+
if (!isRecord(value))
|
|
7
|
+
return undefined;
|
|
8
|
+
if (typeof value.enabled !== 'boolean')
|
|
9
|
+
return undefined;
|
|
10
|
+
if (typeof value.keepTurns !== 'number')
|
|
11
|
+
return undefined;
|
|
12
|
+
if (typeof value.updatedAt !== 'number')
|
|
13
|
+
return undefined;
|
|
14
|
+
if (value.mode !== undefined &&
|
|
15
|
+
value.mode !== 'user-turns' &&
|
|
16
|
+
value.mode !== 'internal-turns' &&
|
|
17
|
+
value.mode !== 'combined') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (value.cutoffTimestamp !== undefined && typeof value.cutoffTimestamp !== 'number') {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (value.internalTurnCutoffs !== undefined &&
|
|
24
|
+
(!Array.isArray(value.internalTurnCutoffs) ||
|
|
25
|
+
value.internalTurnCutoffs.some((cutoff) => !isRecord(cutoff) ||
|
|
26
|
+
typeof cutoff.cutoffTimestamp !== 'number' ||
|
|
27
|
+
(cutoff.userTimestamp !== undefined && typeof cutoff.userTimestamp !== 'number')))) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (value.internalTurnCutoffTimestamp !== undefined &&
|
|
31
|
+
typeof value.internalTurnCutoffTimestamp !== 'number') {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (value.internalTurnUserTimestamp !== undefined &&
|
|
35
|
+
typeof value.internalTurnUserTimestamp !== 'number') {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (value.pendingUsageRefreshAfterTimestamp !== undefined &&
|
|
39
|
+
typeof value.pendingUsageRefreshAfterTimestamp !== 'number') {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (value.estimatedContextTokens !== undefined &&
|
|
43
|
+
typeof value.estimatedContextTokens !== 'number') {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (value.skillBaseDirs !== undefined &&
|
|
47
|
+
(!Array.isArray(value.skillBaseDirs) ||
|
|
48
|
+
value.skillBaseDirs.some((path) => typeof path !== 'string'))) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (value.skillFilePaths !== undefined &&
|
|
52
|
+
(!Array.isArray(value.skillFilePaths) ||
|
|
53
|
+
value.skillFilePaths.some((path) => typeof path !== 'string'))) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (value.keepInternalTurns !== undefined && typeof value.keepInternalTurns !== 'number') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const legacyInternalCutoff = value.internalTurnCutoffTimestamp ??
|
|
60
|
+
(value.mode === 'internal-turns' ? value.cutoffTimestamp : undefined);
|
|
61
|
+
const internalTurnCutoffs = value.internalTurnCutoffs ??
|
|
62
|
+
(legacyInternalCutoff === undefined
|
|
63
|
+
? undefined
|
|
64
|
+
: [{ cutoffTimestamp: legacyInternalCutoff, userTimestamp: value.internalTurnUserTimestamp }]);
|
|
65
|
+
return {
|
|
66
|
+
enabled: value.enabled,
|
|
67
|
+
mode: value.mode ?? 'user-turns',
|
|
68
|
+
cutoffTimestamp: value.mode === 'internal-turns' ? undefined : value.cutoffTimestamp,
|
|
69
|
+
internalTurnCutoffs,
|
|
70
|
+
pendingUsageRefreshAfterTimestamp: value.pendingUsageRefreshAfterTimestamp,
|
|
71
|
+
estimatedContextTokens: value.estimatedContextTokens === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: Math.max(0, value.estimatedContextTokens),
|
|
74
|
+
skillBaseDirs: value.skillBaseDirs,
|
|
75
|
+
skillFilePaths: value.skillFilePaths,
|
|
76
|
+
keepTurns: Math.max(1, Math.floor(value.keepTurns)),
|
|
77
|
+
updatedAt: value.updatedAt
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function defaultContextIgnoreState() {
|
|
81
|
+
return {
|
|
82
|
+
enabled: false,
|
|
83
|
+
mode: 'user-turns',
|
|
84
|
+
keepTurns: DEFAULT_KEEP_TURNS,
|
|
85
|
+
updatedAt: Date.now()
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function loadContextIgnoreState(sessionManager) {
|
|
89
|
+
const state = defaultContextIgnoreState();
|
|
90
|
+
const branch = sessionManager.getBranch();
|
|
91
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
const entry = branch[index];
|
|
93
|
+
if (entry.type !== 'custom' || entry.customType !== CONTEXT_IGNORE_ENTRY_TYPE) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const parsed = parseState(entry.data);
|
|
97
|
+
if (parsed)
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
export function persistContextIgnoreState(pi, state) {
|
|
103
|
+
pi.appendEntry(CONTEXT_IGNORE_ENTRY_TYPE, state);
|
|
104
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolCall } from '@earendil-works/pi-ai';
|
|
2
|
+
export declare function estimateContextTextTokens(text: string): number;
|
|
3
|
+
export declare function estimateThinkingBlockTokens(block: {
|
|
4
|
+
thinking: string;
|
|
5
|
+
}): number;
|
|
6
|
+
export declare function estimateReasoningReplayTokens(block: {
|
|
7
|
+
thinkingSignature?: string;
|
|
8
|
+
}): number;
|
|
9
|
+
export declare function estimateToolCallTokens(toolCall: Pick<ToolCall, 'name' | 'arguments'>): number;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const CHARS_PER_ESTIMATED_TOKEN = 4;
|
|
2
|
+
export function estimateContextTextTokens(text) {
|
|
3
|
+
return Math.ceil(text.length / CHARS_PER_ESTIMATED_TOKEN);
|
|
4
|
+
}
|
|
5
|
+
export function estimateThinkingBlockTokens(block) {
|
|
6
|
+
return estimateContextTextTokens(block.thinking);
|
|
7
|
+
}
|
|
8
|
+
export function estimateReasoningReplayTokens(block) {
|
|
9
|
+
return estimateContextTextTokens(block.thinkingSignature ?? '');
|
|
10
|
+
}
|
|
11
|
+
export function estimateToolCallTokens(toolCall) {
|
|
12
|
+
const serializedArguments = JSON.stringify(toolCall.arguments) ?? '';
|
|
13
|
+
return estimateContextTextTokens(toolCall.name + serializedArguments);
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
2
|
+
import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
|
|
3
|
+
import { type ContextIgnoreTransformOptions } from './context-skill-path.js';
|
|
4
|
+
import type { ContextIgnoreState } from './types.js';
|
|
5
|
+
export declare const COMPRESSED_TOOL_CONTEXT_TAG = "compressed_tool_context";
|
|
6
|
+
export declare const COMPRESSED_TOOL_CONTEXT_CUSTOM_TYPE = "context-ignore-compressed-tools";
|
|
7
|
+
export type CompressedToolStatus = 'ok' | 'error' | 'missing';
|
|
8
|
+
export interface CompressedToolRecord {
|
|
9
|
+
ref: number;
|
|
10
|
+
name: string;
|
|
11
|
+
arguments: ToolCall['arguments'];
|
|
12
|
+
status: CompressedToolStatus;
|
|
13
|
+
}
|
|
14
|
+
export declare function createCompressedToolRecord(ref: number, toolCall: ToolCall, result: ToolResultMessage | undefined): CompressedToolRecord;
|
|
15
|
+
export declare function createToolSummaryText(records: CompressedToolRecord[]): string;
|
|
16
|
+
/** Returns message indexes whose process details are replaced by the current ignore boundaries. */
|
|
17
|
+
export declare function getIgnoredMessageIndexes(messages: AgentMessage[], state: ContextIgnoreState, ignoredMessages?: WeakSet<AgentMessage>): Set<number>;
|
|
18
|
+
/** Captures full-session ignore decisions for strict projection onto compaction slices. */
|
|
19
|
+
export declare function getIgnoredMessageReferences(messages: AgentMessage[], state: ContextIgnoreState): WeakSet<AgentMessage>;
|
|
20
|
+
export interface TransformResult {
|
|
21
|
+
messages: AgentMessage[];
|
|
22
|
+
toolReferences: Map<number, string>;
|
|
23
|
+
}
|
|
24
|
+
export declare function transformContextMessages(messages: AgentMessage[], state: ContextIgnoreState, options?: ContextIgnoreTransformOptions): TransformResult;
|