@cjhyy/code-shell-core 0.6.0-rc.13 → 0.6.0-rc.14
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/dist/context/compaction.d.ts +8 -1
- package/dist/context/compaction.js +49 -4
- package/dist/engine/turn-loop.js +1 -1
- package/dist/session/transcript.d.ts +1 -1
- package/dist/session/transcript.js +17 -5
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/view-image.js +100 -19
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* All slicing functions use adjustIndexToPreserveAPIInvariants()
|
|
11
11
|
* to prevent splitting tool_use / tool_result pairs.
|
|
12
12
|
*/
|
|
13
|
-
import type { Message } from "../types.js";
|
|
13
|
+
import type { ContentBlock, Message } from "../types.js";
|
|
14
14
|
/**
|
|
15
15
|
* Estimate token count from messages.
|
|
16
16
|
* Uses per-block-type estimation with 33% overhead padding.
|
|
@@ -34,6 +34,12 @@ export interface DowngradeImageHistoryResult {
|
|
|
34
34
|
messages: Message[];
|
|
35
35
|
replacedCount: number;
|
|
36
36
|
}
|
|
37
|
+
export interface Base64ImageHistoryEntry {
|
|
38
|
+
imageNumber: number;
|
|
39
|
+
block: ContentBlock;
|
|
40
|
+
}
|
|
41
|
+
export declare function collectBase64Images(messages: Message[]): Base64ImageHistoryEntry[];
|
|
42
|
+
export declare function findImageByNumber(messages: Message[], imageNumber: number): Base64ImageHistoryEntry | undefined;
|
|
37
43
|
/**
|
|
38
44
|
* Replace already-consumed image payload blocks with compact text markers.
|
|
39
45
|
*
|
|
@@ -45,6 +51,7 @@ export interface DowngradeImageHistoryResult {
|
|
|
45
51
|
*/
|
|
46
52
|
export declare function downgradeImagePayloadsInHistory(messages: Message[], options?: DowngradeImageHistoryOptions): DowngradeImageHistoryResult;
|
|
47
53
|
export declare function messageHasBase64ImagePayload(message: Message): boolean;
|
|
54
|
+
export declare function isBase64ImageBlock(block: ContentBlock): boolean;
|
|
48
55
|
/**
|
|
49
56
|
* Reconcile user-supplied compaction ratios into a safe ordering.
|
|
50
57
|
*
|
|
@@ -20,6 +20,36 @@ export function estimateTokens(messages) {
|
|
|
20
20
|
}
|
|
21
21
|
export const IMAGE_HISTORY_PLACEHOLDER_PREFIX = "[image #";
|
|
22
22
|
export const IMAGE_HISTORY_PLACEHOLDER_SUFFIX = ", 已处理 / already provided earlier]";
|
|
23
|
+
export function collectBase64Images(messages) {
|
|
24
|
+
const entries = [];
|
|
25
|
+
let nextImageNumber = 1;
|
|
26
|
+
const visitBlocks = (blocks) => {
|
|
27
|
+
for (const block of blocks) {
|
|
28
|
+
const placeholderNumber = imageHistoryPlaceholderNumber(block);
|
|
29
|
+
if (placeholderNumber !== undefined) {
|
|
30
|
+
nextImageNumber = Math.max(nextImageNumber, placeholderNumber + 1);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (isBase64ImageBlock(block)) {
|
|
34
|
+
entries.push({ imageNumber: nextImageNumber++, block });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
|
38
|
+
visitBlocks(block.content);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
if (Array.isArray(msg.content))
|
|
44
|
+
visitBlocks(msg.content);
|
|
45
|
+
}
|
|
46
|
+
return entries;
|
|
47
|
+
}
|
|
48
|
+
export function findImageByNumber(messages, imageNumber) {
|
|
49
|
+
if (!Number.isSafeInteger(imageNumber) || imageNumber <= 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
return collectBase64Images(messages).find((entry) => entry.imageNumber === imageNumber);
|
|
52
|
+
}
|
|
23
53
|
/**
|
|
24
54
|
* Replace already-consumed image payload blocks with compact text markers.
|
|
25
55
|
*
|
|
@@ -30,9 +60,23 @@ export const IMAGE_HISTORY_PLACEHOLDER_SUFFIX = ", 已处理 / already provided
|
|
|
30
60
|
* nested inside tool_result.content arrays (view_image / browser screenshots).
|
|
31
61
|
*/
|
|
32
62
|
export function downgradeImagePayloadsInHistory(messages, options = {}) {
|
|
33
|
-
let nextImageNumber = 1;
|
|
34
63
|
let replacedCount = 0;
|
|
35
64
|
let changed = false;
|
|
65
|
+
const imageNumberQueues = new WeakMap();
|
|
66
|
+
for (const entry of collectBase64Images(messages)) {
|
|
67
|
+
const key = entry.block;
|
|
68
|
+
const existing = imageNumberQueues.get(key);
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.push(entry.imageNumber);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
imageNumberQueues.set(key, [entry.imageNumber]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const takeImageNumber = (block) => {
|
|
77
|
+
const queue = imageNumberQueues.get(block);
|
|
78
|
+
return queue?.shift();
|
|
79
|
+
};
|
|
36
80
|
const placeholderFor = (imageNumber) => ({
|
|
37
81
|
type: "text",
|
|
38
82
|
text: `${IMAGE_HISTORY_PLACEHOLDER_PREFIX}${imageNumber}${IMAGE_HISTORY_PLACEHOLDER_SUFFIX}`,
|
|
@@ -42,11 +86,12 @@ export function downgradeImagePayloadsInHistory(messages, options = {}) {
|
|
|
42
86
|
const out = blocks.map((block) => {
|
|
43
87
|
const placeholderNumber = imageHistoryPlaceholderNumber(block);
|
|
44
88
|
if (placeholderNumber !== undefined) {
|
|
45
|
-
nextImageNumber = Math.max(nextImageNumber, placeholderNumber + 1);
|
|
46
89
|
return block;
|
|
47
90
|
}
|
|
48
91
|
if (isBase64ImageBlock(block)) {
|
|
49
|
-
const imageNumber =
|
|
92
|
+
const imageNumber = takeImageNumber(block);
|
|
93
|
+
if (imageNumber === undefined)
|
|
94
|
+
return block;
|
|
50
95
|
if (preserve)
|
|
51
96
|
return block;
|
|
52
97
|
replacedCount++;
|
|
@@ -99,7 +144,7 @@ function imageHistoryPlaceholderNumber(block) {
|
|
|
99
144
|
const n = Number(match[1]);
|
|
100
145
|
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
101
146
|
}
|
|
102
|
-
function isBase64ImageBlock(block) {
|
|
147
|
+
export function isBase64ImageBlock(block) {
|
|
103
148
|
if (block.type === "image" &&
|
|
104
149
|
block.source?.type === "base64" &&
|
|
105
150
|
typeof block.source.data === "string" &&
|
package/dist/engine/turn-loop.js
CHANGED
|
@@ -800,7 +800,7 @@ export class TurnLoop {
|
|
|
800
800
|
const resultBlocks = [];
|
|
801
801
|
for (const result of results) {
|
|
802
802
|
resultBlocks.push(toolResultToBlock(result));
|
|
803
|
-
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error);
|
|
803
|
+
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error, result.contentBlocks);
|
|
804
804
|
this.config.onStream?.({ type: "tool_result", result });
|
|
805
805
|
}
|
|
806
806
|
// Fire-and-forget tool use summary (non-blocking). The whole chain is
|
|
@@ -27,7 +27,7 @@ export declare class Transcript {
|
|
|
27
27
|
}): TranscriptEvent;
|
|
28
28
|
hasClientMessageId(clientMessageId: string): boolean;
|
|
29
29
|
appendToolUse(toolName: string, toolCallId: string, args: Record<string, unknown>): TranscriptEvent;
|
|
30
|
-
appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string): TranscriptEvent;
|
|
30
|
+
appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string, contentBlocks?: ContentBlock[]): TranscriptEvent;
|
|
31
31
|
/** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
|
|
32
32
|
* Written at spawn time so replay can rebuild the sub-agent's card from
|
|
33
33
|
* sessions/<agentId>/ — agentId === the sub-agent's session id. */
|
|
@@ -10,7 +10,9 @@ export class Transcript {
|
|
|
10
10
|
events = [];
|
|
11
11
|
filePath;
|
|
12
12
|
currentTurn = 0;
|
|
13
|
-
getFilePath() {
|
|
13
|
+
getFilePath() {
|
|
14
|
+
return this.filePath;
|
|
15
|
+
}
|
|
14
16
|
constructor(filePath) {
|
|
15
17
|
this.filePath = filePath;
|
|
16
18
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -66,8 +68,14 @@ export class Transcript {
|
|
|
66
68
|
appendToolUse(toolName, toolCallId, args) {
|
|
67
69
|
return this.append("tool_use", { toolName, toolCallId, args });
|
|
68
70
|
}
|
|
69
|
-
appendToolResult(toolCallId, toolName, result, error) {
|
|
70
|
-
return this.append("tool_result", {
|
|
71
|
+
appendToolResult(toolCallId, toolName, result, error, contentBlocks) {
|
|
72
|
+
return this.append("tool_result", {
|
|
73
|
+
toolCallId,
|
|
74
|
+
toolName,
|
|
75
|
+
result,
|
|
76
|
+
error,
|
|
77
|
+
...(contentBlocks && contentBlocks.length > 0 ? { contentBlocks } : {}),
|
|
78
|
+
});
|
|
71
79
|
}
|
|
72
80
|
/** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
|
|
73
81
|
* Written at spawn time so replay can rebuild the sub-agent's card from
|
|
@@ -125,13 +133,17 @@ export class Transcript {
|
|
|
125
133
|
break;
|
|
126
134
|
}
|
|
127
135
|
case "tool_result": {
|
|
128
|
-
const { toolCallId, result, error } = event.data;
|
|
136
|
+
const { toolCallId, result, error, contentBlocks } = event.data;
|
|
129
137
|
// Find if there's already a user message with tool_results to append to
|
|
130
138
|
const lastMsg = messages[messages.length - 1];
|
|
131
139
|
const block = {
|
|
132
140
|
type: "tool_result",
|
|
133
141
|
tool_use_id: toolCallId,
|
|
134
|
-
content: error
|
|
142
|
+
content: error
|
|
143
|
+
? `Error: ${error}`
|
|
144
|
+
: Array.isArray(contentBlocks) && contentBlocks.length > 0
|
|
145
|
+
? contentBlocks
|
|
146
|
+
: (result ?? "(no output)"),
|
|
135
147
|
};
|
|
136
148
|
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
|
|
137
149
|
lastMsg.content.push(block);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in view_image tool —
|
|
3
|
-
* 回传进上下文,让 vision 模型「看」它(对照 codex 的 view_image)。
|
|
2
|
+
* Built-in view_image tool — 把一个本地图片文件或历史图片以 base64 image
|
|
3
|
+
* ContentBlock 回传进上下文,让 vision 模型「看」它(对照 codex 的 view_image)。
|
|
4
4
|
*
|
|
5
5
|
* 典型用法:模型先写 SVG/Mermaid 并用 shell 转成 PNG,再调 view_image(png)
|
|
6
6
|
* 检查图画对没有(标签是否重叠、文字是否溢出),不对就改源再重转。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in view_image tool —
|
|
3
|
-
* 回传进上下文,让 vision 模型「看」它(对照 codex 的 view_image)。
|
|
2
|
+
* Built-in view_image tool — 把一个本地图片文件或历史图片以 base64 image
|
|
3
|
+
* ContentBlock 回传进上下文,让 vision 模型「看」它(对照 codex 的 view_image)。
|
|
4
4
|
*
|
|
5
5
|
* 典型用法:模型先写 SVG/Mermaid 并用 shell 转成 PNG,再调 view_image(png)
|
|
6
6
|
* 检查图画对没有(标签是否重叠、文字是否溢出),不对就改源再重转。
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { readFile, stat } from "node:fs/promises";
|
|
15
15
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
16
16
|
import { capabilitiesFor } from "../../llm/capabilities/index.js";
|
|
17
|
+
import { collectBase64Images, findImageByNumber } from "../../context/compaction.js";
|
|
17
18
|
const MAX_BYTES = 5 * 1024 * 1024; // 5MB —— vision 模型按 tile 计 token,大图无益且撑大请求
|
|
18
19
|
const MEDIA_TYPES = {
|
|
19
20
|
".png": "image/png",
|
|
@@ -24,11 +25,11 @@ const MEDIA_TYPES = {
|
|
|
24
25
|
};
|
|
25
26
|
export const viewImageToolDef = {
|
|
26
27
|
name: "view_image",
|
|
27
|
-
description: "Load
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
28
|
+
description: "Load an image into the conversation so you can SEE it (vision). Pass path to view a " +
|
|
29
|
+
"workspace image file, or pass imageNumber to retrieve the original image behind an " +
|
|
30
|
+
"earlier [image #N, already provided] history placeholder. Use exactly one of path or " +
|
|
31
|
+
"imageNumber. File paths support PNG/JPEG/GIF/WebP only; convert SVG/PDF to PNG first. " +
|
|
32
|
+
"Requires a vision-capable model; otherwise the image is skipped.",
|
|
32
33
|
inputSchema: {
|
|
33
34
|
type: "object",
|
|
34
35
|
properties: {
|
|
@@ -36,14 +37,31 @@ export const viewImageToolDef = {
|
|
|
36
37
|
type: "string",
|
|
37
38
|
description: "Path to the image file (absolute, or relative to the working directory).",
|
|
38
39
|
},
|
|
40
|
+
imageNumber: {
|
|
41
|
+
type: "number",
|
|
42
|
+
description: "Image history number N from an earlier [image #N, already provided] placeholder.",
|
|
43
|
+
},
|
|
39
44
|
},
|
|
40
|
-
required: ["path"],
|
|
41
45
|
},
|
|
42
46
|
};
|
|
43
47
|
export async function viewImageTool(args, ctx) {
|
|
44
48
|
const rawPath = args.path;
|
|
49
|
+
const rawImageNumber = args.imageNumber;
|
|
50
|
+
const hasPath = typeof rawPath === "string" && rawPath.trim().length > 0;
|
|
51
|
+
const hasImageNumber = rawImageNumber !== undefined && rawImageNumber !== null;
|
|
52
|
+
if (hasPath === hasImageNumber) {
|
|
53
|
+
return "Error: provide exactly one of path or imageNumber";
|
|
54
|
+
}
|
|
55
|
+
if (hasImageNumber) {
|
|
56
|
+
if (typeof rawImageNumber !== "number" ||
|
|
57
|
+
!Number.isSafeInteger(rawImageNumber) ||
|
|
58
|
+
rawImageNumber <= 0) {
|
|
59
|
+
return "Error: imageNumber must be a positive integer";
|
|
60
|
+
}
|
|
61
|
+
return viewHistoricalImage(rawImageNumber, ctx);
|
|
62
|
+
}
|
|
45
63
|
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
|
46
|
-
return "Error: path
|
|
64
|
+
return "Error: path must be a non-empty string";
|
|
47
65
|
}
|
|
48
66
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
49
67
|
const abs = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
@@ -51,13 +69,7 @@ export async function viewImageTool(args, ctx) {
|
|
|
51
69
|
// 缺 llmConfig 时按「非视觉」处理(fail closed),对齐 DEFAULT_CAPABILITY
|
|
52
70
|
// 的保守姿态:运行时 ToolContext.llmConfig 必有,缺失只发生在测试 / 异常
|
|
53
71
|
// 装配下,此时绝不把 base64 读进上下文。
|
|
54
|
-
|
|
55
|
-
if (!ctx?.llmConfig)
|
|
56
|
-
return false;
|
|
57
|
-
const kind = (ctx.llmConfig.providerKind ?? ctx.llmConfig.provider);
|
|
58
|
-
return capabilitiesFor(kind, ctx.llmConfig.model).supportsVision;
|
|
59
|
-
})();
|
|
60
|
-
if (!supportsVision) {
|
|
72
|
+
if (!supportsVision(ctx)) {
|
|
61
73
|
return `[图片未加载: ${abs} —— 当前模型不支持视觉输入,已跳过。切换到 vision 模型后再 view_image。]`;
|
|
62
74
|
}
|
|
63
75
|
// 闸门 2:格式 gate
|
|
@@ -88,9 +100,78 @@ export async function viewImageTool(args, ctx) {
|
|
|
88
100
|
const data = buf.toString("base64");
|
|
89
101
|
const kb = Math.round(buf.length / 1024);
|
|
90
102
|
return {
|
|
91
|
-
contentBlocks: [
|
|
92
|
-
{ type: "image", source: { type: "base64", media_type: mediaType, data } },
|
|
93
|
-
],
|
|
103
|
+
contentBlocks: [{ type: "image", source: { type: "base64", media_type: mediaType, data } }],
|
|
94
104
|
result: `[已加载图片: ${abs} (${mediaType}, ${kb} KB)]`,
|
|
95
105
|
};
|
|
96
106
|
}
|
|
107
|
+
function supportsVision(ctx) {
|
|
108
|
+
if (!ctx?.llmConfig)
|
|
109
|
+
return false;
|
|
110
|
+
const kind = (ctx.llmConfig.providerKind ?? ctx.llmConfig.provider);
|
|
111
|
+
return capabilitiesFor(kind, ctx.llmConfig.model).supportsVision;
|
|
112
|
+
}
|
|
113
|
+
async function viewHistoricalImage(imageNumber, ctx) {
|
|
114
|
+
if (!supportsVision(ctx)) {
|
|
115
|
+
return `[图片未取回: image #${imageNumber} —— 当前模型不支持视觉输入,已跳过。切换到 vision 模型后再 view_image。]`;
|
|
116
|
+
}
|
|
117
|
+
const sessionManager = ctx?.engine?.getSessionManager?.();
|
|
118
|
+
const sessionId = ctx?.sessionId;
|
|
119
|
+
if (!sessionManager || !sessionId) {
|
|
120
|
+
return `Error: 无法取回 image #${imageNumber}: 当前工具上下文没有可读取的 session 历史。`;
|
|
121
|
+
}
|
|
122
|
+
let messages;
|
|
123
|
+
try {
|
|
124
|
+
messages = sessionManager.resume(sessionId).transcript.toMessages();
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return `Error: 无法读取 session 历史以取回 image #${imageNumber}: ${err.message}`;
|
|
128
|
+
}
|
|
129
|
+
const images = collectBase64Images(messages);
|
|
130
|
+
const found = findImageByNumber(messages, imageNumber);
|
|
131
|
+
if (!found) {
|
|
132
|
+
return `Error: 未找到 image #${imageNumber}; 当前 session 历史中共有 ${images.length} 张可取回图片。`;
|
|
133
|
+
}
|
|
134
|
+
const block = normalizeImageBlockForReturn(found.block);
|
|
135
|
+
const size = decodedImageBlockBytes(block);
|
|
136
|
+
if (size !== undefined && size > MAX_BYTES) {
|
|
137
|
+
const mb = (size / 1024 / 1024).toFixed(1);
|
|
138
|
+
return `[图片未取回: image #${imageNumber} —— 图片过大 (${mb} MB > 5 MB),请先压缩或缩放原图。]`;
|
|
139
|
+
}
|
|
140
|
+
const kb = size === undefined ? "unknown" : String(Math.round(size / 1024));
|
|
141
|
+
const mediaType = mediaTypeOfImageBlock(block) ?? "base64 image";
|
|
142
|
+
return {
|
|
143
|
+
contentBlocks: [block],
|
|
144
|
+
result: `[已取回 image #${imageNumber}: ${mediaType}, ${kb} KB]`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function normalizeImageBlockForReturn(block) {
|
|
148
|
+
if (block.type === "image" && block.source?.type === "base64")
|
|
149
|
+
return block;
|
|
150
|
+
const source = openAIDataUrlImageSource(block);
|
|
151
|
+
return source ? { type: "image", source } : block;
|
|
152
|
+
}
|
|
153
|
+
function decodedImageBlockBytes(block) {
|
|
154
|
+
const data = base64DataOfImageBlock(block);
|
|
155
|
+
if (!data)
|
|
156
|
+
return undefined;
|
|
157
|
+
return Buffer.byteLength(data, "base64");
|
|
158
|
+
}
|
|
159
|
+
function base64DataOfImageBlock(block) {
|
|
160
|
+
if (block.type === "image" && block.source?.type === "base64") {
|
|
161
|
+
return block.source.data;
|
|
162
|
+
}
|
|
163
|
+
return openAIDataUrlImageSource(block)?.data;
|
|
164
|
+
}
|
|
165
|
+
function mediaTypeOfImageBlock(block) {
|
|
166
|
+
if (block.type === "image" && block.source?.type === "base64") {
|
|
167
|
+
return block.source.media_type;
|
|
168
|
+
}
|
|
169
|
+
return openAIDataUrlImageSource(block)?.media_type;
|
|
170
|
+
}
|
|
171
|
+
function openAIDataUrlImageSource(block) {
|
|
172
|
+
const maybeOpenAI = block;
|
|
173
|
+
const match = maybeOpenAI.image_url?.url?.match(/^data:(image\/[^;,]+);base64,(.+)$/i);
|
|
174
|
+
if (maybeOpenAI.type !== "image_url" || !match?.[1] || !match[2])
|
|
175
|
+
return undefined;
|
|
176
|
+
return { type: "base64", media_type: match[1], data: match[2] };
|
|
177
|
+
}
|
package/package.json
CHANGED