@lwmxiaobei/xbcode 1.0.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/LICENSE +21 -0
- package/README.md +631 -0
- package/README.zh-CN.md +542 -0
- package/dist/agent.js +1450 -0
- package/dist/busy-status.js +29 -0
- package/dist/clipboard-image.js +97 -0
- package/dist/commands.js +109 -0
- package/dist/compact.js +262 -0
- package/dist/config.js +516 -0
- package/dist/error-log.js +80 -0
- package/dist/http.js +89 -0
- package/dist/idle-watchdog.js +88 -0
- package/dist/index.js +2031 -0
- package/dist/input-submit.js +41 -0
- package/dist/mcp/client.js +466 -0
- package/dist/mcp/manager.js +275 -0
- package/dist/mcp/runtime.js +420 -0
- package/dist/mcp/types.js +12 -0
- package/dist/message-bus.js +180 -0
- package/dist/oauth/openai.js +326 -0
- package/dist/prompt.js +156 -0
- package/dist/session-store.js +186 -0
- package/dist/skills/frontmatter.js +85 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.js +88 -0
- package/dist/skills/render.js +35 -0
- package/dist/skills/types.js +1 -0
- package/dist/subagents.js +64 -0
- package/dist/supervisor.js +58 -0
- package/dist/task-manager.js +280 -0
- package/dist/team-types.js +1 -0
- package/dist/teammate-manager.js +266 -0
- package/dist/tools.js +1068 -0
- package/dist/trust-store.js +42 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +226 -0
- package/dist/utils.js +21 -0
- package/package.json +67 -0
- package/scripts/postinstall.mjs +30 -0
- package/skills/code-review/SKILL.md +22 -0
- package/skills/pdf/SKILL.md +18 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format the footer line shown while the agent is busy.
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独抽:
|
|
5
|
+
* - UI 之前只有一行静态 "Working... Esc stops this turn.",无法区分"模型正在
|
|
6
|
+
* reasoning"、"网关把流缓冲住了"、"连接已死"这几种情况。
|
|
7
|
+
* - 把"是否显示 idle 字段"以及"秒数取整"的规则抽成纯函数,UI 只负责按 1s tick
|
|
8
|
+
* 触发重渲染,逻辑可以脱离 React 单独验证。
|
|
9
|
+
*
|
|
10
|
+
* 设计取舍:
|
|
11
|
+
* - idle < 5s 不显示,避免抖动:流式 chunk 之间通常有几百毫秒到 1~2 秒的间隔,
|
|
12
|
+
* 持续显示 "idle 1s" 反而干扰阅读。
|
|
13
|
+
* - 一旦 idle ≥ 5s 就开始显示,让用户在 reasoning 长尾或网关 stall 时能立刻感知
|
|
14
|
+
* "还在等 / 等了多久"。
|
|
15
|
+
*/
|
|
16
|
+
const IDLE_DISPLAY_THRESHOLD_SECONDS = 5;
|
|
17
|
+
function clampToNonNegativeSeconds(value) {
|
|
18
|
+
if (!Number.isFinite(value) || value < 0)
|
|
19
|
+
return 0;
|
|
20
|
+
return Math.floor(value);
|
|
21
|
+
}
|
|
22
|
+
export function formatBusyStatus(elapsedSeconds, idleSeconds) {
|
|
23
|
+
const elapsed = clampToNonNegativeSeconds(elapsedSeconds);
|
|
24
|
+
const idle = clampToNonNegativeSeconds(idleSeconds);
|
|
25
|
+
if (idle >= IDLE_DISPLAY_THRESHOLD_SECONDS) {
|
|
26
|
+
return `Working ${elapsed}s · idle ${idle}s · Esc to stop`;
|
|
27
|
+
}
|
|
28
|
+
return `Working ${elapsed}s · Esc to stop`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const TMP_DIR = path.join(os.tmpdir(), "xbcode-images");
|
|
8
|
+
function ensureTmpDir() {
|
|
9
|
+
fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
function guessMimeType(filePath) {
|
|
12
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
13
|
+
switch (ext) {
|
|
14
|
+
case ".jpg":
|
|
15
|
+
case ".jpeg":
|
|
16
|
+
return "image/jpeg";
|
|
17
|
+
case ".webp":
|
|
18
|
+
return "image/webp";
|
|
19
|
+
case ".gif":
|
|
20
|
+
return "image/gif";
|
|
21
|
+
default:
|
|
22
|
+
return "image/png";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function isSupportedImagePath(filePath) {
|
|
26
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
27
|
+
return [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext);
|
|
28
|
+
}
|
|
29
|
+
export function readImageAttachmentFromPath(filePath) {
|
|
30
|
+
const absolutePath = path.resolve(filePath);
|
|
31
|
+
if (!fs.existsSync(absolutePath)) {
|
|
32
|
+
throw new Error(`Image file not found: ${filePath}`);
|
|
33
|
+
}
|
|
34
|
+
if (!isSupportedImagePath(absolutePath)) {
|
|
35
|
+
throw new Error(`Unsupported image file: ${filePath}`);
|
|
36
|
+
}
|
|
37
|
+
const base64Data = fs.readFileSync(absolutePath).toString("base64");
|
|
38
|
+
return {
|
|
39
|
+
path: absolutePath,
|
|
40
|
+
mimeType: guessMimeType(absolutePath),
|
|
41
|
+
base64Data,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function extractImagePathsFromText(input) {
|
|
45
|
+
const trimmed = input.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return { attachments: [], remainingText: input };
|
|
48
|
+
}
|
|
49
|
+
const candidates = trimmed
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.map((part) => part.replace(/^['\"]|['\"]$/g, "").replace(/\\ /g, " "));
|
|
52
|
+
const attachments = [];
|
|
53
|
+
const consumed = new Set();
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (!candidate.startsWith("/") && !candidate.startsWith("./") && !candidate.startsWith("../")) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const resolved = path.resolve(candidate);
|
|
59
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile() || !isSupportedImagePath(resolved)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
attachments.push(readImageAttachmentFromPath(resolved));
|
|
63
|
+
consumed.add(candidate);
|
|
64
|
+
consumed.add(candidate.replace(/ /g, "\\ "));
|
|
65
|
+
}
|
|
66
|
+
if (attachments.length === 0) {
|
|
67
|
+
return { attachments, remainingText: input };
|
|
68
|
+
}
|
|
69
|
+
const remainingWords = trimmed
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter((part) => !consumed.has(part.replace(/^['\"]|['\"]$/g, "")));
|
|
72
|
+
return {
|
|
73
|
+
attachments,
|
|
74
|
+
remainingText: remainingWords.join(" ").trim(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function importClipboardImageMacos() {
|
|
78
|
+
if (process.platform !== "darwin") {
|
|
79
|
+
throw new Error("Clipboard image import is only supported on macOS.");
|
|
80
|
+
}
|
|
81
|
+
ensureTmpDir();
|
|
82
|
+
const targetPath = path.join(TMP_DIR, `clipboard-${Date.now()}.png`);
|
|
83
|
+
try {
|
|
84
|
+
await execFileAsync("pngpaste", [targetPath]);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
if (message.includes("ENOENT")) {
|
|
89
|
+
throw new Error("pngpaste is not installed. Install it with: brew install pngpaste");
|
|
90
|
+
}
|
|
91
|
+
throw new Error("Clipboard does not contain an image.");
|
|
92
|
+
}
|
|
93
|
+
if (!fs.existsSync(targetPath) || fs.statSync(targetPath).size === 0) {
|
|
94
|
+
throw new Error("Clipboard does not contain an image.");
|
|
95
|
+
}
|
|
96
|
+
return readImageAttachmentFromPath(targetPath);
|
|
97
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize built-in slash commands into the format consumed by the submit
|
|
3
|
+
* handler.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* - The CLI needs one authoritative decision point for "is this a built-in
|
|
7
|
+
* command or should it fall through to skills/user input?".
|
|
8
|
+
* - Some commands intentionally keep their trailing arguments because later
|
|
9
|
+
* dispatch logic needs the provider/model name, for example `/login openai`.
|
|
10
|
+
* - Extracting this logic into a small module makes regression testing
|
|
11
|
+
* possible without importing the full TUI entrypoint.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeCommand(inputValue) {
|
|
14
|
+
const trimmed = inputValue.trim().toLowerCase();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Built-in commands must be explicitly prefixed with "/".
|
|
20
|
+
*
|
|
21
|
+
* Why this matters:
|
|
22
|
+
* - Normal chat text can legitimately start with words like "provider",
|
|
23
|
+
* "login", or "logout".
|
|
24
|
+
* - Treating bare text as a command makes Chinese/English mixed input easy to
|
|
25
|
+
* misclassify because there may be no whitespace boundary after the first
|
|
26
|
+
* English token.
|
|
27
|
+
* - The submit layer already handles bare "q"/"exit" separately, so command
|
|
28
|
+
* normalization should stay strict here.
|
|
29
|
+
*/
|
|
30
|
+
if (!trimmed.startsWith("/")) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const withoutSlash = trimmed.slice(1);
|
|
34
|
+
const parts = withoutSlash.split(/\s+/);
|
|
35
|
+
const cmd = parts[0];
|
|
36
|
+
switch (cmd) {
|
|
37
|
+
case "help":
|
|
38
|
+
case "status":
|
|
39
|
+
case "usage":
|
|
40
|
+
case "mcp":
|
|
41
|
+
case "team":
|
|
42
|
+
case "inbox":
|
|
43
|
+
case "compact":
|
|
44
|
+
case "new":
|
|
45
|
+
case "resume":
|
|
46
|
+
case "exit":
|
|
47
|
+
return withoutSlash;
|
|
48
|
+
case "provider":
|
|
49
|
+
case "model":
|
|
50
|
+
case "login":
|
|
51
|
+
case "logout":
|
|
52
|
+
return withoutSlash;
|
|
53
|
+
case "quit":
|
|
54
|
+
return "exit";
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a given submission would start an agent turn and therefore
|
|
61
|
+
* requires the user to have explicitly selected a model first.
|
|
62
|
+
*
|
|
63
|
+
* Why this exists:
|
|
64
|
+
* - The CLI can intentionally enter the main UI without a chosen model when
|
|
65
|
+
* the picker is dismissed, but plain chat input must still be blocked.
|
|
66
|
+
* - Built-in commands such as `/help`, `/model`, or `/resume` should continue
|
|
67
|
+
* to work because they do not directly open a model-backed turn.
|
|
68
|
+
* - Skill slash commands are special: they are slash-prefixed, but they do run
|
|
69
|
+
* an agent turn, so the caller passes that knowledge explicitly.
|
|
70
|
+
*/
|
|
71
|
+
export function submissionNeedsSelectedModel(inputValue, isSkillSlashInvocation = false) {
|
|
72
|
+
const trimmed = inputValue.trim();
|
|
73
|
+
if (!trimmed) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (["q", "exit"].includes(trimmed.toLowerCase())) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (normalizeCommand(trimmed)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (isSkillSlashInvocation) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return !trimmed.startsWith("/");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse process argv into the minimal startup commands supported by the CLI.
|
|
89
|
+
*
|
|
90
|
+
* Why this exists:
|
|
91
|
+
* - Slash commands run after the TUI mounts, but `xbcode resume <id>` needs to
|
|
92
|
+
* restore state before the first render so the user lands directly in that
|
|
93
|
+
* session.
|
|
94
|
+
* - Keeping argv parsing separate from the Ink entrypoint makes the behavior
|
|
95
|
+
* testable without booting the full terminal UI.
|
|
96
|
+
* - The parser stays intentionally tiny: unknown argv falls back to normal
|
|
97
|
+
* startup so we do not accidentally turn future positional text into a
|
|
98
|
+
* pseudo-command.
|
|
99
|
+
*/
|
|
100
|
+
export function parseStartupCommand(argv) {
|
|
101
|
+
const [firstArg, secondArg] = argv.map((value) => value.trim()).filter(Boolean);
|
|
102
|
+
if (firstArg === "resume") {
|
|
103
|
+
return {
|
|
104
|
+
kind: "resume",
|
|
105
|
+
sessionId: secondArg,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { kind: "default" };
|
|
109
|
+
}
|
package/dist/compact.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
const KEEP_RECENT = 3;
|
|
4
|
+
const TOKEN_THRESHOLD = 50000;
|
|
5
|
+
const TRANSCRIPT_DIR = ".transcripts";
|
|
6
|
+
const COMPACT_INPUT_CHAR_LIMIT = 120000;
|
|
7
|
+
const RECENT_USER_MESSAGE_COUNT = 2;
|
|
8
|
+
const RECENT_MESSAGE_FALLBACK_COUNT = 6;
|
|
9
|
+
/** Rough token estimate: ~4 chars ≈ 1 token */
|
|
10
|
+
export function estimateTokens(messages) {
|
|
11
|
+
return Math.ceil(JSON.stringify(messages).length / 4);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Layer 1: Micro-Compact
|
|
15
|
+
* Replace old tool result content with placeholders (in-place).
|
|
16
|
+
*/
|
|
17
|
+
export function microCompact(messages) {
|
|
18
|
+
const toolIndices = [];
|
|
19
|
+
for (let i = 0; i < messages.length; i++) {
|
|
20
|
+
if (messages[i].role === "tool") {
|
|
21
|
+
toolIndices.push(i);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (toolIndices.length <= KEEP_RECENT)
|
|
25
|
+
return;
|
|
26
|
+
const toReplace = toolIndices.slice(0, -KEEP_RECENT);
|
|
27
|
+
for (const idx of toReplace) {
|
|
28
|
+
const msg = messages[idx];
|
|
29
|
+
const content = String(msg.content ?? "");
|
|
30
|
+
if (content.length > 100) {
|
|
31
|
+
const toolName = findToolName(messages, idx);
|
|
32
|
+
msg.content = `[Previous: used ${toolName}]`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function findToolName(messages, toolIdx) {
|
|
37
|
+
const toolCallId = messages[toolIdx].tool_call_id;
|
|
38
|
+
if (!toolCallId)
|
|
39
|
+
return "unknown";
|
|
40
|
+
for (let i = toolIdx - 1; i >= 0; i--) {
|
|
41
|
+
const msg = messages[i];
|
|
42
|
+
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
|
43
|
+
for (const tc of msg.tool_calls) {
|
|
44
|
+
if (tc.id === toolCallId) {
|
|
45
|
+
return tc.function?.name ?? "unknown";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 识别一条本地历史记录是否代表“用户发言”的边界。
|
|
54
|
+
*
|
|
55
|
+
* 为什么需要统一这个判断:
|
|
56
|
+
* - chat-completions 和 responses 两种模式的历史结构不同,但“最近若干轮用户消息之后保留原文”
|
|
57
|
+
* 这个策略是共通的。
|
|
58
|
+
* - 压缩时如果从 assistant/tool 中间截断,后续上下文很容易出现孤立的工具结果或失去最近任务的起点。
|
|
59
|
+
* - 把边界判断集中在这里,能让两种 API 模式复用同一套保留策略,而不是各写一套分叉逻辑。
|
|
60
|
+
*/
|
|
61
|
+
function isUserBoundaryMessage(message) {
|
|
62
|
+
return String(message.role ?? "") === "user";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 计算压缩时应该保留原文的最近消息区间起点。
|
|
66
|
+
*
|
|
67
|
+
* 为什么采用“最近两条用户消息边界”:
|
|
68
|
+
* - 相比简单保留最后 N 条消息,按用户消息切可以更稳定地保住最近完整任务上下文。
|
|
69
|
+
* - 最近一条用户消息往往只包含当前追问,保留最近两条通常能覆盖“当前问题 + 紧邻上一轮背景”。
|
|
70
|
+
* - 当历史里用户消息太少时,再退化成固定保留最后若干条,避免把整段对话都压成摘要。
|
|
71
|
+
*/
|
|
72
|
+
function findRecentMessagesStart(messages) {
|
|
73
|
+
let seenUserMessages = 0;
|
|
74
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
75
|
+
if (!isUserBoundaryMessage(messages[index])) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
seenUserMessages += 1;
|
|
79
|
+
if (seenUserMessages >= RECENT_USER_MESSAGE_COUNT) {
|
|
80
|
+
return index;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Math.max(0, messages.length - RECENT_MESSAGE_FALLBACK_COUNT);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 把待压缩历史拆成“需要总结的旧前缀”和“原样保留的最近后缀”。
|
|
87
|
+
*
|
|
88
|
+
* 为什么单独抽这个步骤:
|
|
89
|
+
* - 旧实现是整段历史一次性总结,导致最近最关键的原文也被吃掉。
|
|
90
|
+
* - 压缩后保留最近原文,可以显著降低摘要漂移,并保住最近的工具调用、错误信息和用户最新要求。
|
|
91
|
+
* - 两种 API 模式都依赖这一步,所以这里返回通用的前后缀结构,而不是直接拼装最终消息。
|
|
92
|
+
*/
|
|
93
|
+
function splitMessagesForCompaction(messages) {
|
|
94
|
+
if (messages.length <= RECENT_MESSAGE_FALLBACK_COUNT) {
|
|
95
|
+
return {
|
|
96
|
+
olderMessages: messages.slice(0, Math.max(0, messages.length - 1)),
|
|
97
|
+
recentMessages: messages.slice(Math.max(0, messages.length - 1)),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const recentStart = findRecentMessagesStart(messages);
|
|
101
|
+
return {
|
|
102
|
+
olderMessages: messages.slice(0, recentStart),
|
|
103
|
+
recentMessages: messages.slice(recentStart),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 生成用于 continuity 的压缩摘要正文。
|
|
108
|
+
*
|
|
109
|
+
* 为什么额外附带 transcript 路径:
|
|
110
|
+
* - 摘要永远是有损的,复杂报错、精确代码片段、长工具输出不适合全部塞回上下文。
|
|
111
|
+
* - 给模型一个可回溯的 transcript 路径,能在必要时重新读取细节,而不必默认把所有内容都塞进 summary。
|
|
112
|
+
* - 同一段文字会同时用于 chat 历史替换和 responses 切链续接,统一文案可以减少两条路径的语义偏差。
|
|
113
|
+
*/
|
|
114
|
+
function buildCompressedHistoryText(summary, transcriptPath) {
|
|
115
|
+
return [
|
|
116
|
+
"[Compressed conversation history]",
|
|
117
|
+
"",
|
|
118
|
+
summary,
|
|
119
|
+
"",
|
|
120
|
+
`Full transcript saved at: ${transcriptPath}`,
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 为压缩请求构建结构化总结提示词。
|
|
125
|
+
*
|
|
126
|
+
* 为什么不用简单一句“请总结”:
|
|
127
|
+
* - compact 的目标不是泛化摘要,而是让后续模型继续工作,因此需要强约束保留任务、文件、修改、错误和待办。
|
|
128
|
+
* - 明确要求“recent preserved messages already remain verbatim”,可以避免摘要重复最近上下文,节省后续 token。
|
|
129
|
+
* - 这里仍然保持提示词简短,避免为了生成摘要本身再把 compaction 请求做得过重。
|
|
130
|
+
*/
|
|
131
|
+
function buildCompactPrompt(serializedHistory) {
|
|
132
|
+
return [
|
|
133
|
+
"Summarize the older portion of the following conversation for continuity.",
|
|
134
|
+
"The most recent messages will remain verbatim after compaction, so focus on preserving durable context only.",
|
|
135
|
+
"Preserve exactly these items when present:",
|
|
136
|
+
"- current task goals and user intent",
|
|
137
|
+
"- key decisions and constraints",
|
|
138
|
+
"- file paths, functions, APIs, and code changes",
|
|
139
|
+
"- important tool findings, errors, and fixes",
|
|
140
|
+
"- pending work and the exact point where work should resume",
|
|
141
|
+
"Be concise but complete.",
|
|
142
|
+
"",
|
|
143
|
+
serializedHistory,
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 调用模型生成旧前缀摘要。
|
|
148
|
+
*
|
|
149
|
+
* 为什么只序列化“待总结前缀”:
|
|
150
|
+
* - 最近消息会保留原文,重复发送它们只会浪费 compact request 的输入预算。
|
|
151
|
+
* - 旧实现只截取整段 JSON 的前 80k 字符,容易把最近上下文直接丢掉;现在改成先分段,再截旧前缀。
|
|
152
|
+
* - 这里仍保留字符上限,避免极端长历史让 compact 请求本身再次超长。
|
|
153
|
+
*/
|
|
154
|
+
async function summarizeOlderMessages(client, model, messages) {
|
|
155
|
+
const serializedHistory = JSON.stringify(messages).slice(0, COMPACT_INPUT_CHAR_LIMIT);
|
|
156
|
+
const response = await client.chat.completions.create({
|
|
157
|
+
model,
|
|
158
|
+
messages: [
|
|
159
|
+
{
|
|
160
|
+
role: "user",
|
|
161
|
+
content: buildCompactPrompt(serializedHistory),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
max_tokens: 2000,
|
|
165
|
+
});
|
|
166
|
+
return response.choices[0]?.message?.content ?? "No summary generated.";
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 构造 chat-completions 模式压缩后的历史。
|
|
170
|
+
*
|
|
171
|
+
* 为什么采用“摘要消息 + 最近原文”的结构:
|
|
172
|
+
* - 第一条 user summary 给模型一个稳定的 continuity 入口。
|
|
173
|
+
* - 紧跟一条 assistant 确认消息,能把“这是一段承接上下文”固定成对话状态,而不是裸插一段文本。
|
|
174
|
+
* - 最近消息保留原文,可以让后续推理直接接在最新真实上下文后面,而不是完全依赖摘要复述。
|
|
175
|
+
*/
|
|
176
|
+
function buildCompactedChatHistory(summary, transcriptPath, recentMessages) {
|
|
177
|
+
return [
|
|
178
|
+
{ role: "user", content: buildCompressedHistoryText(summary, transcriptPath) },
|
|
179
|
+
{ role: "assistant", content: "Understood. I will continue from the preserved recent context." },
|
|
180
|
+
...recentMessages.map((message) => ({ ...message })),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 构造 responses 模式本地 replay 历史里的压缩结果。
|
|
185
|
+
*
|
|
186
|
+
* 为什么也保留最近原文 replay items:
|
|
187
|
+
* - stateless replay 分支会直接把本地 `responseHistory` 重发给模型,如果压缩后只剩摘要,会把最近细节全部抹平。
|
|
188
|
+
* - 对支持 `previous_response_id` 的分支,这份 replay 历史虽然默认不直接发送,但仍用于本地估算、手动 compact 和 resume。
|
|
189
|
+
* - 保留最近 replay items 让两种 responses 子路径在 compact 后拥有一致的“摘要旧前缀 + 原文最近后缀”语义。
|
|
190
|
+
*/
|
|
191
|
+
function buildCompactedResponseHistory(summary, transcriptPath, recentMessages) {
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
type: "message",
|
|
195
|
+
role: "user",
|
|
196
|
+
content: [
|
|
197
|
+
{
|
|
198
|
+
type: "input_text",
|
|
199
|
+
text: buildCompressedHistoryText(summary, transcriptPath),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
...recentMessages.map((message) => ({ ...message })),
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Layer 2: Auto-Compact for chat-completions mode.
|
|
208
|
+
*
|
|
209
|
+
* 为什么保留最近原文而不是完全替换成摘要:
|
|
210
|
+
* - 最近上下文最容易影响下一步动作,直接保留能显著降低压缩后的行为漂移。
|
|
211
|
+
* - 旧前缀仍然通过摘要保留下来,整体 token 体积会比原始全量历史小很多。
|
|
212
|
+
* - 返回结构化结果而不是裸消息数组,方便 responses 模式复用同一次摘要逻辑。
|
|
213
|
+
*/
|
|
214
|
+
export async function autoCompact(client, model, messages) {
|
|
215
|
+
const transcriptPath = saveTranscript(messages);
|
|
216
|
+
const { olderMessages, recentMessages } = splitMessagesForCompaction(messages);
|
|
217
|
+
const summarySource = olderMessages.length > 0 ? olderMessages : messages;
|
|
218
|
+
const summary = await summarizeOlderMessages(client, model, summarySource);
|
|
219
|
+
return {
|
|
220
|
+
messages: buildCompactedChatHistory(summary, transcriptPath, recentMessages),
|
|
221
|
+
summary,
|
|
222
|
+
transcriptPath,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 为 responses 模式生成压缩后的本地 replay 历史和续接文本。
|
|
227
|
+
*
|
|
228
|
+
* 为什么额外返回 continuationMessage:
|
|
229
|
+
* - 支持 `previous_response_id` 的 providers 不会默认重放本地历史,所以切链后必须把摘要主动注入下一轮用户输入。
|
|
230
|
+
* - 不支持 `previous_response_id` 的 stateless replay 路径则直接使用压缩后的 `messages` 即可。
|
|
231
|
+
* - 两条路径共用同一份 summary,可以避免 stateful/stateless 在 compact 后看到不同语义的上下文。
|
|
232
|
+
*/
|
|
233
|
+
export async function autoCompactResponseHistory(client, model, messages) {
|
|
234
|
+
const transcriptPath = saveTranscript(messages);
|
|
235
|
+
const { olderMessages, recentMessages } = splitMessagesForCompaction(messages);
|
|
236
|
+
const summarySource = olderMessages.length > 0 ? olderMessages : messages;
|
|
237
|
+
const summary = await summarizeOlderMessages(client, model, summarySource);
|
|
238
|
+
const continuityMessage = buildCompressedHistoryText(summary, transcriptPath);
|
|
239
|
+
return {
|
|
240
|
+
messages: buildCompactedResponseHistory(summary, transcriptPath, recentMessages),
|
|
241
|
+
summary,
|
|
242
|
+
transcriptPath,
|
|
243
|
+
continuationMessage: continuityMessage,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 把完整历史落盘成 JSONL transcript。
|
|
248
|
+
*
|
|
249
|
+
* 为什么返回具体路径:
|
|
250
|
+
* - compact 后需要把 transcript 路径带回 summary/continuity 文本,方便后续按需回读细节。
|
|
251
|
+
* - responses 和 chat 两条压缩路径都要引用这个路径,直接返回可避免调用方重复拼接。
|
|
252
|
+
* - 保持 append-only 文件名规则不变,可以兼容现有的人工排查习惯。
|
|
253
|
+
*/
|
|
254
|
+
function saveTranscript(messages) {
|
|
255
|
+
mkdirSync(TRANSCRIPT_DIR, { recursive: true });
|
|
256
|
+
const filename = `transcript_${Date.now()}.jsonl`;
|
|
257
|
+
const content = messages.map(m => JSON.stringify(m)).join("\n");
|
|
258
|
+
const filePath = join(TRANSCRIPT_DIR, filename);
|
|
259
|
+
writeFileSync(filePath, content, "utf-8");
|
|
260
|
+
return filePath;
|
|
261
|
+
}
|
|
262
|
+
export { TOKEN_THRESHOLD };
|