@love-moon/ai-sdk 0.8.0 → 0.10.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/CHANGELOG.md +14 -0
- package/dist/built-in-backends.d.ts +1 -0
- package/dist/built-in-backends.js +6 -0
- package/dist/context-files.d.ts +16 -0
- package/dist/context-files.js +86 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/manager/index.d.ts +1 -0
- package/dist/manager/index.js +1 -0
- package/dist/manager/install.js +29 -2
- package/dist/manager/manager.d.ts +5 -1
- package/dist/manager/manager.js +8 -0
- package/dist/manager/network.js +4 -2
- package/dist/manager/quota/dsh.d.ts +55 -0
- package/dist/manager/quota/dsh.js +149 -0
- package/dist/manager/types.d.ts +25 -1
- package/dist/media-adapters.d.ts +34 -0
- package/dist/media-adapters.js +57 -0
- package/dist/media-input.d.ts +21 -0
- package/dist/media-input.js +197 -0
- package/dist/providers/chat-web-session.d.ts +7 -1
- package/dist/providers/chat-web-session.js +12 -1
- package/dist/providers/claude-agent-sdk-session.d.ts +10 -1
- package/dist/providers/claude-agent-sdk-session.js +24 -4
- package/dist/providers/codex-app-server-session.d.ts +19 -21
- package/dist/providers/codex-app-server-session.js +124 -12
- package/dist/providers/codex-exec-session.d.ts +8 -3
- package/dist/providers/codex-exec-session.js +13 -13
- package/dist/providers/copilot-sdk-session.d.ts +6 -1
- package/dist/providers/copilot-sdk-session.js +10 -3
- package/dist/providers/dsh-sdk-session.d.ts +194 -0
- package/dist/providers/dsh-sdk-session.js +951 -0
- package/dist/providers/kimi-cli-session.d.ts +6 -1
- package/dist/providers/kimi-cli-session.js +13 -3
- package/dist/providers/kimi-print-session.d.ts +10 -3
- package/dist/providers/kimi-print-session.js +23 -53
- package/dist/providers/opencode-sdk-session.d.ts +6 -1
- package/dist/providers/opencode-sdk-session.js +11 -9
- package/dist/resume/dsh.d.ts +23 -0
- package/dist/resume/dsh.js +122 -0
- package/dist/resume/index.js +2 -0
- package/dist/session-factory.d.ts +3 -1
- package/dist/session-factory.js +5 -1
- package/dsh/cordis.yml +100 -0
- package/package.json +24 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function normalizeMediaInputs(value: any): {
|
|
2
|
+
kind: any;
|
|
3
|
+
path: any;
|
|
4
|
+
mimeType: any;
|
|
5
|
+
name: string;
|
|
6
|
+
sizeBytes: number;
|
|
7
|
+
}[];
|
|
8
|
+
export function resolveTurnMedia(sessionOptions: any, turnOptions?: {}): {
|
|
9
|
+
kind: any;
|
|
10
|
+
path: any;
|
|
11
|
+
mimeType: any;
|
|
12
|
+
name: string;
|
|
13
|
+
sizeBytes: number;
|
|
14
|
+
}[];
|
|
15
|
+
export function assertMediaCapabilities(media: any, backend: any, capabilities: any): void;
|
|
16
|
+
export function fileMediaToDataUri(media: any): string;
|
|
17
|
+
export function fileMediaToBase64(media: any): string;
|
|
18
|
+
export function createMediaInputError(message: any, extras?: {}): Error;
|
|
19
|
+
export function defaultPromptForMedia(media: any): string;
|
|
20
|
+
export const MEDIA_CAPABILITY_NATIVE: "native";
|
|
21
|
+
export const MEDIA_CAPABILITY_UNSUPPORTED: "unsupported";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SUPPORTED_KINDS = new Set(["image"]);
|
|
4
|
+
const SUPPORTED_MIME_TYPES = new Set([
|
|
5
|
+
"image/gif",
|
|
6
|
+
"image/jpeg",
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/webp",
|
|
9
|
+
]);
|
|
10
|
+
const MAX_MEDIA_ITEMS = 20;
|
|
11
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
const MAX_TOTAL_MEDIA_BYTES = 100 * 1024 * 1024;
|
|
13
|
+
export const MEDIA_CAPABILITY_NATIVE = "native";
|
|
14
|
+
export const MEDIA_CAPABILITY_UNSUPPORTED = "unsupported";
|
|
15
|
+
function inferKind(mimeType) {
|
|
16
|
+
if (mimeType.startsWith("image/"))
|
|
17
|
+
return "image";
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
function inferMimeType(filePath, kind) {
|
|
21
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
22
|
+
const known = {
|
|
23
|
+
".bmp": "image/bmp",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".heic": "image/heic",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
};
|
|
31
|
+
return known[extension] || `${kind}/unknown`;
|
|
32
|
+
}
|
|
33
|
+
function hasExpectedSignature(filePath, mimeType) {
|
|
34
|
+
const handle = fs.openSync(filePath, "r");
|
|
35
|
+
try {
|
|
36
|
+
const header = Buffer.alloc(12);
|
|
37
|
+
const bytesRead = fs.readSync(handle, header, 0, header.length, 0);
|
|
38
|
+
if (bytesRead < 4)
|
|
39
|
+
return false;
|
|
40
|
+
if (mimeType === "image/png")
|
|
41
|
+
return header.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"));
|
|
42
|
+
if (mimeType === "image/jpeg")
|
|
43
|
+
return header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff;
|
|
44
|
+
if (mimeType === "image/gif")
|
|
45
|
+
return header.subarray(0, 6).toString("ascii") === "GIF87a" || header.subarray(0, 6).toString("ascii") === "GIF89a";
|
|
46
|
+
if (mimeType === "image/webp")
|
|
47
|
+
return header.subarray(0, 4).toString("ascii") === "RIFF" && header.subarray(8, 12).toString("ascii") === "WEBP";
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
fs.closeSync(handle);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function normalizeMediaInputs(value) {
|
|
55
|
+
if (value === undefined || value === null)
|
|
56
|
+
return [];
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
throw createMediaInputError("media must be an array", { reason: "invalid_media_input" });
|
|
59
|
+
}
|
|
60
|
+
if (value.length > MAX_MEDIA_ITEMS) {
|
|
61
|
+
throw createMediaInputError(`media cannot contain more than ${MAX_MEDIA_ITEMS} files`, {
|
|
62
|
+
reason: "media_limit_exceeded",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let totalBytes = 0;
|
|
66
|
+
return value.map((item, index) => {
|
|
67
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
68
|
+
throw createMediaInputError(`media[${index}] must be an object`, {
|
|
69
|
+
reason: "invalid_media_input",
|
|
70
|
+
mediaIndex: index,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const filePath = typeof item.path === "string" ? item.path.trim() : "";
|
|
74
|
+
const mimeType = typeof item.mimeType === "string" ? item.mimeType.trim().toLowerCase() : "";
|
|
75
|
+
const explicitKind = typeof item.kind === "string" ? item.kind.trim().toLowerCase() : "";
|
|
76
|
+
const kind = explicitKind || inferKind(mimeType);
|
|
77
|
+
if (!SUPPORTED_KINDS.has(kind)) {
|
|
78
|
+
throw createMediaInputError(`media[${index}] kind must be image`, {
|
|
79
|
+
reason: "invalid_media_input",
|
|
80
|
+
mediaIndex: index,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (!filePath || !path.isAbsolute(filePath)) {
|
|
84
|
+
throw createMediaInputError(`media[${index}] path must be absolute`, {
|
|
85
|
+
reason: "invalid_media_input",
|
|
86
|
+
mediaIndex: index,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
let stat;
|
|
90
|
+
try {
|
|
91
|
+
const linkStat = fs.lstatSync(filePath);
|
|
92
|
+
if (linkStat.isSymbolicLink()) {
|
|
93
|
+
throw new Error("symbolic links are not allowed");
|
|
94
|
+
}
|
|
95
|
+
stat = fs.statSync(filePath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw createMediaInputError(`media[${index}] file does not exist`, {
|
|
99
|
+
reason: "invalid_media_input",
|
|
100
|
+
mediaIndex: index,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
throw createMediaInputError(`media[${index}] path must reference a file`, {
|
|
105
|
+
reason: "invalid_media_input",
|
|
106
|
+
mediaIndex: index,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const normalizedMimeType = mimeType || inferMimeType(filePath, kind);
|
|
110
|
+
if (!SUPPORTED_MIME_TYPES.has(normalizedMimeType) || inferKind(normalizedMimeType) !== kind) {
|
|
111
|
+
throw createMediaInputError(`media[${index}] kind does not match mimeType`, {
|
|
112
|
+
reason: "invalid_media_input",
|
|
113
|
+
mediaIndex: index,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (!hasExpectedSignature(filePath, normalizedMimeType)) {
|
|
117
|
+
throw createMediaInputError(`media[${index}] content does not match mimeType`, {
|
|
118
|
+
reason: "invalid_media_input",
|
|
119
|
+
mediaIndex: index,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const itemLimit = MAX_IMAGE_BYTES;
|
|
123
|
+
if (stat.size > itemLimit) {
|
|
124
|
+
throw createMediaInputError(`media[${index}] exceeds the ${kind} size limit`, {
|
|
125
|
+
reason: "media_limit_exceeded",
|
|
126
|
+
mediaIndex: index,
|
|
127
|
+
maxBytes: itemLimit,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
totalBytes += stat.size;
|
|
131
|
+
if (totalBytes > MAX_TOTAL_MEDIA_BYTES) {
|
|
132
|
+
throw createMediaInputError("media exceeds the total size limit", {
|
|
133
|
+
reason: "media_limit_exceeded",
|
|
134
|
+
maxBytes: MAX_TOTAL_MEDIA_BYTES,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind,
|
|
139
|
+
path: filePath,
|
|
140
|
+
mimeType: normalizedMimeType,
|
|
141
|
+
name: typeof item.name === "string" && item.name.trim()
|
|
142
|
+
? path.basename(item.name.trim())
|
|
143
|
+
: path.basename(filePath),
|
|
144
|
+
sizeBytes: stat.size,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export function resolveTurnMedia(sessionOptions, turnOptions = {}) {
|
|
149
|
+
const media = normalizeMediaInputs(turnOptions.media);
|
|
150
|
+
if (!turnOptions.useInitialImages || !Array.isArray(sessionOptions?.initialImages)) {
|
|
151
|
+
return media;
|
|
152
|
+
}
|
|
153
|
+
const legacy = sessionOptions.initialImages
|
|
154
|
+
.filter((item) => typeof item === "string" && item.trim())
|
|
155
|
+
.map((filePath) => ({
|
|
156
|
+
kind: "image",
|
|
157
|
+
path: path.resolve(sessionOptions?.cwd || process.cwd(), filePath.trim()),
|
|
158
|
+
mimeType: inferMimeType(filePath.trim(), "image"),
|
|
159
|
+
}));
|
|
160
|
+
const combined = [...normalizeMediaInputs(legacy), ...media];
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
return combined.filter((item) => {
|
|
163
|
+
const key = `${item.kind}:${item.path}`;
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
return false;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export function assertMediaCapabilities(media, backend, capabilities) {
|
|
171
|
+
for (const [index, item] of media.entries()) {
|
|
172
|
+
if (capabilities?.[item.kind] !== MEDIA_CAPABILITY_NATIVE) {
|
|
173
|
+
throw createMediaInputError(`${backend} does not support ${item.kind} input`, {
|
|
174
|
+
reason: "unsupported_media",
|
|
175
|
+
backend,
|
|
176
|
+
mediaKind: item.kind,
|
|
177
|
+
mediaIndex: index,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function fileMediaToDataUri(media) {
|
|
183
|
+
return `data:${media.mimeType};base64,${fs.readFileSync(media.path).toString("base64")}`;
|
|
184
|
+
}
|
|
185
|
+
export function fileMediaToBase64(media) {
|
|
186
|
+
return fs.readFileSync(media.path).toString("base64");
|
|
187
|
+
}
|
|
188
|
+
export function createMediaInputError(message, extras = {}) {
|
|
189
|
+
const error = new Error(message);
|
|
190
|
+
for (const [key, value] of Object.entries(extras)) {
|
|
191
|
+
error[key] = value;
|
|
192
|
+
}
|
|
193
|
+
return error;
|
|
194
|
+
}
|
|
195
|
+
export function defaultPromptForMedia(media) {
|
|
196
|
+
return "Analyze the attached images.";
|
|
197
|
+
}
|
|
@@ -109,6 +109,11 @@ export class ChatWebSession extends EventEmitter<[never]> {
|
|
|
109
109
|
session_file_path: undefined;
|
|
110
110
|
updated_at: string;
|
|
111
111
|
} | null;
|
|
112
|
+
capabilities: {
|
|
113
|
+
media: Readonly<{
|
|
114
|
+
image: "unsupported";
|
|
115
|
+
}>;
|
|
116
|
+
};
|
|
112
117
|
chatWebProvider: string;
|
|
113
118
|
providerConversationId: any;
|
|
114
119
|
providerUrl: string | undefined;
|
|
@@ -173,7 +178,8 @@ export class ChatWebSession extends EventEmitter<[never]> {
|
|
|
173
178
|
* render "open in ChatGPT" / "open in AI Studio" links.
|
|
174
179
|
*/
|
|
175
180
|
providerConversationUrl(): string | undefined;
|
|
176
|
-
runTurn(promptText: any, { onProgress }?: {
|
|
181
|
+
runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress }?: {
|
|
182
|
+
useInitialImages?: boolean | undefined;
|
|
177
183
|
onProgress?: null | undefined;
|
|
178
184
|
}): Promise<{
|
|
179
185
|
text: string;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { CHAT_WEB_SESSION_VARIANT } from "../built-in-backends.js";
|
|
3
|
+
import { PROVIDER_MEDIA_CAPABILITIES } from "../media-adapters.js";
|
|
4
|
+
import { assertMediaCapabilities, resolveTurnMedia } from "../media-input.js";
|
|
5
|
+
import { normalizeContextFiles } from "../context-files.js";
|
|
3
6
|
import { emitLog, normalizeLogger } from "../shared.js";
|
|
4
7
|
const SUPPORTED_CHAT_WEB_PROVIDERS = new Set(["chatgpt", "gemini"]);
|
|
5
8
|
const DEFAULT_CHAT_WEB_PROVIDER = "chatgpt";
|
|
@@ -209,6 +212,7 @@ export class ChatWebSession extends EventEmitter {
|
|
|
209
212
|
? { ready: true, command: this.providerConversationUrl() }
|
|
210
213
|
: null,
|
|
211
214
|
currentTurnStatus: this.getCurrentTurnStatus(),
|
|
215
|
+
capabilities: { media: PROVIDER_MEDIA_CAPABILITIES[CHAT_WEB_SESSION_VARIANT] },
|
|
212
216
|
chatWebProvider: this.chatWebProvider,
|
|
213
217
|
providerConversationId: this.providerConversationId,
|
|
214
218
|
providerUrl: this.providerConversationUrl(),
|
|
@@ -401,7 +405,14 @@ export class ChatWebSession extends EventEmitter {
|
|
|
401
405
|
return undefined;
|
|
402
406
|
}
|
|
403
407
|
}
|
|
404
|
-
async runTurn(promptText, { onProgress = null } = {}) {
|
|
408
|
+
async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null } = {}) {
|
|
409
|
+
if (normalizeContextFiles(contextFiles).length > 0) {
|
|
410
|
+
const error = new Error("chat-web does not support context file input");
|
|
411
|
+
error.reason = "unsupported_context_files";
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
|
|
415
|
+
assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CHAT_WEB_SESSION_VARIANT]);
|
|
405
416
|
const prompt = String(promptText || "").trim();
|
|
406
417
|
if (!prompt) {
|
|
407
418
|
return {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
2
2
|
static capabilities: Readonly<{
|
|
3
3
|
goal: true;
|
|
4
|
+
media: Readonly<{
|
|
5
|
+
image: "native";
|
|
6
|
+
}>;
|
|
4
7
|
}>;
|
|
5
8
|
constructor(backend: any, options?: {});
|
|
6
9
|
getCapabilities(): {
|
|
7
10
|
goal: true;
|
|
11
|
+
media: Readonly<{
|
|
12
|
+
image: "native";
|
|
13
|
+
}>;
|
|
8
14
|
};
|
|
9
15
|
backend: string;
|
|
10
16
|
options: {};
|
|
@@ -68,6 +74,9 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
68
74
|
currentTurnStatus: any;
|
|
69
75
|
capabilities: {
|
|
70
76
|
goal: true;
|
|
77
|
+
media: Readonly<{
|
|
78
|
+
image: "native";
|
|
79
|
+
}>;
|
|
71
80
|
};
|
|
72
81
|
};
|
|
73
82
|
getSessionInfo(): {
|
|
@@ -131,7 +140,7 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
131
140
|
onProgress: any;
|
|
132
141
|
}): Promise<void>;
|
|
133
142
|
interruptCurrentTurn(): Promise<boolean>;
|
|
134
|
-
runTurn(promptText: any, { useInitialImages, onProgress, jsonSchema }?: {
|
|
143
|
+
runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema }?: {
|
|
135
144
|
useInitialImages?: boolean | undefined;
|
|
136
145
|
onProgress?: null | undefined;
|
|
137
146
|
jsonSchema?: null | undefined;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { CLAUDE_AGENT_SDK_VARIANT as CLAUDE_PROVIDER_VARIANT } from "../built-in-backends.js";
|
|
3
|
+
import { appendContextFilesToPrompt } from "../context-files.js";
|
|
4
|
+
import { PROVIDER_MEDIA_CAPABILITIES, buildClaudeContent } from "../media-adapters.js";
|
|
5
|
+
import { assertMediaCapabilities, defaultPromptForMedia, resolveTurnMedia, } from "../media-input.js";
|
|
3
6
|
import { emitLog, extractLongFlagFromCommandLine, getBoundedEnvInt, isGoalStatus, loadEnvConfig, normalizeLogger, proxyToEnv, sanitizeForLog, } from "../shared.js";
|
|
4
7
|
const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
|
|
5
8
|
const MIN_TURN_DEADLINE_MS = 30 * 1000;
|
|
@@ -8,6 +11,16 @@ const DEFAULT_SETTING_SOURCES = ["user", "project", "local"];
|
|
|
8
11
|
function waitForever() {
|
|
9
12
|
return new Promise(() => { });
|
|
10
13
|
}
|
|
14
|
+
async function* buildClaudeInput(promptText, media) {
|
|
15
|
+
yield {
|
|
16
|
+
type: "user",
|
|
17
|
+
message: {
|
|
18
|
+
role: "user",
|
|
19
|
+
content: buildClaudeContent(promptText, media),
|
|
20
|
+
},
|
|
21
|
+
parent_tool_use_id: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
11
24
|
function createTurnError(message, extras = {}) {
|
|
12
25
|
const error = new Error(message);
|
|
13
26
|
for (const [key, value] of Object.entries(extras)) {
|
|
@@ -129,7 +142,10 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
129
142
|
// Capability advertised via getSnapshot().capabilities so worker proxies
|
|
130
143
|
// can short-circuit runGoal without an IPC round trip. Claude exposes
|
|
131
144
|
// native `/goal` slash command, so this is true.
|
|
132
|
-
static capabilities = Object.freeze({
|
|
145
|
+
static capabilities = Object.freeze({
|
|
146
|
+
goal: true,
|
|
147
|
+
media: PROVIDER_MEDIA_CAPABILITIES[CLAUDE_PROVIDER_VARIANT],
|
|
148
|
+
});
|
|
133
149
|
getCapabilities() {
|
|
134
150
|
return { ...ClaudeAgentSdkSession.capabilities };
|
|
135
151
|
}
|
|
@@ -722,11 +738,15 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
722
738
|
}
|
|
723
739
|
return true;
|
|
724
740
|
}
|
|
725
|
-
async runTurn(promptText, { useInitialImages = false, onProgress = null, jsonSchema = null } = {}) {
|
|
741
|
+
async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null } = {}) {
|
|
726
742
|
if (this.closeRequested) {
|
|
727
743
|
throw this.createSessionClosedError();
|
|
728
744
|
}
|
|
729
|
-
const
|
|
745
|
+
const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
|
|
746
|
+
assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CLAUDE_PROVIDER_VARIANT]);
|
|
747
|
+
let effectivePrompt = this.buildPrompt(promptText, { useInitialImages: false }) ||
|
|
748
|
+
(media.length ? defaultPromptForMedia(media) : "");
|
|
749
|
+
effectivePrompt = appendContextFilesToPrompt(effectivePrompt, contextFiles).prompt;
|
|
730
750
|
if (!effectivePrompt) {
|
|
731
751
|
return {
|
|
732
752
|
text: "",
|
|
@@ -776,7 +796,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
776
796
|
status_line: "claude is working",
|
|
777
797
|
}, onProgress);
|
|
778
798
|
const query = sdkModule.query({
|
|
779
|
-
prompt: effectivePrompt,
|
|
799
|
+
prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
|
|
780
800
|
options: this.buildSdkOptions(abortController),
|
|
781
801
|
});
|
|
782
802
|
currentTurn.query = query;
|
|
@@ -53,6 +53,7 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
|
|
|
53
53
|
} | null;
|
|
54
54
|
bootPromise: Promise<void> | null;
|
|
55
55
|
booted: boolean;
|
|
56
|
+
_contextRecoveryInFlight: boolean;
|
|
56
57
|
goalMode: boolean;
|
|
57
58
|
currentGoal: (import("../shared.js").GoalState & {
|
|
58
59
|
raw?: unknown;
|
|
@@ -127,6 +128,22 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
|
|
|
127
128
|
}): void;
|
|
128
129
|
applySessionConfigured(params: any): void;
|
|
129
130
|
resolveContextUsagePercent(): number | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Whether a turn failure was caused by the provider thread outgrowing the
|
|
133
|
+
* model context window. Such failures are recoverable by rolling onto a fresh
|
|
134
|
+
* thread; ordinary failures (timeouts, closes, tool errors) are not.
|
|
135
|
+
*
|
|
136
|
+
* @param {any} error
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
isRecoverableContextOverflow(error: any): boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Reset thread-bound state so the next {@link boot} starts a brand-new Codex
|
|
142
|
+
* thread, seeded with the most-recent {@link RECOVERY_HISTORY_LIMIT} history
|
|
143
|
+
* entries as a prompt preface. The oversized thread is abandoned (never
|
|
144
|
+
* resumed) so its context can no longer overflow.
|
|
145
|
+
*/
|
|
146
|
+
rollOntoFreshThread(): void;
|
|
130
147
|
normalizeWorkingStatusPayload(payload: any): {
|
|
131
148
|
source: string;
|
|
132
149
|
reply_in_progress: boolean;
|
|
@@ -223,29 +240,10 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
|
|
|
223
240
|
handleTransportExit(payload: any): void;
|
|
224
241
|
maybeEmitAuthRequired(error: any): void;
|
|
225
242
|
interruptCurrentTurn(): Promise<boolean>;
|
|
226
|
-
runTurn(promptText: any, { useInitialImages, jsonSchema }?: {
|
|
243
|
+
runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, jsonSchema }?: {
|
|
227
244
|
useInitialImages?: boolean | undefined;
|
|
228
245
|
jsonSchema?: null | undefined;
|
|
229
|
-
}):
|
|
230
|
-
text: string;
|
|
231
|
-
usage: null;
|
|
232
|
-
items: never[];
|
|
233
|
-
events: never[];
|
|
234
|
-
provider?: undefined;
|
|
235
|
-
metadata?: undefined;
|
|
236
|
-
} | {
|
|
237
|
-
text: string;
|
|
238
|
-
usage: any;
|
|
239
|
-
items: any;
|
|
240
|
-
events: never[];
|
|
241
|
-
provider: string;
|
|
242
|
-
metadata: {
|
|
243
|
-
source: string;
|
|
244
|
-
threadId: any;
|
|
245
|
-
threadPath: string | undefined;
|
|
246
|
-
nativeSessionId: string | undefined;
|
|
247
|
-
};
|
|
248
|
-
}>;
|
|
246
|
+
}): any;
|
|
249
247
|
/**
|
|
250
248
|
* Normalize a goal payload from a codex `thread/goal/*` notification into
|
|
251
249
|
* the cross-layer {@link GoalState} shape.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { CODEX_APP_SERVER_VARIANT } from "../built-in-backends.js";
|
|
3
|
+
import { appendContextFilesToPrompt } from "../context-files.js";
|
|
4
|
+
import { PROVIDER_MEDIA_CAPABILITIES, buildCodexAppServerInput } from "../media-adapters.js";
|
|
5
|
+
import { assertMediaCapabilities, defaultPromptForMedia, resolveTurnMedia, } from "../media-input.js";
|
|
3
6
|
import { CodexAppServerTransport } from "../transports/codex-app-server-transport.js";
|
|
4
7
|
import { TERMINAL_GOAL_STATUSES, emitLog, getBoundedEnvInt, loadEnvConfig, normalizeLogger, proxyToEnv, sanitizeForLog, } from "../shared.js";
|
|
5
8
|
const TERMINAL_GOAL_STATUSES_LOCAL = new Set(TERMINAL_GOAL_STATUSES);
|
|
@@ -9,6 +12,15 @@ function isTerminalGoalStatusLocal(status) {
|
|
|
9
12
|
const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
|
|
10
13
|
const MIN_TURN_DEADLINE_MS = 30 * 1000;
|
|
11
14
|
const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
|
|
15
|
+
// Oversized-thread recovery: when a Codex turn fails because the provider
|
|
16
|
+
// thread outgrew the model context window (and remote compaction could not
|
|
17
|
+
// recover it), roll the same logical chat onto a fresh thread seeded with this
|
|
18
|
+
// many most-recent history entries and retry the prompt once.
|
|
19
|
+
const RECOVERY_HISTORY_LIMIT = 12;
|
|
20
|
+
// Matches the various ways Codex / the underlying model report a context-window
|
|
21
|
+
// overflow. Kept broad but anchored on "context"/"token" wording so ordinary
|
|
22
|
+
// failures are not misclassified as recoverable.
|
|
23
|
+
const CONTEXT_OVERFLOW_PATTERN = /context[ _-]?(?:length|window|limit)|context[ _-]?length[ _-]?exceeded|maximum[ _-]?context|exceed(?:s|ed)?[^.]{0,40}context|too many tokens|token[ _-]?limit|input[^.]{0,20}too long|prompt[^.]{0,20}too long/i;
|
|
12
24
|
function waitForever() {
|
|
13
25
|
return new Promise(() => { });
|
|
14
26
|
}
|
|
@@ -35,14 +47,6 @@ function extractSessionConfiguredValue(params, ...keys) {
|
|
|
35
47
|
}
|
|
36
48
|
return undefined;
|
|
37
49
|
}
|
|
38
|
-
function buildTurnInput(promptText) {
|
|
39
|
-
return [
|
|
40
|
-
{
|
|
41
|
-
type: "text",
|
|
42
|
-
text: promptText,
|
|
43
|
-
},
|
|
44
|
-
];
|
|
45
|
-
}
|
|
46
50
|
function injectJsonSchemaPrompt(promptText, jsonSchema) {
|
|
47
51
|
const schemaText = typeof jsonSchema === "string" ? jsonSchema : JSON.stringify(jsonSchema, null, 2);
|
|
48
52
|
return `You must respond with valid JSON that strictly conforms to the following JSON Schema. Do not include any markdown formatting or explanation outside the JSON object.
|
|
@@ -179,7 +183,10 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
179
183
|
* Treat as read-only.
|
|
180
184
|
* @type {Readonly<import("../shared.js").SessionCapabilities>}
|
|
181
185
|
*/
|
|
182
|
-
static capabilities = Object.freeze({
|
|
186
|
+
static capabilities = Object.freeze({
|
|
187
|
+
goal: true,
|
|
188
|
+
media: PROVIDER_MEDIA_CAPABILITIES[CODEX_APP_SERVER_VARIANT],
|
|
189
|
+
});
|
|
183
190
|
/**
|
|
184
191
|
* @returns {import("../shared.js").SessionCapabilities}
|
|
185
192
|
*/
|
|
@@ -219,6 +226,7 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
219
226
|
this.currentTurn = null;
|
|
220
227
|
this.bootPromise = null;
|
|
221
228
|
this.booted = false;
|
|
229
|
+
this._contextRecoveryInFlight = false;
|
|
222
230
|
this.goalMode = options.goalMode === true;
|
|
223
231
|
this.currentGoal = null;
|
|
224
232
|
// Goal-mode lifecycle is decoupled from `currentTurn` so that goal events
|
|
@@ -467,6 +475,61 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
467
475
|
}
|
|
468
476
|
return Math.max(0, Math.min(100, Math.round((totalTokens / contextWindow) * 100)));
|
|
469
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Whether a turn failure was caused by the provider thread outgrowing the
|
|
480
|
+
* model context window. Such failures are recoverable by rolling onto a fresh
|
|
481
|
+
* thread; ordinary failures (timeouts, closes, tool errors) are not.
|
|
482
|
+
*
|
|
483
|
+
* @param {any} error
|
|
484
|
+
* @returns {boolean}
|
|
485
|
+
*/
|
|
486
|
+
isRecoverableContextOverflow(error) {
|
|
487
|
+
if (!error || error.reason === "session_closed" || error.reason === "turn_timeout") {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const parts = [];
|
|
491
|
+
if (typeof error.message === "string")
|
|
492
|
+
parts.push(error.message);
|
|
493
|
+
if (typeof error.turnStatus === "string")
|
|
494
|
+
parts.push(error.turnStatus);
|
|
495
|
+
const turnError = error.turnError;
|
|
496
|
+
if (typeof turnError === "string") {
|
|
497
|
+
parts.push(turnError);
|
|
498
|
+
}
|
|
499
|
+
else if (turnError && typeof turnError === "object") {
|
|
500
|
+
for (const key of ["message", "code", "type", "reason"]) {
|
|
501
|
+
if (typeof turnError[key] === "string")
|
|
502
|
+
parts.push(turnError[key]);
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
parts.push(JSON.stringify(turnError));
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// Ignore non-serializable provider payloads.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return CONTEXT_OVERFLOW_PATTERN.test(parts.join(" | "));
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Reset thread-bound state so the next {@link boot} starts a brand-new Codex
|
|
515
|
+
* thread, seeded with the most-recent {@link RECOVERY_HISTORY_LIMIT} history
|
|
516
|
+
* entries as a prompt preface. The oversized thread is abandoned (never
|
|
517
|
+
* resumed) so its context can no longer overflow.
|
|
518
|
+
*/
|
|
519
|
+
rollOntoFreshThread() {
|
|
520
|
+
const recent = this.history.slice(-RECOVERY_HISTORY_LIMIT);
|
|
521
|
+
this.history = recent;
|
|
522
|
+
this.pendingHistorySeed = recent.length > 0;
|
|
523
|
+
this.sessionId = "";
|
|
524
|
+
this.threadPath = "";
|
|
525
|
+
this.nativeSessionId = "";
|
|
526
|
+
this.resumeSessionId = "";
|
|
527
|
+
this.manualResumeReady = false;
|
|
528
|
+
this.sessionInfo = null;
|
|
529
|
+
this.tokenUsage = null;
|
|
530
|
+
this.booted = false;
|
|
531
|
+
this.bootPromise = null;
|
|
532
|
+
}
|
|
470
533
|
normalizeWorkingStatusPayload(payload) {
|
|
471
534
|
return {
|
|
472
535
|
source: CODEX_APP_SERVER_VARIANT,
|
|
@@ -1029,11 +1092,15 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
1029
1092
|
}
|
|
1030
1093
|
return false;
|
|
1031
1094
|
}
|
|
1032
|
-
async runTurn(promptText, { useInitialImages = false, jsonSchema = null } = {}) {
|
|
1095
|
+
async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, jsonSchema = null } = {}) {
|
|
1033
1096
|
if (this.closeRequested) {
|
|
1034
1097
|
throw this.createSessionClosedError();
|
|
1035
1098
|
}
|
|
1036
|
-
|
|
1099
|
+
const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
|
|
1100
|
+
assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CODEX_APP_SERVER_VARIANT]);
|
|
1101
|
+
let effectivePrompt = this.buildPrompt(promptText, { useInitialImages: false }) ||
|
|
1102
|
+
(media.length ? defaultPromptForMedia(media) : "");
|
|
1103
|
+
effectivePrompt = appendContextFilesToPrompt(effectivePrompt, contextFiles).prompt;
|
|
1037
1104
|
if (jsonSchema && typeof jsonSchema === "object" && effectivePrompt) {
|
|
1038
1105
|
effectivePrompt = injectJsonSchemaPrompt(effectivePrompt, jsonSchema);
|
|
1039
1106
|
}
|
|
@@ -1081,7 +1148,7 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
1081
1148
|
threadId: this.sessionId,
|
|
1082
1149
|
cwd: this.cwd,
|
|
1083
1150
|
approvalPolicy: "never",
|
|
1084
|
-
input:
|
|
1151
|
+
input: buildCodexAppServerInput(effectivePrompt, media),
|
|
1085
1152
|
});
|
|
1086
1153
|
const turnResult = await Promise.race([
|
|
1087
1154
|
(async () => {
|
|
@@ -1123,6 +1190,51 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
1123
1190
|
if (error?.reason === "turn_timeout") {
|
|
1124
1191
|
await this.interruptCurrentTurn();
|
|
1125
1192
|
}
|
|
1193
|
+
// Oversized-thread recovery: the provider thread outgrew the context
|
|
1194
|
+
// window and remote compaction could not save it. Roll the same logical
|
|
1195
|
+
// chat onto a fresh thread seeded with bounded recent history and retry
|
|
1196
|
+
// the prompt exactly once, rather than surfacing a dead turn. The
|
|
1197
|
+
// in-flight guard ensures a single retry: if the fresh thread also
|
|
1198
|
+
// overflows, the recursive call skips recovery and fails normally.
|
|
1199
|
+
if (!this._contextRecoveryInFlight &&
|
|
1200
|
+
!this.closeRequested &&
|
|
1201
|
+
!currentTurn.goalMode &&
|
|
1202
|
+
this.isRecoverableContextOverflow(error)) {
|
|
1203
|
+
// Drop the user message pushed for the failed attempt; the retry
|
|
1204
|
+
// re-adds it (and folds prior history in as a preface via buildPrompt).
|
|
1205
|
+
if (this.history.length > 0 &&
|
|
1206
|
+
String(this.history[this.history.length - 1]?.role || "").toLowerCase() === "user") {
|
|
1207
|
+
this.history.pop();
|
|
1208
|
+
}
|
|
1209
|
+
if (this.currentTurn === currentTurn) {
|
|
1210
|
+
this.currentTurn = null;
|
|
1211
|
+
}
|
|
1212
|
+
this._contextRecoveryInFlight = true;
|
|
1213
|
+
// Release the outer turn's guards before recursing so their timers do
|
|
1214
|
+
// not linger for the duration of the retry. Both cleanups are
|
|
1215
|
+
// idempotent; the outer `finally` calls them again harmlessly.
|
|
1216
|
+
closeGuard.cleanup();
|
|
1217
|
+
turnTimeoutGuard.cleanup();
|
|
1218
|
+
try {
|
|
1219
|
+
this.rollOntoFreshThread();
|
|
1220
|
+
this.trace("context overflow; rolling onto a fresh thread and retrying the turn");
|
|
1221
|
+
await this.emitWorkingStatus({
|
|
1222
|
+
phase: "working",
|
|
1223
|
+
reply_in_progress: true,
|
|
1224
|
+
status_line: "codex context full; retrying on a fresh thread",
|
|
1225
|
+
});
|
|
1226
|
+
this.emit("thread_recovered", { reason: "context_overflow", backend: this.backend });
|
|
1227
|
+
return await this.runTurn(promptText, {
|
|
1228
|
+
useInitialImages,
|
|
1229
|
+
media: mediaInput,
|
|
1230
|
+
contextFiles,
|
|
1231
|
+
jsonSchema,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
finally {
|
|
1235
|
+
this._contextRecoveryInFlight = false;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1126
1238
|
if (!this.closeRequested && error?.reason !== "session_closed") {
|
|
1127
1239
|
await this.emitWorkingStatus({
|
|
1128
1240
|
phase: "turn_failed",
|
|
@@ -52,6 +52,11 @@ export class CodexExecSession extends EventEmitter<[never]> {
|
|
|
52
52
|
resumeReady: boolean;
|
|
53
53
|
manualResume: null;
|
|
54
54
|
currentTurnStatus: any;
|
|
55
|
+
capabilities: {
|
|
56
|
+
media: Readonly<{
|
|
57
|
+
image: "native";
|
|
58
|
+
}>;
|
|
59
|
+
};
|
|
55
60
|
pid: any;
|
|
56
61
|
};
|
|
57
62
|
getSessionInfo(): {
|
|
@@ -85,13 +90,13 @@ export class CodexExecSession extends EventEmitter<[never]> {
|
|
|
85
90
|
emitWorkingStatus(payload: any, onProgress?: null): Promise<any>;
|
|
86
91
|
emitAssistantMessage(text: any): Promise<void>;
|
|
87
92
|
buildPrompt(promptText: any): string;
|
|
88
|
-
buildExecArgs({
|
|
89
|
-
|
|
93
|
+
buildExecArgs({ media, schemaFilePath, lastMessageFilePath }?: {
|
|
94
|
+
media?: never[] | undefined;
|
|
90
95
|
schemaFilePath?: string | undefined;
|
|
91
96
|
lastMessageFilePath?: string | undefined;
|
|
92
97
|
}): string[];
|
|
93
98
|
maybeEmitAuthRequired(stderrTail: any): void;
|
|
94
|
-
runTurn(promptText: any, { useInitialImages, onProgress, jsonSchema }?: {
|
|
99
|
+
runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema }?: {
|
|
95
100
|
useInitialImages?: boolean | undefined;
|
|
96
101
|
onProgress?: null | undefined;
|
|
97
102
|
jsonSchema?: null | undefined;
|