@nickyzj2023/ai 1.2.0 → 1.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ai
2
2
 
3
- 男生自用MyPi Coding Agent,0外部依赖
3
+ 男生自用MyPi Coding Agent
4
4
 
5
5
  ## 安装
6
6
 
package/dist/cli.mjs CHANGED
@@ -1,9 +1,11 @@
1
- import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-BJFCp7ur.mjs";
2
- import { extractErrorMessage } from "@nickyzj2023/utils";
1
+ import { a as defineTool, i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-Dm0rwYe2.mjs";
2
+ import { extractErrorMessage, isObject, omit } from "@nickyzj2023/utils";
3
3
  import readline from "node:readline";
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
+ import { Client } from "@modelcontextprotocol/sdk/client";
8
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
9
  //#region src/utils/config.ts
8
10
  /**
9
11
  * 获取全局配置文件路径(Windows/macOS/Linux通用)
@@ -162,16 +164,78 @@ var TUI = class {
162
164
  this.preparePrint("tool_result");
163
165
  process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
164
166
  }
167
+ formatter = new Intl.NumberFormat("en-US", {
168
+ notation: "compact",
169
+ maximumFractionDigits: 1
170
+ });
171
+ format(number) {
172
+ return this.formatter.format(number);
173
+ }
165
174
  /** 打印轮次结束原因、token消耗 */
166
175
  printFinish(finishReason, usage) {
167
176
  this.preparePrint("done");
168
- process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${usage.prompt_tokens}tok,输出${usage.completion_tokens}tok,总共${usage.total_tokens}tok` : ""}${finishReason === "stop" ? "\n\n" : "\n"}`, "90"));
177
+ process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${this.format(usage.prompt_tokens)}token,输出${this.format(usage.completion_tokens)}token,总共${this.format(usage.total_tokens)}token` : ""}${finishReason === "stop" ? "\n\n" : "\n"}`, "90"));
169
178
  }
170
179
  };
171
180
  //#endregion
181
+ //#region src/tools/mcp.ts
182
+ /** 全局单例MCP加载器 */
183
+ let router = null;
184
+ var MCPRouter = class {
185
+ entries = /* @__PURE__ */ new Map();
186
+ /** 注册一个新的MCP客户端 */
187
+ async addClient(name, url, options) {
188
+ if (this.entries.has(name)) return;
189
+ const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: options?.headers } });
190
+ const client = new Client({
191
+ name,
192
+ version: "1.0.0"
193
+ });
194
+ await client.connect(transport);
195
+ const { tools } = await client.listTools();
196
+ const normalizedTools = tools.filter((tool) => !options?.ignoredToolNames?.includes(tool.name)).map((tool) => {
197
+ const _properties = { ...tool.inputSchema.properties ?? {} };
198
+ tool.inputSchema.required?.forEach((key) => {
199
+ if (isObject(_properties[key])) _properties[key] = {
200
+ ..._properties[key],
201
+ required: true
202
+ };
203
+ });
204
+ return defineTool(tool.name, tool.description ?? "", _properties, (args) => client.callTool({
205
+ name: tool.name,
206
+ arguments: args
207
+ }));
208
+ });
209
+ this.entries.set(name, {
210
+ client,
211
+ tools: normalizedTools
212
+ });
213
+ return client;
214
+ }
215
+ /** 返回OpenAI API兼容的tools数组 */
216
+ async getTools() {
217
+ return [...this.entries.values()].flatMap((e) => e.tools);
218
+ }
219
+ };
220
+ /**
221
+ * 把传入的MCPServer列表转换成OpenAI API兼容的tools数组
222
+ */
223
+ const loadMCPTools = async (mcpServers) => {
224
+ router ||= new MCPRouter();
225
+ await Promise.allSettled(Object.entries(mcpServers).map(async ([name, server]) => {
226
+ try {
227
+ await router?.addClient(name, server.url, omit(server, ["type", "url"]));
228
+ console.log(`已加载MCP工具:${name}`);
229
+ } catch (e) {
230
+ console.error(`MCP服务器${name}连接失败,跳过:${extractErrorMessage(e)}`);
231
+ }
232
+ }));
233
+ return router.getTools();
234
+ };
235
+ //#endregion
172
236
  //#region src/cli.ts
