@zhin.js/core 1.0.26 → 1.0.28

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/lib/ai/agent.d.ts.map +1 -1
  3. package/lib/ai/agent.js +4 -0
  4. package/lib/ai/agent.js.map +1 -1
  5. package/lib/ai/bootstrap.d.ts +82 -0
  6. package/lib/ai/bootstrap.d.ts.map +1 -0
  7. package/lib/ai/bootstrap.js +199 -0
  8. package/lib/ai/bootstrap.js.map +1 -0
  9. package/lib/ai/builtin-tools.d.ts +36 -0
  10. package/lib/ai/builtin-tools.d.ts.map +1 -0
  11. package/lib/ai/builtin-tools.js +509 -0
  12. package/lib/ai/builtin-tools.js.map +1 -0
  13. package/lib/ai/compaction.d.ts +132 -0
  14. package/lib/ai/compaction.d.ts.map +1 -0
  15. package/lib/ai/compaction.js +370 -0
  16. package/lib/ai/compaction.js.map +1 -0
  17. package/lib/ai/hooks.d.ts +143 -0
  18. package/lib/ai/hooks.d.ts.map +1 -0
  19. package/lib/ai/hooks.js +108 -0
  20. package/lib/ai/hooks.js.map +1 -0
  21. package/lib/ai/index.d.ts +6 -0
  22. package/lib/ai/index.d.ts.map +1 -1
  23. package/lib/ai/index.js +6 -0
  24. package/lib/ai/index.js.map +1 -1
  25. package/lib/ai/init.d.ts.map +1 -1
  26. package/lib/ai/init.js +120 -3
  27. package/lib/ai/init.js.map +1 -1
  28. package/lib/ai/types.d.ts +2 -0
  29. package/lib/ai/types.d.ts.map +1 -1
  30. package/lib/ai/zhin-agent.d.ts +28 -1
  31. package/lib/ai/zhin-agent.d.ts.map +1 -1
  32. package/lib/ai/zhin-agent.js +196 -57
  33. package/lib/ai/zhin-agent.js.map +1 -1
  34. package/lib/built/config.d.ts +10 -0
  35. package/lib/built/config.d.ts.map +1 -1
  36. package/lib/built/config.js +54 -3
  37. package/lib/built/config.js.map +1 -1
  38. package/lib/built/tool.d.ts +6 -0
  39. package/lib/built/tool.d.ts.map +1 -1
  40. package/lib/built/tool.js +12 -0
  41. package/lib/built/tool.js.map +1 -1
  42. package/lib/cron.d.ts +0 -27
  43. package/lib/cron.d.ts.map +1 -1
  44. package/lib/cron.js +28 -27
  45. package/lib/cron.js.map +1 -1
  46. package/lib/types-generator.js +1 -1
  47. package/lib/types-generator.js.map +1 -1
  48. package/lib/types.d.ts +7 -0
  49. package/lib/types.d.ts.map +1 -1
  50. package/package.json +6 -6
  51. package/src/ai/agent.ts +6 -0
  52. package/src/ai/bootstrap.ts +263 -0
  53. package/src/ai/builtin-tools.ts +569 -0
  54. package/src/ai/compaction.ts +529 -0
  55. package/src/ai/hooks.ts +223 -0
  56. package/src/ai/index.ts +58 -0
  57. package/src/ai/init.ts +127 -3
  58. package/src/ai/types.ts +2 -0
  59. package/src/ai/zhin-agent.ts +226 -54
  60. package/src/built/config.ts +53 -3
  61. package/src/built/tool.ts +12 -0
  62. package/src/cron.ts +28 -27
  63. package/src/types-generator.ts +1 -1
  64. package/src/types.ts +8 -0
  65. package/tests/adapter.test.ts +1 -1
  66. package/tests/config.test.ts +2 -2
  67. package/test/minimal-bot.ts +0 -31
  68. package/test/stress-test.ts +0 -123
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Session Compaction — 会话压缩
3
+ *
4
+ * 借鉴 OpenClaw 的 compaction 设计,当对话历史超过上下文窗口限制时,
5
+ * 通过 LLM 生成摘要来压缩早期消息,保留最近的消息完整性。
6
+ *
7
+ * 核心理念:
8
+ * 1. Token 估算:用 chars/4 粗略估算 token 数
9
+ * 2. 分块(Chunk):按 token 预算将消息分成多块
10
+ * 3. 渐进式摘要:对每块分别生成摘要,再合并
11
+ * 4. 安全裕度:估算偏低时留 20% buffer
12
+ * 5. 降级策略:摘要失败时使用更粗糙的摘要
13
+ */
14
+
15
+ import { Logger } from '@zhin.js/logger';
16
+ import type { AIProvider, ChatMessage } from './types.js';
17
+
18
+ const logger = new Logger(null, 'Compaction');
19
+
20
+ // ============================================================================
21
+ // 常量
22
+ // ============================================================================
23
+
24
+ /** 默认上下文窗口大小(tokens) */
25
+ export const DEFAULT_CONTEXT_TOKENS = 128_000;
26
+
27
+ /** 上下文窗口最低阈值 */
28
+ export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
29
+
30
+ /** 上下文窗口警告阈值 */
31
+ export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
32
+
33
+ /** 基础分块比例 — 每块最多占上下文的 40% */
34
+ export const BASE_CHUNK_RATIO = 0.4;
35
+
36
+ /** 最小分块比例 */
37
+ export const MIN_CHUNK_RATIO = 0.15;
38
+
39
+ /** 安全裕度系数 — 20% buffer 补偿 estimateTokens 的低估 */
40
+ export const SAFETY_MARGIN = 1.2;
41
+
42
+ /** 默认摘要回退文本 */
43
+ const DEFAULT_SUMMARY_FALLBACK = '无历史记录。';
44
+
45
+ /** 摘要合并指令 */
46
+ const MERGE_SUMMARIES_INSTRUCTIONS =
47
+ '将下面这些部分摘要合并为一份连贯的完整摘要。保留关键决定、TODO、未解决的问题和所有约束。';
48
+
49
+ // ============================================================================
50
+ // Token 估算
51
+ // ============================================================================
52
+
53
+ /**
54
+ * 估算单条消息的 token 数
55
+ * 使用 chars/4 粗略估算(对中文偏高,对英文偏低,但足够用于分块)
56
+ */
57
+ export function estimateTokens(message: ChatMessage): number {
58
+ const content = typeof message.content === 'string'
59
+ ? message.content
60
+ : JSON.stringify(message.content);
61
+ // 消息角色和格式开销约 4 tokens
62
+ return Math.ceil(content.length / 4) + 4;
63
+ }
64
+
65
+ /**
66
+ * 估算消息列表的总 token 数
67
+ */
68
+ export function estimateMessagesTokens(messages: ChatMessage[]): number {
69
+ return messages.reduce((sum, m) => sum + estimateTokens(m), 0);
70
+ }
71
+
72
+ // ============================================================================
73
+ // 分块
74
+ // ============================================================================
75
+
76
+ /**
77
+ * 按 token 份额拆分消息
78
+ */
79
+ export function splitMessagesByTokenShare(
80
+ messages: ChatMessage[],
81
+ parts = 2,
82
+ ): ChatMessage[][] {
83
+ if (messages.length === 0) return [];
84
+ const normalizedParts = Math.min(Math.max(1, Math.floor(parts)), messages.length);
85
+ if (normalizedParts <= 1) return [messages];
86
+
87
+ const totalTokens = estimateMessagesTokens(messages);
88
+ const targetTokens = totalTokens / normalizedParts;
89
+ const chunks: ChatMessage[][] = [];
90
+ let current: ChatMessage[] = [];
91
+ let currentTokens = 0;
92
+
93
+ for (const message of messages) {
94
+ const msgTokens = estimateTokens(message);
95
+ if (
96
+ chunks.length < normalizedParts - 1 &&
97
+ current.length > 0 &&
98
+ currentTokens + msgTokens > targetTokens
99
+ ) {
100
+ chunks.push(current);
101
+ current = [];
102
+ currentTokens = 0;
103
+ }
104
+ current.push(message);
105
+ currentTokens += msgTokens;
106
+ }
107
+
108
+ if (current.length > 0) chunks.push(current);
109
+ return chunks;
110
+ }
111
+
112
+ /**
113
+ * 按最大 token 数拆分消息
114
+ */
115
+ export function chunkMessagesByMaxTokens(
116
+ messages: ChatMessage[],
117
+ maxTokens: number,
118
+ ): ChatMessage[][] {
119
+ if (messages.length === 0) return [];
120
+
121
+ const effectiveMax = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN));
122
+ const chunks: ChatMessage[][] = [];
123
+ let currentChunk: ChatMessage[] = [];
124
+ let currentTokens = 0;
125
+
126
+ for (const message of messages) {
127
+ const msgTokens = estimateTokens(message);
128
+ if (currentChunk.length > 0 && currentTokens + msgTokens > effectiveMax) {
129
+ chunks.push(currentChunk);
130
+ currentChunk = [];
131
+ currentTokens = 0;
132
+ }
133
+ currentChunk.push(message);
134
+ currentTokens += msgTokens;
135
+
136
+ // 超大单条消息也要拆
137
+ if (msgTokens > effectiveMax) {
138
+ chunks.push(currentChunk);
139
+ currentChunk = [];
140
+ currentTokens = 0;
141
+ }
142
+ }
143
+
144
+ if (currentChunk.length > 0) chunks.push(currentChunk);
145
+ return chunks;
146
+ }
147
+
148
+ /**
149
+ * 计算自适应分块比例
150
+ * 消息越大,分块比例越小,避免超出模型限制
151
+ */
152
+ export function computeAdaptiveChunkRatio(
153
+ messages: ChatMessage[],
154
+ contextWindow: number,
155
+ ): number {
156
+ if (messages.length === 0) return BASE_CHUNK_RATIO;
157
+
158
+ const totalTokens = estimateMessagesTokens(messages);
159
+ const avgTokens = totalTokens / messages.length;
160
+ const safeAvgTokens = avgTokens * SAFETY_MARGIN;
161
+ const avgRatio = safeAvgTokens / contextWindow;
162
+
163
+ if (avgRatio > 0.1) {
164
+ const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
165
+ return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
166
+ }
167
+
168
+ return BASE_CHUNK_RATIO;
169
+ }
170
+
171
+ // ============================================================================
172
+ // 上下文窗口保护
173
+ // ============================================================================
174
+
175
+ export type ContextWindowSource = 'config' | 'model' | 'default';
176
+
177
+ export interface ContextWindowInfo {
178
+ tokens: number;
179
+ source: ContextWindowSource;
180
+ }
181
+
182
+ export interface ContextWindowGuardResult extends ContextWindowInfo {
183
+ shouldWarn: boolean;
184
+ shouldBlock: boolean;
185
+ }
186
+
187
+ /**
188
+ * 解析上下文窗口大小
189
+ */
190
+ export function resolveContextWindowTokens(
191
+ configTokens?: number,
192
+ modelContextWindow?: number,
193
+ ): ContextWindowInfo {
194
+ if (configTokens && configTokens > 0) {
195
+ return { tokens: Math.floor(configTokens), source: 'config' };
196
+ }
197
+ if (modelContextWindow && modelContextWindow > 0) {
198
+ return { tokens: Math.floor(modelContextWindow), source: 'model' };
199
+ }
200
+ return { tokens: DEFAULT_CONTEXT_TOKENS, source: 'default' };
201
+ }
202
+
203
+ /**
204
+ * 评估上下文窗口安全性
205
+ */
206
+ export function evaluateContextWindowGuard(
207
+ info: ContextWindowInfo,
208
+ ): ContextWindowGuardResult {
209
+ const tokens = Math.max(0, Math.floor(info.tokens));
210
+ return {
211
+ ...info,
212
+ tokens,
213
+ shouldWarn: tokens > 0 && tokens < CONTEXT_WINDOW_WARN_BELOW_TOKENS,
214
+ shouldBlock: tokens > 0 && tokens < CONTEXT_WINDOW_HARD_MIN_TOKENS,
215
+ };
216
+ }
217
+
218
+ // ============================================================================
219
+ // 摘要生成
220
+ // ============================================================================
221
+
222
+ /**
223
+ * 通过 LLM 对消息进行摘要
224
+ */
225
+ async function generateSummary(
226
+ provider: AIProvider,
227
+ messages: ChatMessage[],
228
+ maxChunkTokens: number,
229
+ previousSummary?: string,
230
+ customInstructions?: string,
231
+ ): Promise<string> {
232
+ const conversation = messages.map(m => {
233
+ const role = m.role === 'user' ? '用户' : m.role === 'assistant' ? '助手' : '系统';
234
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
235
+ return `[${role}] ${content}`;
236
+ }).join('\n');
237
+
238
+ let systemPrompt = `你是一个对话摘要助手。请将以下对话压缩为简洁的摘要,保留:
239
+ - 关键决定和结论
240
+ - 未完成的 TODO 和待解决的问题
241
+ - 重要的用户偏好和约束
242
+ - 讨论的核心主题
243
+
244
+ 摘要应该简洁但信息量大,便于后续对话能快速了解上下文。`;
245
+
246
+ if (customInstructions) {
247
+ systemPrompt += `\n\n额外要求:${customInstructions}`;
248
+ }
249
+
250
+ let userContent = '';
251
+ if (previousSummary) {
252
+ userContent += `之前的摘要:\n${previousSummary}\n\n`;
253
+ }
254
+ userContent += `新的对话内容:\n${conversation}\n\n请生成更新后的完整摘要。`;
255
+
256
+ try {
257
+ const response = await provider.chat({
258
+ model: provider.models[0],
259
+ messages: [
260
+ { role: 'system', content: systemPrompt },
261
+ { role: 'user', content: userContent },
262
+ ],
263
+ });
264
+ const result = response.choices?.[0]?.message?.content;
265
+ return typeof result === 'string' ? result : DEFAULT_SUMMARY_FALLBACK;
266
+ } catch (e: any) {
267
+ logger.warn(`摘要生成失败: ${e.message}`);
268
+ return DEFAULT_SUMMARY_FALLBACK;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 对消息分块摘要,支持降级
274
+ */
275
+ async function summarizeChunks(params: {
276
+ provider: AIProvider;
277
+ messages: ChatMessage[];
278
+ maxChunkTokens: number;
279
+ previousSummary?: string;
280
+ customInstructions?: string;
281
+ }): Promise<string> {
282
+ if (params.messages.length === 0) {
283
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
284
+ }
285
+
286
+ const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
287
+ let summary = params.previousSummary;
288
+
289
+ for (const chunk of chunks) {
290
+ summary = await generateSummary(
291
+ params.provider,
292
+ chunk,
293
+ params.maxChunkTokens,
294
+ summary,
295
+ params.customInstructions,
296
+ );
297
+ }
298
+
299
+ return summary ?? DEFAULT_SUMMARY_FALLBACK;
300
+ }
301
+
302
+ /**
303
+ * 带降级的摘要生成
304
+ *
305
+ * 完整摘要失败时,尝试排除超大消息再摘要
306
+ */
307
+ export async function summarizeWithFallback(params: {
308
+ provider: AIProvider;
309
+ messages: ChatMessage[];
310
+ maxChunkTokens: number;
311
+ contextWindow: number;
312
+ previousSummary?: string;
313
+ customInstructions?: string;
314
+ }): Promise<string> {
315
+ if (params.messages.length === 0) {
316
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
317
+ }
318
+
319
+ // 尝试完整摘要
320
+ try {
321
+ return await summarizeChunks(params);
322
+ } catch (fullError: any) {
323
+ logger.warn(`完整摘要失败,尝试部分摘要: ${fullError.message}`);
324
+ }
325
+
326
+ // 降级:排除超大消息
327
+ const smallMessages: ChatMessage[] = [];
328
+ const oversizedNotes: string[] = [];
329
+
330
+ for (const msg of params.messages) {
331
+ const tokens = estimateTokens(msg) * SAFETY_MARGIN;
332
+ if (tokens > params.contextWindow * 0.5) {
333
+ oversizedNotes.push(
334
+ `[大型 ${msg.role} 消息 (~${Math.round(tokens / 1000)}K tokens) 已从摘要中省略]`,
335
+ );
336
+ } else {
337
+ smallMessages.push(msg);
338
+ }
339
+ }
340
+
341
+ if (smallMessages.length > 0) {
342
+ try {
343
+ const partial = await summarizeChunks({ ...params, messages: smallMessages });
344
+ const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join('\n')}` : '';
345
+ return partial + notes;
346
+ } catch (partialError: any) {
347
+ logger.warn(`部分摘要也失败: ${partialError.message}`);
348
+ }
349
+ }
350
+
351
+ // 最终降级
352
+ return `上下文包含 ${params.messages.length} 条消息(${oversizedNotes.length} 条超大)。由于大小限制,摘要不可用。`;
353
+ }
354
+
355
+ /**
356
+ * 分阶段摘要 — 先将消息拆为多段分别摘要,再合并
357
+ */
358
+ export async function summarizeInStages(params: {
359
+ provider: AIProvider;
360
+ messages: ChatMessage[];
361
+ maxChunkTokens: number;
362
+ contextWindow: number;
363
+ previousSummary?: string;
364
+ customInstructions?: string;
365
+ parts?: number;
366
+ }): Promise<string> {
367
+ const { messages } = params;
368
+ if (messages.length === 0) {
369
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
370
+ }
371
+
372
+ const parts = Math.min(Math.max(1, params.parts ?? 2), messages.length);
373
+ const totalTokens = estimateMessagesTokens(messages);
374
+
375
+ // 消息太少,或 token 没超限,直接走单次摘要
376
+ if (parts <= 1 || messages.length < 4 || totalTokens <= params.maxChunkTokens) {
377
+ return summarizeWithFallback(params);
378
+ }
379
+
380
+ const splits = splitMessagesByTokenShare(messages, parts).filter(c => c.length > 0);
381
+ if (splits.length <= 1) return summarizeWithFallback(params);
382
+
383
+ // 每段分别摘要
384
+ const partialSummaries: string[] = [];
385
+ for (const chunk of splits) {
386
+ partialSummaries.push(
387
+ await summarizeWithFallback({
388
+ ...params,
389
+ messages: chunk,
390
+ previousSummary: undefined,
391
+ }),
392
+ );
393
+ }
394
+
395
+ if (partialSummaries.length === 1) return partialSummaries[0];
396
+
397
+ // 合并多段摘要
398
+ const summaryMessages: ChatMessage[] = partialSummaries.map(s => ({
399
+ role: 'user' as const,
400
+ content: s,
401
+ }));
402
+
403
+ const mergeInstructions = params.customInstructions
404
+ ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\n额外要求:${params.customInstructions}`
405
+ : MERGE_SUMMARIES_INSTRUCTIONS;
406
+
407
+ return summarizeWithFallback({
408
+ ...params,
409
+ messages: summaryMessages,
410
+ customInstructions: mergeInstructions,
411
+ });
412
+ }
413
+
414
+ // ============================================================================
415
+ // 历史剪裁
416
+ // ============================================================================
417
+
418
+ export interface PruneResult {
419
+ /** 保留的消息 */
420
+ messages: ChatMessage[];
421
+ /** 被丢弃的消息 */
422
+ droppedMessages: ChatMessage[];
423
+ /** 丢弃的块数 */
424
+ droppedChunks: number;
425
+ /** 丢弃的消息数 */
426
+ droppedCount: number;
427
+ /** 丢弃的 token 数 */
428
+ droppedTokens: number;
429
+ /** 保留的 token 数 */
430
+ keptTokens: number;
431
+ /** 预算 token 数 */
432
+ budgetTokens: number;
433
+ }
434
+
435
+ /**
436
+ * 剪裁历史消息,使其不超过上下文预算
437
+ *
438
+ * 策略:从最旧的消息开始丢弃,直到 token 总量在预算内
439
+ */
440
+ export function pruneHistoryForContext(params: {
441
+ messages: ChatMessage[];
442
+ maxContextTokens: number;
443
+ maxHistoryShare?: number;
444
+ parts?: number;
445
+ }): PruneResult {
446
+ const maxHistoryShare = params.maxHistoryShare ?? 0.5;
447
+ const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
448
+ let keptMessages = params.messages;
449
+ const allDropped: ChatMessage[] = [];
450
+ let droppedChunks = 0;
451
+ let droppedCount = 0;
452
+ let droppedTokens = 0;
453
+
454
+ const parts = Math.min(Math.max(1, params.parts ?? 2), keptMessages.length);
455
+
456
+ while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
457
+ const chunks = splitMessagesByTokenShare(keptMessages, parts);
458
+ if (chunks.length <= 1) break;
459
+
460
+ const [dropped, ...rest] = chunks;
461
+ keptMessages = rest.flat();
462
+ droppedChunks += 1;
463
+ droppedCount += dropped.length;
464
+ droppedTokens += estimateMessagesTokens(dropped);
465
+ allDropped.push(...dropped);
466
+ }
467
+
468
+ return {
469
+ messages: keptMessages,
470
+ droppedMessages: allDropped,
471
+ droppedChunks,
472
+ droppedCount,
473
+ droppedTokens,
474
+ keptTokens: estimateMessagesTokens(keptMessages),
475
+ budgetTokens,
476
+ };
477
+ }
478
+
479
+ // ============================================================================
480
+ // /compact 命令 — 主动压缩当前会话
481
+ // ============================================================================
482
+
483
+ /**
484
+ * 对给定消息列表执行一次就地压缩,返回压缩后的摘要和保留消息
485
+ */
486
+ export async function compactSession(params: {
487
+ provider: AIProvider;
488
+ messages: ChatMessage[];
489
+ contextWindow?: number;
490
+ keepRecentCount?: number;
491
+ }): Promise<{
492
+ summary: string;
493
+ keptMessages: ChatMessage[];
494
+ compactedCount: number;
495
+ savedTokens: number;
496
+ }> {
497
+ const contextWindow = params.contextWindow ?? DEFAULT_CONTEXT_TOKENS;
498
+ const keepRecentCount = params.keepRecentCount ?? 6;
499
+ const messages = params.messages;
500
+
501
+ if (messages.length <= keepRecentCount) {
502
+ return {
503
+ summary: '',
504
+ keptMessages: messages,
505
+ compactedCount: 0,
506
+ savedTokens: 0,
507
+ };
508
+ }
509
+
510
+ // 保留最近 N 条,其余摘要
511
+ const toCompact = messages.slice(0, messages.length - keepRecentCount);
512
+ const toKeep = messages.slice(messages.length - keepRecentCount);
513
+ const beforeTokens = estimateMessagesTokens(toCompact);
514
+
515
+ const maxChunkTokens = Math.floor(contextWindow * computeAdaptiveChunkRatio(toCompact, contextWindow));
516
+ const summary = await summarizeInStages({
517
+ provider: params.provider,
518
+ messages: toCompact,
519
+ maxChunkTokens,
520
+ contextWindow,
521
+ });
522
+
523
+ return {
524
+ summary,
525
+ keptMessages: toKeep,
526
+ compactedCount: toCompact.length,
527
+ savedTokens: beforeTokens - estimateTokens({ role: 'system', content: summary }),
528
+ };
529
+ }