pulse-coder-engine 0.0.1-alpha.1
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/examples/new-engine-usage.ts +52 -0
- package/package.json +54 -0
- package/src/Engine.ts +150 -0
- package/src/ai/index.ts +116 -0
- package/src/built-in/index.ts +27 -0
- package/src/built-in/mcp-plugin/index.ts +104 -0
- package/src/built-in/skills-plugin/index.ts +223 -0
- package/src/built-in/sub-agent-plugin/index.ts +203 -0
- package/src/config/index.ts +35 -0
- package/src/context/index.ts +134 -0
- package/src/core/loop.ts +147 -0
- package/src/index.ts +17 -0
- package/src/plugin/EnginePlugin.ts +60 -0
- package/src/plugin/PluginManager.ts +426 -0
- package/src/plugin/UserConfigPlugin.ts +183 -0
- package/src/prompt/index.ts +1 -0
- package/src/prompt/system.ts +126 -0
- package/src/shared/types.ts +50 -0
- package/src/tools/bash.ts +59 -0
- package/src/tools/clarify.ts +74 -0
- package/src/tools/edit.ts +79 -0
- package/src/tools/grep.ts +148 -0
- package/src/tools/index.ts +44 -0
- package/src/tools/ls.ts +20 -0
- package/src/tools/read.ts +69 -0
- package/src/tools/tavily.ts +55 -0
- package/src/tools/utils.ts +16 -0
- package/src/tools/write.ts +42 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Skills Plugin for Pulse Coder Engine
|
|
3
|
+
* 将技能系统作为引擎内置插件
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EnginePlugin, EnginePluginContext } from '../../plugin/EnginePlugin';
|
|
7
|
+
import { Tool } from '../../shared/types';
|
|
8
|
+
import { existsSync, readFileSync } from 'fs';
|
|
9
|
+
import { globSync } from 'glob';
|
|
10
|
+
import matter from 'gray-matter';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 技能信息接口
|
|
17
|
+
*/
|
|
18
|
+
export interface SkillInfo {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
location: string;
|
|
22
|
+
content: string;
|
|
23
|
+
metadata?: Record<string, any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 技能注册表
|
|
28
|
+
*/
|
|
29
|
+
export class BuiltInSkillRegistry {
|
|
30
|
+
private skills: Map<string, SkillInfo> = new Map();
|
|
31
|
+
private initialized = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 初始化注册表,扫描并加载所有技能
|
|
35
|
+
*/
|
|
36
|
+
async initialize(cwd: string): Promise<void> {
|
|
37
|
+
if (this.initialized) {
|
|
38
|
+
console.warn('SkillRegistry already initialized');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('Scanning built-in skills...');
|
|
43
|
+
const skillList = await this.scanSkills(cwd);
|
|
44
|
+
|
|
45
|
+
this.skills.clear();
|
|
46
|
+
for (const skill of skillList) {
|
|
47
|
+
this.skills.set(skill.name, skill);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.initialized = true;
|
|
51
|
+
console.log(`Loaded ${this.skills.size} built-in skill(s)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 扫描技能文件
|
|
56
|
+
*/
|
|
57
|
+
private async scanSkills(cwd: string): Promise<SkillInfo[]> {
|
|
58
|
+
const skills: SkillInfo[] = [];
|
|
59
|
+
|
|
60
|
+
const scanPaths = [
|
|
61
|
+
// 项目级技能(优先 .pulse-coder,兼容旧版 .coder 和 .claude)
|
|
62
|
+
{ base: cwd, pattern: '.pulse-coder/skills/**/SKILL.md' },
|
|
63
|
+
{ base: cwd, pattern: '.coder/skills/**/SKILL.md' },
|
|
64
|
+
{ base: cwd, pattern: '.claude/skills/**/SKILL.md' },
|
|
65
|
+
// 用户级技能(优先 .pulse-coder,兼容旧版 .coder)
|
|
66
|
+
{ base: homedir(), pattern: '.pulse-coder/skills/**/SKILL.md' },
|
|
67
|
+
{ base: homedir(), pattern: '.coder/skills/**/SKILL.md' }
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const { base, pattern } of scanPaths) {
|
|
71
|
+
try {
|
|
72
|
+
const files = globSync(pattern, { cwd: base, absolute: true });
|
|
73
|
+
|
|
74
|
+
for (const filePath of files) {
|
|
75
|
+
try {
|
|
76
|
+
const skillInfo = this.parseSkillFile(filePath);
|
|
77
|
+
if (skillInfo) {
|
|
78
|
+
skills.push(skillInfo);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.warn(`Failed to parse skill file ${filePath}:`, error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.debug(`Skip scanning ${pattern} in ${base}:`, error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return skills;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 解析技能文件
|
|
94
|
+
*/
|
|
95
|
+
private parseSkillFile(filePath: string): SkillInfo | null {
|
|
96
|
+
try {
|
|
97
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
98
|
+
const { data, content: markdownContent } = matter(content);
|
|
99
|
+
|
|
100
|
+
if (!data.name || !data.description) {
|
|
101
|
+
console.warn(`Skill file ${filePath} missing required fields (name or description)`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
name: data.name,
|
|
107
|
+
description: data.description,
|
|
108
|
+
location: filePath,
|
|
109
|
+
content: markdownContent,
|
|
110
|
+
metadata: data
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.warn(`Failed to read skill file ${filePath}:`, error);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 获取所有技能
|
|
120
|
+
*/
|
|
121
|
+
getAll(): SkillInfo[] {
|
|
122
|
+
return Array.from(this.skills.values());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 根据名称获取技能
|
|
127
|
+
*/
|
|
128
|
+
get(name: string): SkillInfo | undefined {
|
|
129
|
+
return this.skills.get(name);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 检查技能是否存在
|
|
134
|
+
*/
|
|
135
|
+
has(name: string): boolean {
|
|
136
|
+
return this.skills.has(name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 搜索技能(模糊匹配)
|
|
141
|
+
*/
|
|
142
|
+
search(keyword: string): SkillInfo[] {
|
|
143
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
144
|
+
return this.getAll().filter(
|
|
145
|
+
(skill) =>
|
|
146
|
+
skill.name.toLowerCase().includes(lowerKeyword) ||
|
|
147
|
+
skill.description.toLowerCase().includes(lowerKeyword)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 技能工具参数 schema
|
|
154
|
+
*/
|
|
155
|
+
const skillToolSchema = z.object({
|
|
156
|
+
name: z.string().describe('The name of the skill to execute')
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
type SkillToolInput = z.infer<typeof skillToolSchema>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 生成技能工具
|
|
163
|
+
*/
|
|
164
|
+
function generateSkillTool(skills: SkillInfo[]): Tool<SkillToolInput, SkillInfo> {
|
|
165
|
+
const getSkillsPrompt = (availableSkills: SkillInfo[]) => {
|
|
166
|
+
return [
|
|
167
|
+
"If query matches an available skill's description or instruction [use skill], use the skill tool to get detailed instructions.",
|
|
168
|
+
"Load a skill to get detailed instructions for a specific task.",
|
|
169
|
+
"Skills provide specialized knowledge and step-by-step guidance.",
|
|
170
|
+
"Use this when a task matches an available skill's description.",
|
|
171
|
+
"Only the skills listed here are available:",
|
|
172
|
+
"[!important] You should follow the skill's step-by-step guidance. If the skill is not complete, ask the user for more information.",
|
|
173
|
+
"<available_skills>",
|
|
174
|
+
...availableSkills.flatMap((skill) => [
|
|
175
|
+
` <skill>`,
|
|
176
|
+
` <name>${skill.name}</name>`,
|
|
177
|
+
` <description>${skill.description}</description>`,
|
|
178
|
+
` </skill>`
|
|
179
|
+
]),
|
|
180
|
+
"</available_skills>"
|
|
181
|
+
].join(" ");
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
name: "skill",
|
|
186
|
+
description: getSkillsPrompt(skills),
|
|
187
|
+
inputSchema: skillToolSchema,
|
|
188
|
+
execute: async ({ name }) => {
|
|
189
|
+
const skill = skills.find((skill) => skill.name === name);
|
|
190
|
+
if (!skill) {
|
|
191
|
+
throw new Error(`Skill ${name} not found`);
|
|
192
|
+
}
|
|
193
|
+
return skill;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 内置技能插件
|
|
200
|
+
*/
|
|
201
|
+
export const builtInSkillsPlugin: EnginePlugin = {
|
|
202
|
+
name: '@pulse-coder/engine/built-in-skills',
|
|
203
|
+
version: '1.0.0',
|
|
204
|
+
|
|
205
|
+
async initialize(context: EnginePluginContext) {
|
|
206
|
+
const registry = new BuiltInSkillRegistry();
|
|
207
|
+
await registry.initialize(process.cwd());
|
|
208
|
+
|
|
209
|
+
const skills = registry.getAll();
|
|
210
|
+
if (skills.length === 0) {
|
|
211
|
+
console.log('[Skills] No skills found');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const skillTool = generateSkillTool(skills);
|
|
216
|
+
context.registerTool('skill', skillTool);
|
|
217
|
+
context.registerService('skillRegistry', registry);
|
|
218
|
+
|
|
219
|
+
console.log(`[Skills] Registered ${skills.length} skill(s)`);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export default builtInSkillsPlugin;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import type { EnginePlugin, EnginePluginContext } from '../../plugin/EnginePlugin';
|
|
5
|
+
import type { Context } from '../../shared/types.js';
|
|
6
|
+
import { loop } from '../../core/loop';
|
|
7
|
+
import { BuiltinToolsMap } from '../../tools';
|
|
8
|
+
import { Tool } from 'ai';
|
|
9
|
+
|
|
10
|
+
interface AgentConfig {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
systemPrompt: string;
|
|
14
|
+
filePath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class ConfigLoader {
|
|
18
|
+
|
|
19
|
+
async getAgentFilesInfo(configDirs: string[]) {
|
|
20
|
+
const fileInfos: Array<{ files: string[], configDir: string }> = [];
|
|
21
|
+
for (let configDir of configDirs) {
|
|
22
|
+
try {
|
|
23
|
+
await fs.access(configDir);
|
|
24
|
+
|
|
25
|
+
const files = await fs.readdir(configDir);
|
|
26
|
+
fileInfos.push({ files, configDir });
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fileInfos;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async loadAgentConfigs(configDir: string | string[] = ['.pulse-coder/agents', '.coder/agents']): Promise<AgentConfig[]> {
|
|
36
|
+
const configs: AgentConfig[] = [];
|
|
37
|
+
|
|
38
|
+
const configDirs = Array.isArray(configDir) ? configDir : [configDir];
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const filesInfo = await this.getAgentFilesInfo(configDirs);
|
|
42
|
+
|
|
43
|
+
for (const fileInfo of filesInfo) {
|
|
44
|
+
const files = fileInfo.files;
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if (file.endsWith('.md')) {
|
|
47
|
+
const config = await this.parseConfig(path.join(fileInfo.configDir, file));
|
|
48
|
+
if (config) configs.push(config);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(`Failed to scan agent configs: ${error}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return configs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async parseConfig(filePath: string): Promise<AgentConfig | null> {
|
|
60
|
+
try {
|
|
61
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
|
|
64
|
+
let name = '';
|
|
65
|
+
let description = '';
|
|
66
|
+
let systemPrompt = '';
|
|
67
|
+
let inFrontmatter = false;
|
|
68
|
+
let frontmatterEnd = false;
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (line.trim() === '---') {
|
|
72
|
+
if (!inFrontmatter) {
|
|
73
|
+
inFrontmatter = true;
|
|
74
|
+
continue;
|
|
75
|
+
} else {
|
|
76
|
+
frontmatterEnd = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (inFrontmatter && !frontmatterEnd) {
|
|
82
|
+
const match = line.match(/^\s*(\w+)\s*:\s*(.+)$/);
|
|
83
|
+
if (match) {
|
|
84
|
+
const [, key, value] = match;
|
|
85
|
+
if (key === 'name') name = value.trim();
|
|
86
|
+
if (key === 'description') description = value.trim();
|
|
87
|
+
}
|
|
88
|
+
} else if (frontmatterEnd) {
|
|
89
|
+
systemPrompt += line + '\n';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!name) {
|
|
94
|
+
name = path.basename(filePath, '.md');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: name.trim(),
|
|
99
|
+
description: description.trim(),
|
|
100
|
+
systemPrompt: systemPrompt.trim(),
|
|
101
|
+
filePath
|
|
102
|
+
};
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.warn(`Failed to parse agent config ${filePath}: ${error}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class AgentRunner {
|
|
111
|
+
async runAgent(
|
|
112
|
+
config: AgentConfig,
|
|
113
|
+
task: string,
|
|
114
|
+
context?: Record<string, any>,
|
|
115
|
+
tools?: Record<string, any>
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const subContext: Context = {
|
|
118
|
+
messages: [
|
|
119
|
+
{ role: 'system', content: config.systemPrompt },
|
|
120
|
+
{ role: 'user', content: task }
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (context && Object.keys(context).length > 0) {
|
|
125
|
+
subContext.messages.push({
|
|
126
|
+
role: 'user',
|
|
127
|
+
content: `上下文信息:\n${JSON.stringify(context, null, 2)}`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return await loop(subContext, { tools });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class SubAgentPlugin implements EnginePlugin {
|
|
136
|
+
name = 'sub-agent';
|
|
137
|
+
version = '1.0.0';
|
|
138
|
+
|
|
139
|
+
private configLoader = new ConfigLoader();
|
|
140
|
+
private agentRunner = new AgentRunner();
|
|
141
|
+
|
|
142
|
+
async initialize(context: EnginePluginContext): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
const configs = await this.configLoader.loadAgentConfigs();
|
|
145
|
+
|
|
146
|
+
// 获取插件管理器提供的所有工具
|
|
147
|
+
const tools = this.getAvailableTools(context);
|
|
148
|
+
|
|
149
|
+
for (const config of configs) {
|
|
150
|
+
this.registerAgentTool(context, config, tools);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
context.logger.info(`SubAgentPlugin loaded ${configs.length} agents.`);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
context.logger.error('Failed to initialize SubAgentPlugin', error as Error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private getAvailableTools(context: EnginePluginContext): Record<string, any> {
|
|
160
|
+
// 从插件上下文中获取所有注册的工具
|
|
161
|
+
// 在初始化阶段,所有工具已经通过插件系统注册
|
|
162
|
+
const allTools: Record<string, any> = {};
|
|
163
|
+
|
|
164
|
+
// 这里我们假设工具通过某种方式可用
|
|
165
|
+
// 实际使用时,引擎会提供所有可用的工具
|
|
166
|
+
return BuiltinToolsMap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private registerAgentTool(
|
|
170
|
+
context: EnginePluginContext,
|
|
171
|
+
config: AgentConfig,
|
|
172
|
+
tools: Record<string, any>
|
|
173
|
+
): void {
|
|
174
|
+
const toolName = `${config.name}_agent`;
|
|
175
|
+
|
|
176
|
+
const tool: Tool = {
|
|
177
|
+
description: config.description,
|
|
178
|
+
inputSchema: z.object({
|
|
179
|
+
task: z.string().describe('要执行的任务描述'),
|
|
180
|
+
context: z.any().optional().describe('任务上下文信息')
|
|
181
|
+
}),
|
|
182
|
+
execute: async ({ task, context: taskContext }: { task: string; context?: Record<string, any> }) => {
|
|
183
|
+
try {
|
|
184
|
+
context.logger.info(`Running agent ${config.name}: ${task}`);
|
|
185
|
+
const result = await this.agentRunner.runAgent(config, task, taskContext, tools);
|
|
186
|
+
context.logger.info(`Agent ${config.name} completed successfully`);
|
|
187
|
+
return result;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
context.logger.error(`Agent ${config.name} failed`, error as Error);
|
|
190
|
+
throw new Error(`Agent ${config.name} failed: ${error}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
context.registerTool(toolName, tool);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async destroy(context: EnginePluginContext): Promise<void> {
|
|
199
|
+
context.logger.info('SubAgentPlugin destroyed');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default SubAgentPlugin;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
4
|
+
import { LanguageModel } from "ai";
|
|
5
|
+
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
export const CoderAI = (process.env.USE_ANTHROPIC
|
|
9
|
+
? createAnthropic({
|
|
10
|
+
apiKey: process.env.ANTHROPIC_API_KEY || '',
|
|
11
|
+
baseURL: process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com/v1'
|
|
12
|
+
})
|
|
13
|
+
: createOpenAI({
|
|
14
|
+
apiKey: process.env.OPENAI_API_KEY || '',
|
|
15
|
+
baseURL: process.env.OPENAI_API_URL || 'https://api.openai.com/v1'
|
|
16
|
+
}).chat) as (model: string) => LanguageModel;
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL || 'novita/deepseek/deepseek_v3';
|
|
19
|
+
|
|
20
|
+
export const MAX_TURNS = 100;
|
|
21
|
+
export const MAX_ERROR_COUNT = 3;
|
|
22
|
+
export const MAX_STEPS = 100;
|
|
23
|
+
export const MAX_TOOL_OUTPUT_LENGTH = 30_000;
|
|
24
|
+
|
|
25
|
+
export const CONTEXT_WINDOW_TOKENS = Number(process.env.CONTEXT_WINDOW_TOKENS ?? 64_000);
|
|
26
|
+
export const COMPACT_TRIGGER = Number(process.env.COMPACT_TRIGGER ?? Math.floor(CONTEXT_WINDOW_TOKENS * 0.75));
|
|
27
|
+
export const COMPACT_TARGET = Number(process.env.COMPACT_TARGET ?? Math.floor(CONTEXT_WINDOW_TOKENS * 0.5));
|
|
28
|
+
export const KEEP_LAST_TURNS = Number(process.env.KEEP_LAST_TURNS ?? 6);
|
|
29
|
+
export const COMPACT_SUMMARY_MAX_TOKENS = Number(process.env.COMPACT_SUMMARY_MAX_TOKENS ?? 1200);
|
|
30
|
+
export const MAX_COMPACTION_ATTEMPTS = Number(process.env.MAX_COMPACTION_ATTEMPTS ?? 2);
|
|
31
|
+
export const OPENAI_REASONING_EFFORT = process.env.OPENAI_REASONING_EFFORT;
|
|
32
|
+
|
|
33
|
+
// Clarification settings
|
|
34
|
+
export const CLARIFICATION_TIMEOUT = Number(process.env.CLARIFICATION_TIMEOUT ?? 300_000); // 5 minutes
|
|
35
|
+
export const CLARIFICATION_ENABLED = process.env.CLARIFICATION_ENABLED !== 'false';
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { pruneMessages, type ModelMessage } from "ai";
|
|
2
|
+
import { summarizeMessages } from "../ai";
|
|
3
|
+
import {
|
|
4
|
+
COMPACT_TRIGGER,
|
|
5
|
+
COMPACT_TARGET,
|
|
6
|
+
KEEP_LAST_TURNS,
|
|
7
|
+
} from "../config/index";
|
|
8
|
+
import type { Context } from "../shared/types";
|
|
9
|
+
|
|
10
|
+
type CompactResult = {
|
|
11
|
+
didCompact: boolean;
|
|
12
|
+
reason?: string;
|
|
13
|
+
newMessages?: ModelMessage[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ensureSummaryPrefix = (summary: string): string => {
|
|
17
|
+
const trimmed = summary.trim();
|
|
18
|
+
if (trimmed.length === 0) {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
if (trimmed.startsWith('[COMPACTED_CONTEXT]')) {
|
|
22
|
+
return trimmed;
|
|
23
|
+
}
|
|
24
|
+
return `[COMPACTED_CONTEXT]\n${trimmed}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const safeStringify = (value: unknown): string => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
} catch {
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const estimateTokens = (messages: ModelMessage[]): number => {
|
|
36
|
+
let totalChars = 0;
|
|
37
|
+
for (const message of messages) {
|
|
38
|
+
totalChars += message.role.length;
|
|
39
|
+
if (typeof message.content === 'string') {
|
|
40
|
+
totalChars += message.content.length;
|
|
41
|
+
} else {
|
|
42
|
+
totalChars += safeStringify(message.content).length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return Math.ceil(totalChars / 4);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const splitByTurns = (messages: ModelMessage[], keepLastTurns: number) => {
|
|
49
|
+
const userIndices: number[] = [];
|
|
50
|
+
messages.forEach((message, index) => {
|
|
51
|
+
if (message.role === 'user') {
|
|
52
|
+
userIndices.push(index);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (userIndices.length <= keepLastTurns) {
|
|
57
|
+
return { oldMessages: [], recentMessages: messages };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cutIndex = userIndices[userIndices.length - keepLastTurns];
|
|
61
|
+
return {
|
|
62
|
+
oldMessages: messages.slice(0, cutIndex),
|
|
63
|
+
recentMessages: messages.slice(cutIndex),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const takeLastTurns = (messages: ModelMessage[], keepLastTurns: number): ModelMessage[] => {
|
|
68
|
+
const userIndices: number[] = [];
|
|
69
|
+
messages.forEach((message, index) => {
|
|
70
|
+
if (message.role === 'user') {
|
|
71
|
+
userIndices.push(index);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (userIndices.length === 0) {
|
|
76
|
+
return messages;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (userIndices.length <= keepLastTurns) {
|
|
80
|
+
return messages;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const startIndex = userIndices[userIndices.length - keepLastTurns];
|
|
84
|
+
return messages.slice(startIndex);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const maybeCompactContext = async (
|
|
88
|
+
context: Context,
|
|
89
|
+
options?: { force?: boolean }
|
|
90
|
+
): Promise<CompactResult> => {
|
|
91
|
+
const { messages } = context;
|
|
92
|
+
if (messages.length === 0) {
|
|
93
|
+
return { didCompact: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const estimatedTokens = estimateTokens(messages);
|
|
97
|
+
if (!options?.force && estimatedTokens < COMPACT_TRIGGER) {
|
|
98
|
+
return { didCompact: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { oldMessages, recentMessages } = splitByTurns(messages, KEEP_LAST_TURNS);
|
|
102
|
+
if (oldMessages.length === 0) {
|
|
103
|
+
return { didCompact: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const summary = await summarizeMessages(oldMessages);
|
|
108
|
+
const summaryText = ensureSummaryPrefix(summary);
|
|
109
|
+
if (!summaryText) {
|
|
110
|
+
throw new Error('Empty summary result');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nextMessages: ModelMessage[] = [
|
|
114
|
+
{ role: 'assistant', content: summaryText },
|
|
115
|
+
...recentMessages,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (estimateTokens(nextMessages) > COMPACT_TARGET) {
|
|
119
|
+
const newMessages = takeLastTurns(messages, KEEP_LAST_TURNS);
|
|
120
|
+
return { didCompact: true, reason: 'summary-too-large', newMessages };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { didCompact: true, newMessages: nextMessages };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const pruned = pruneMessages({
|
|
126
|
+
messages,
|
|
127
|
+
reasoning: 'all',
|
|
128
|
+
toolCalls: 'all',
|
|
129
|
+
emptyMessages: 'remove',
|
|
130
|
+
});
|
|
131
|
+
const newMessages = takeLastTurns(pruned, KEEP_LAST_TURNS);
|
|
132
|
+
return { didCompact: true, reason: 'fallback', newMessages };
|
|
133
|
+
}
|
|
134
|
+
};
|