173
237
  /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
174
- const startChat = () => {
238
+ const startChat = async () => {
175
239
  const config = loadConfig();
176
240
  if (!config) {
177
241
  console.error("请先运行 `ai setup` 配置一个模型");
@@ -179,7 +243,12 @@ const startChat = () => {
179
243
  }
180
244
  const model = defineModel(config);
181
245
  const messages = [];
182
- const tools = [get_weather_default, get_time_default];
246
+ const mcpTools = await loadMCPTools(config.mcpServers);
247
+ const tools = [
248
+ get_weather_default,
249
+ get_time_default,
250
+ ...mcpTools
251
+ ];
183
252
  const tui = new TUI(async (input) => {
184
253
  messages.push({
185
254
  role: "user",
package/dist/index.d.mts CHANGED
@@ -150,124 +150,102 @@ declare const _default$1: ToolDefinition;
150
150
  //#endregion
151
151
  //#region src/utils/compact/types.d.ts
152
152
  declare namespace Compact {
153
- type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<Message["content"]> | Message["content"];
154
- type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<Message["content"]> | Message["content"];
153
+ type Options = {
154
+ /** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
155
+ usage?: Usage;
156
+ /**
157
+ * 各种压缩方式统一保留的最近消息条数
158
+ * @default 10
159
+ * @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
160
+ */
161
+ keepCount?: number;
162
+ /**
163
+ * 压缩工具/媒体消息时做个标记,防止下次重复压缩
164
+ * @default "[已压缩]"
165
+ */
166
+ compactedMessageMark?: string;
167
+ /**
168
+ * 上下文>总上下文*ratio时压缩工具调用结果
169
+ * @default 0.6
170
+ */
171
+ ratioToCompactToolResults?: number;
172
+ /**
173
+ * 如何压缩工具调用结果
174
+ * @default 让其他模型返回简化后的工具结果
175
+ */
176
+ replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
177
+ /**
178
+ * 上下文>总上下文*ratio时压缩图片/音频/视频消息
179
+ * @default 0.7
180
+ */
181
+ ratioToCompactMedia?: number;
182
+ /**
183
+ * 如何压缩媒体消息
184
+ * @default 让其他模型用自然语言简短描述一遍
185
+ */
186
+ replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
187
+ /**
188
+ * 上下文>总上下文*ratio时总结消息
189
+ * @default 0.8
190
+ * @remarks 如果总结成功,会把keepCount以前的消息压成一条用户消息
191
+ */
192
+ ratioToSummarize?: number;
193
+ /**
194
+ * 总结消息时的配置项
195
+ * @default { systemPrompt: "你现在的任务是总结历史消息" }
196
+ */
197
+ summarizeOptions?: Partial<Compact.SummarizeOptions>;
198
+ };
199
+ type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
200
+ type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
155
201
  type SummarizeOptions = {
156
- /** 用什么模型总结 */
157
- model: Model;
158
- /** 用于指导大模型如何总结消息的提示词 */
202
+ /** 指导大模型如何总结消息 */
159
203
  systemPrompt: string;
204
+ model: Model;
160
205
  };
161
- /**
162
- * compactMessages 的返回值,告知调用方各压缩动作是否执行
163
- * @remarks
164
- * 这些字段是"操作级"标志:为 true 只代表对应操作已执行,不代表一定产生了效果。
165
- * 例如 hasSummarized 为 true 只代表进入了总结流程,是否真的总结成功,
166
- * 应由调用方检查消息数组里是否出现含 `<summary>` 标签的消息来判断。
167
- */
168
- type CompactResult = {
169
- /** 是否执行了压缩工具调用结果 */
170
- hasCompactedToolResult: boolean;
171
- /** 是否执行了压缩图片/音频/视频消息 */
206
+ type Result = {
207
+ /** 是否压缩了工具调用结果 */
208
+ hasCompactedToolResults: boolean;
209
+ /** 是否压缩了图片/音频/视频消息 */
172
210
  hasCompactedMedia: boolean;
173
- /** 是否执行了清理软删除残留的占位消息 */
174
- hasClearedSoftDeletedMessages: boolean;
175
- /** 是否执行了总结消息操作(是否真的总结,请检查消息中是否出现`<summary>`标签) */
211
+ /** 是否总结了消息 */
176
212
  hasSummarized: boolean;
177
- /** 是否执行了兜底硬删除较早消息 */
178
- hasDeletedOldMessages: boolean;
213
+ /** 是否丢弃了一些旧消息(最终兜底策略) */
214
+ hasDiscardMessages: boolean;
179
215
  };
180
216
  }
181
217
  //#endregion
182
218
  //#region src/utils/compact/strategy.d.ts
183
- declare const softDeleteToolResults: (messages: Message[], options: {
219
+ /**
220
+ * @returns 实际处理了几条消息
221
+ */
222
+ declare const compactToolResults: (compressible: Message[], options: {
184
223
  replacer?: Compact.ReplacerOfToolResultContent;
185
224
  mark: string;
186
225
  model?: Model;
187
- }) => Promise<Message[]>;
188
- declare const softDeleteOldMediaMessages: (messages: Message[], options: {
226
+ }) => Promise<number>;
227
+ /**
228
+ * @returns 实际处理了几条消息
229
+ */
230
+ declare const compactMediaMessages: (compressible: Message[], options: {
189
231
  replacer?: Compact.ReplacerOfMediaContent;
190
- mark: string;
191
232
  model?: Model;
192
- }) => Promise<Message[]>;
233
+ }) => Promise<number>;
234
+ declare const summarizeMessages: (compressible: Message[], options: Compact.SummarizeOptions) => Promise<number>;
193
235
  /**
194
- * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
195
- * @param messages 可压缩区消息数组,直接原地删除
196
- * @param softDeleted 本轮被软删过的消息引用集合
197
- * @remarks
198
- * tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
199
- * 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
200
- * 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
201
- * 组内多条tool被软删时,集合去重后只会删除一次。
236
+ * 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
202
237
  */
203
- declare const hardDeleteSoftMessages: (messages: Message[], softDeleted: ReadonlySet<Message>) => void;
204
- declare const summarizeMessages: (messages: Message[], options: Compact.SummarizeOptions) => Promise<void>;
205
- declare const hardDeleteOldMessages: (messages: Message[]) => void;
238
+ declare const discardMessagesUntil: (compressible: Message[], until: (compressible: Message[]) => boolean) => number;
206
239
  //#endregion
207
240
  //#region src/utils/compact/index.d.ts
208
241
  /**
209
242
  * 自动优化上下文,类似AI Coding Agent的/compact命令
210
243
  */
211
- declare const compact: ((messages: Message[], model: Model, options?: {
212
- /** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
213
- usage?: Usage;
214
- /**
215
- * 各种压缩方式统一保留的最近消息条数
216
- * @default 10
217
- * @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
218
- */
219
- keepCount?: number;
220
- /**
221
- * 上下文>总上下文*ratio时压缩工具调用结果
222
- * @default 0.5
223
- */
224
- ratioToCompactToolResult?: number;
225
- /**
226
- * 如何压缩工具调用结果,例如让其他模型返回精简后的工具结果
227
- * @default (content) => "(已被消费)"
228
- */
229
- replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
230
- /**
231
- * 上下文>总上下文*ratio时压缩图片/音频/视频消息
232
- * @default 0.6
233
- */
234
- ratioToCompactMedia?: number;
235
- /**
236
- * 如何压缩媒体消息,例如让其他模型用自然语言简短描述一遍
237
- * @default (content) => "(已被丢弃)"
238
- */
239
- replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
240
- /**
241
- * 用于标记哪些消息被软删除了
242
- */
243
- softDeletedMessageMark?: string;
244
- /**
245
- * 上下文>总上下文*ratio时清理软删除残留的占位消息
246
- * @default 0.7
247
- * @remarks
248
- * 软删除(ratioToCompactToolResult/ratioToCompactMedia)只替换content不删消息,
249
- * 该选项负责把残留的占位消息真正移除。信息在软删除时已丢失,清理不损失任何额外信息。
250
- * 阈值应介于ratioToCompactMedia与ratioToSummarize之间:
251
- * 太早则媒体软删还没执行、无残留可清;太晚则总结/硬删除兜底已处理整个压缩区,清理失去意义
252
- */
253
- ratioToClearSoftDeletedMessages?: number;
254
- /**
255
- * 上下文>总上下文*ratio时总结消息
256
- * @default 0.8
257
- * @remarks 如果总结成功,会把keepCount(默认10,见顶层选项)以前的消息压成一条消息;如果总结失败,会采取兜底压缩方法:硬删除keepCount以前的消息
258
- */
259
- ratioToSummarize?: number;
260
- /**
261
- * 总结消息时的配置项
262
- * @default { model: undefined, systemPrompt: "总结历史消息" }
263
- */
264
- summarizeOptions?: Partial<Compact.SummarizeOptions>;
265
- }) => Promise<Compact.CompactResult>) & {
244
+ declare const compact: ((messages: Message[], model: Model, options?: Compact.Options) => Promise<Compact.Result>) & {
245
+ compactToolResults: typeof compactToolResults;
246
+ compactMediaMessages: typeof compactMediaMessages;
266
247
  summarizeMessages: typeof summarizeMessages;
267
- softDeleteToolResults: typeof softDeleteToolResults;
268
- softDeleteOldMediaMessages: typeof softDeleteOldMediaMessages;
269
- hardDeleteSoftMessages: typeof hardDeleteSoftMessages;
270
- hardDeleteOldMessages: typeof hardDeleteOldMessages;
248
+ discardMessagesUntil: typeof discardMessagesUntil;
271
249
  };
272
250
  //#endregion
273
251
  //#region src/utils/helper.d.ts
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as defineTool, i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default, t as compact } from "./src-BJFCp7ur.mjs";
1
+ import { a as defineTool, i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default, t as compact } from "./src-Dm0rwYe2.mjs";
2
2
  export { compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, runAgent };
@@ -231,246 +231,201 @@ var get_weather_default = defineTool("get_weather", "查询指定城市的天气
231
231
  //#endregion
232
232
  //#region src/utils/compact/helper.ts
233
233
  /**
234
- * 判断消息是否为带有工具调用的 assistant 消息
234
+ * 校验assistant(tool_calls)消息
235
235
  */
236
- const hasToolCalls = (message) => message?.role === "assistant" && Array.isArray(message.tool_calls);
236
+ const isToolCalls = (message) => {
237
+ return message?.role === "assistant" && Array.isArray(message.tool_calls);
238
+ };
239
+ /**
240
+ * 校验多模态消息
241
+ */
242
+ const isMediaMessage = (message) => {
243
+ const MEDIA_TYPES = [
244
+ "image_url",
245
+ "input_audio",
246
+ "video_url"
247
+ ];
248
+ return message && Array.isArray(message.content) && message.content.some((part) => MEDIA_TYPES.includes(part.type));
249
+ };
237
250
  /**
238
- * 查找tool消息所属的 assistant(tool_calls) + tool 配对组范围
251
+ * 查找assistant(tool_calls) + tool配对组范围
239
252
  * @param messages 消息数组
240
- * @param toolIndex tool消息的索引
241
- * @returns 配对组范围 [startIndex, endIndex)(含组头assistant与其后连续的全部tool消息);
242
- * 找不到配对组头时返回 null
243
- * @remarks 配对组约定:组头是assistant(tool_calls),组内是紧随其后的连续tool消息。
244
- * 删除或切分tool消息时都应整组处理,否则会留下孤立消息导致OpenAI API返回400。
245
- * 组内任意一条tool消息都能定位到整组,供上层按整组范围批量操作
253
+ * @param toolIndex 组内任意tool消息下标
254
+ * @returns 下标数组[组头assistant(tool_calls), 组内最后一个tool),找不到组头时返回null
246
255
  */
247
256
  const findToolGroupRange = (messages, toolIndex) => {
248
257
  let groupStart = toolIndex;
249
258
  while (groupStart > 0 && messages[groupStart - 1]?.role === "tool") groupStart--;
250
- if (!hasToolCalls(messages[groupStart - 1])) return null;
259
+ if (!isToolCalls(messages[groupStart - 1])) return null;
251
260
  let groupEnd = groupStart;
252
261
  while (groupEnd < messages.length && messages[groupEnd]?.role === "tool") groupEnd++;
253
262
  return [groupStart - 1, groupEnd];
254
263
  };
255
264
  //#endregion
256
265
  //#region src/utils/compact/strategy.ts
257
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
266
+ /** 默认的压缩工具结果策略:让大模型精简消息内容 */
258
267
  const defaultReplacerOfToolResultContent = async (content, options) => {
259
- const { mark, model } = options ?? {};
268
+ const { model } = options ?? {};
260
269
  const messages = [{
261
270
  role: "user",
262
271
  content
263
272
  }, {
264
273
  role: "user",
265
- content: "请用一两句话对上条消息做个“省流”"
274
+ content: "请用一两句话简述上条消息"
266
275
  }];
267
- let simplifiedContent = mark;
276
+ let simplifiedContent = "";
268
277
  for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
278
+ else if (e.type === "error") throw new Error(e.message);
269
279
  return simplifiedContent;
270
280
  };
271
- const softDeleteToolResults = async (messages, options) => {
272
- const { replacer = defaultReplacerOfToolResultContent, mark, model } = options ?? {};
273
- if (!replacer && !model) return [];
274
- const softDeleted = [];
275
- for (const message of messages) if (message?.role === "tool" && typeof message.content === "string") {
281
+ /**
282
+ * @returns 实际处理了几条消息
283
+ */
284
+ const compactToolResults = async (compressible, options) => {
285
+ const { replacer, mark, model } = options ?? {};
286
+ if (!replacer && !model) return 0;
287
+ const _replacer = replacer || defaultReplacerOfToolResultContent;
288
+ let count = 0;
289
+ for (const message of compressible) if (message?.role === "tool" && typeof message.content === "string") {
276
290
  if (message.content.startsWith(mark)) continue;
277
- message.content = await replacer(message.content, {
278
- mark,
279
- model
280
- });
281
- softDeleted.push(message);
291
+ message.content = mark + await _replacer(message.content, { model });
292
+ count++;
282
293
  }
283
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条工具调用结果消息`);
284
- return softDeleted;
294
+ if (count > 0) logger(`压缩了${count}条工具调用结果`);
295
+ return count;
285
296
  };
286
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
297
+ /** 默认的压缩多模态消息策略:让大模型精简消息内容 */
287
298
  const defaultReplacerOfMediaContent = async (content, options) => {
288
- const { mark, model } = options ?? {};
299
+ const { model } = options ?? {};
289
300
  const messages = [{
290
301
  role: "user",
291
302
  content
292
303
  }, {
293
304
  role: "user",
294
- content: "请用一两句话描述上方的多模态消息"
305
+ content: "请用一两句话简述上方的多模态消息"
295
306
  }];
296
- let simplifiedContent = mark;
307
+ let simplifiedContent = "";
297
308
  for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
309
+ else if (e.type === "error") throw new Error(e.message);
298
310
  return simplifiedContent;
299
311
  };
300
- const softDeleteOldMediaMessages = async (messages, options) => {
301
- const { replacer = defaultReplacerOfMediaContent, mark, model } = options ?? {};
302
- if (!replacer && !model) return [];
303
- const mediaTypes = [
304
- "image_url",
305
- "input_audio",
306
- "video_url"
307
- ];
308
- const softDeleted = [];
309
- for (const message of messages) if (message && Array.isArray(message.content) && message.content.some((part) => mediaTypes.includes(part.type))) {
310
- message.content = await replacer(message.content, {
311
- mark,
312
- model
313
- });
314
- softDeleted.push(message);
315
- }
316
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条旧图片/音频/视频消息`);
317
- return softDeleted;
318
- };
319
312
  /**
320
- * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
321
- * @param messages 可压缩区消息数组,直接原地删除
322
- * @param softDeleted 本轮被软删过的消息引用集合
323
- * @remarks
324
- * tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
325
- * 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
326
- * 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
327
- * 组内多条tool被软删时,集合去重后只会删除一次。
313
+ * @returns 实际处理了几条消息
328
314
  */
329
- const hardDeleteSoftMessages = (messages, softDeleted) => {
330
- const deleteIndices = /* @__PURE__ */ new Set();
331
- for (let i = 0; i < messages.length; i++) {
332
- const message = messages[i];
333
- if (!message || !softDeleted.has(message)) continue;
334
- if (message.role === "tool") {
335
- const range = findToolGroupRange(messages, i);
336
- if (range) {
337
- const [start, end] = range;
338
- for (let k = start; k < end; k++) deleteIndices.add(k);
339
- } else deleteIndices.add(i);
340
- } else deleteIndices.add(i);
315
+ const compactMediaMessages = async (compressible, options) => {
316
+ const { replacer, model } = options ?? {};
317
+ if (!replacer && !model) return 0;
318
+ const _replacer = replacer || defaultReplacerOfMediaContent;
319
+ let count = 0;
320
+ for (const message of compressible) if (isMediaMessage(message)) {
321
+ const compacted = await _replacer(message.content, { model });
322
+ message.content = createXMLText("media", compacted);
323
+ count++;
341
324
  }
342
- const sortedIndices = [...deleteIndices].sort((a, b) => b - a);
343
- for (const index of sortedIndices) if (index !== void 0) messages.splice(index, 1);
344
- if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
325
+ if (count > 0) logger(`压缩了${count}条多模态消息`);
326
+ return count;
345
327
  };
346
- const summarizeMessages = async (messages, options) => {
328
+ const summarizeMessages = async (compressible, options) => {
347
329
  const { model, systemPrompt } = options ?? {};
348
- if (messages.length === 0) {
330
+ const summarizable = compressible.slice(1);
331
+ if (summarizable.length === 0) {
349
332
  logger("消息太少,无需总结");
350
- return;
351
- }
352
- const summarizableIndices = [];
353
- const summarizingMessages = [];
354
- for (let i = 0; i < messages.length; i++) {
355
- const message = messages[i];
356
- if (!message) continue;
357
- if (i === 0 && message.role === "system") continue;
358
- if (hasToolCalls(message)) {
359
- let j = i + 1;
360
- while (j < messages.length && messages[j]?.role === "tool") j++;
361
- const group = messages.slice(i, j);
362
- for (let k = i; k < j; k++) summarizableIndices.push(k);
363
- summarizingMessages.push(...group);
364
- i = j - 1;
365
- continue;
366
- }
367
- if (message.role === "tool") continue;
368
- summarizableIndices.push(i);
369
- summarizingMessages.push(message);
333
+ return 0;
370
334
  }
371
- if (summarizableIndices.length === 0) {
372
- logger("没有可总结的消息");
373
- return;
374
- }
375
- summarizingMessages.push({
335
+ const count = summarizable.length;
336
+ summarizable.push({
376
337
  role: "system",
377
338
  content: systemPrompt
378
339
  }, {
379
340
  role: "user",
380
- content: "开始总结"
341
+ content: "开始总结上下文"
381
342
  });
382
343
  let summarized = "";
383
344
  let usage;
384
- for await (const e of runAgent(model, summarizingMessages, [])) if (e.type === "content_delta") summarized += e.delta;
345
+ for await (const e of runAgent(model, summarizable, [])) if (e.type === "content_delta") summarized += e.delta;
385
346
  else if (e.type === "done") usage = e.usage;
386
- logger(`总结了${summarizingMessages.length}条消息,消耗:`, usage);
387
- const firstIndex = summarizableIndices[0] ?? 0;
388
- for (let i = summarizableIndices.length - 1; i >= 0; i--) {
389
- const index = summarizableIndices[i];
390
- if (index !== void 0) messages.splice(index, 1);
391
- }
392
- messages.splice(firstIndex, 0, {
347
+ else if (e.type === "error") throw new Error(e.message);
348
+ compressible.splice(1, Infinity, {
393
349
  role: "user",
394
350
  content: createXMLText("summary", summarized)
395
351
  });
352
+ logger(`总结了${count}条消息,消耗:`, usage);
353
+ return count;
396
354
  };
397
- const hardDeleteOldMessages = (messages) => {
398
- const startIndex = messages.findIndex((message) => message.role === "user");
399
- if (startIndex < 0) {
400
- logger("消息太少,无需硬删除");
401
- return;
355
+ /**
356
+ * 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
357
+ */
358
+ const discardMessagesUntil = (compressible, until) => {
359
+ let count = 0;
360
+ while (compressible.length > 0 && !until(compressible)) {
361
+ let endIndex = Math.max(1, Math.floor(compressible.length / 10));
362
+ if (compressible[endIndex]?.role === "tool") {
363
+ const range = findToolGroupRange(compressible, endIndex);
364
+ if (range) endIndex = range[1];
365
+ else while (compressible[endIndex]?.role === "tool") endIndex++;
366
+ }
367
+ count += compressible.splice(0, endIndex).length;
402
368
  }
403
- const deletedCount = messages.length - startIndex;
404
- messages.splice(startIndex, deletedCount);
405
- logger(`硬删除了${deletedCount}条较早的消息`);
369
+ logger(`丢弃了${count}条旧消息`);
370
+ return count;
406
371
  };
407
372
  /**
408
373
  * 自动优化上下文,类似AI Coding Agent的/compact命令
409
374
  */
410
375
  const compact = Object.assign(async (messages, model, options) => {
411
- const { usage, keepCount = 10, ratioToCompactToolResult = .5, replacerOfToolResultContent, ratioToCompactMedia = .6, replacerOfMediaContent, ratioToClearSoftDeletedMessages = .7, softDeletedMessageMark = "[已简化]", ratioToSummarize = .8, summarizeOptions } = options ?? {};
376
+ const { usage, keepCount = 10, compactedMessageMark = "[已简化]", ratioToCompactToolResults = .6, replacerOfToolResultContent, ratioToCompactMedia = .7, replacerOfMediaContent, ratioToSummarize = .8, summarizeOptions } = options ?? {};
412
377
  const context = model?.context ?? 131072;
413
378
  const tokens = usage?.total_tokens ?? estimateTokens(messages);
414
379
  const result = {
415
- hasCompactedToolResult: false,
380
+ hasCompactedToolResults: false,
416
381
  hasCompactedMedia: false,
417
- hasClearedSoftDeletedMessages: false,
418
382
  hasSummarized: false,
419
- hasDeletedOldMessages: false
383
+ hasDiscardMessages: false
420
384
  };
421
- let endIndex = Math.max(0, messages.length - keepCount);
385
+ const startIndex = 1;
386
+ let endIndex = Math.max(startIndex, messages.length - keepCount);
422
387
  if (messages[endIndex]?.role === "tool") {
423
388
  const range = findToolGroupRange(messages, endIndex);
424
389
  if (range) endIndex = range[1];
425
390
  else while (messages[endIndex]?.role === "tool") endIndex++;
426
391
  }
427
- const compressible = messages.slice(0, endIndex);
428
- const reserved = messages.slice(endIndex);
429
- const softDeleted = /* @__PURE__ */ new Set();
430
- if (tokens > context * ratioToCompactToolResult) {
431
- const softDeletedMessages = await softDeleteToolResults(compressible, {
432
- replacer: replacerOfToolResultContent,
433
- mark: softDeletedMessageMark,
434
- model
435
- });
436
- for (const message of softDeletedMessages) softDeleted.add(message);
437
- result.hasCompactedToolResult = true;
438
- }
439
- if (tokens > context * ratioToCompactMedia) {
440
- const softDeletedMessages = await softDeleteOldMediaMessages(compressible, {
441
- replacer: replacerOfMediaContent,
442
- mark: softDeletedMessageMark,
443
- model
444
- });
445
- for (const message of softDeletedMessages) softDeleted.add(message);
446
- result.hasCompactedMedia = true;
447
- }
448
- if (tokens > context * ratioToClearSoftDeletedMessages) {
449
- hardDeleteSoftMessages(compressible, softDeleted);
450
- result.hasClearedSoftDeletedMessages = true;
451
- messages.splice(0, messages.length, ...compressible, ...reserved);
452
- }
392
+ endIndex = Math.max(startIndex, endIndex);
393
+ const reservedStart = messages.slice(0, startIndex);
394
+ const compressible = messages.slice(startIndex, endIndex);
395
+ const reservedEnd = messages.slice(endIndex);
396
+ if (tokens > context * ratioToCompactToolResults) result.hasCompactedToolResults = await compactToolResults(compressible, {
397
+ replacer: replacerOfToolResultContent,
398
+ mark: compactedMessageMark,
399
+ model
400
+ }) > 0;
401
+ if (tokens > context * ratioToCompactMedia) result.hasCompactedMedia = await compactMediaMessages(compressible, {
402
+ replacer: replacerOfMediaContent,
403
+ model
404
+ }) > 0;
453
405
  if (tokens > context * ratioToSummarize) {
454
406
  const { systemPrompt = "你现在的任务是总结历史消息" } = summarizeOptions ?? {};
455
- const [error] = await to(summarizeMessages(compressible, {
407
+ const [error, count] = await to(summarizeMessages(compressible, {
456
408
  model,
457
409
  systemPrompt
458
410
  }));
459
- result.hasSummarized = true;
460
- if (error) {
461
- logger(`总结失败(${error.message}),改用硬删除兜底`);
462
- hardDeleteOldMessages(compressible);
463
- result.hasDeletedOldMessages = true;
464
- }
465
- messages.splice(0, messages.length, ...compressible, ...reserved);
411
+ if (!error) result.hasSummarized = count > 0;
412
+ else result.hasDiscardMessages = discardMessagesUntil(compressible, (compressible) => {
413
+ if (compressible.length === 0) return true;
414
+ const tempMessages = [
415
+ ...reservedStart,
416
+ ...compressible,
417
+ ...reservedEnd
418
+ ];
419
+ return estimateTokens(tempMessages) < context * ratioToSummarize;
420
+ }) > 0;
421
+ messages.splice(0, Infinity, ...reservedStart, ...compressible, ...reservedEnd);
466
422
  }
467
423
  return result;
468
424
  }, {
425
+ compactToolResults,
426
+ compactMediaMessages,
469
427
  summarizeMessages,
470
- softDeleteToolResults,
471
- softDeleteOldMediaMessages,
472
- hardDeleteSoftMessages,
473
- hardDeleteOldMessages
428
+ discardMessagesUntil
474
429
  });
475
430
  //#endregion
476
431
  export { defineTool as a, defineModel as i, get_weather_default as n, runAgent as o, get_time_default as r, compact as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickyzj2023/ai",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "我的“pi”,参考了pi-from-scratch",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -32,6 +32,7 @@
32
32
  "typescript": "^7.0.2"
33
33
  },
34
34
  "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.30.0",
35
36
  "@nickyzj2023/utils": "link:../utils"
36
37
  },
37
38
  "scripts": {