@love-moon/ai-sdk 0.9.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @love-moon/ai-sdk
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43c4f87: Add the `codex-app-server-session` provider, recover Codex oversized threads by
8
+ rolling onto a fresh provider thread with bounded recent history (one retry per
9
+ turn), and trim the dsh quota raw payload.
10
+
3
11
  ## 0.9.0
4
12
 
5
13
  ### Minor Changes
@@ -110,6 +110,10 @@ export async function getDshQuota(options = {}) {
110
110
  }
111
111
  const payload = (await res.json());
112
112
  const { isAvailable, balances } = parseDshBalance(payload);
113
+ // No `raw` field: unlike the window-based providers whose parsing is
114
+ // lossy, `balances`/`isAvailable` already capture the whole balance
115
+ // response, so keeping the raw payload would only bloat the cache and the
116
+ // worker/WebSocket hop to the web client.
113
117
  const quota = {
114
118
  tool: "dsh",
115
119
  isAvailable,
@@ -117,7 +121,6 @@ export async function getDshQuota(options = {}) {
117
121
  balances,
118
122
  fetchedAt: Math.floor(Date.now() / 1000),
119
123
  source: "fresh",
120
- raw: payload,
121
124
  };
122
125
  const entry = await writeCache(file, quota);
123
126
  return { ...quota, fetchedAt: entry.fetchedAt };
@@ -128,7 +128,6 @@ export interface DshQuota {
128
128
  balances: DshBalanceInfo[];
129
129
  fetchedAt: number;
130
130
  source: QuotaSource;
131
- raw?: Record<string, unknown>;
132
131
  error?: string;
133
132
  }
134
133
  export interface ExternalQuota {
@@ -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;
@@ -226,26 +243,7 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
226
243
  runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, jsonSchema }?: {
227
244
  useInitialImages?: boolean | undefined;
228
245
  jsonSchema?: null | undefined;
229
- }): Promise<{
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.
@@ -12,6 +12,15 @@ function isTerminalGoalStatusLocal(status) {
12
12
  const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
13
13
  const MIN_TURN_DEADLINE_MS = 30 * 1000;
14
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;
15
24
  function waitForever() {
16
25
  return new Promise(() => { });
17
26
  }
@@ -217,6 +226,7 @@ export class CodexAppServerSession extends EventEmitter {
217
226
  this.currentTurn = null;
218
227
  this.bootPromise = null;
219
228
  this.booted = false;
229
+ this._contextRecoveryInFlight = false;
220
230
  this.goalMode = options.goalMode === true;
221
231
  this.currentGoal = null;
222
232
  // Goal-mode lifecycle is decoupled from `currentTurn` so that goal events
@@ -465,6 +475,61 @@ export class CodexAppServerSession extends EventEmitter {
465
475
  }
466
476
  return Math.max(0, Math.min(100, Math.round((totalTokens / contextWindow) * 100)));
467
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
+ }
468
533
  normalizeWorkingStatusPayload(payload) {
469
534
  return {
470
535
  source: CODEX_APP_SERVER_VARIANT,
@@ -1125,6 +1190,51 @@ export class CodexAppServerSession extends EventEmitter {
1125
1190
  if (error?.reason === "turn_timeout") {
1126
1191
  await this.interruptCurrentTurn();
1127
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
+ }
1128
1238
  if (!this.closeRequested && error?.reason !== "session_closed") {
1129
1239
  await this.emitWorkingStatus({
1130
1240
  phase: "turn_failed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/ai-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -53,12 +53,12 @@
53
53
  "zod": "^4.1.5"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@love-moon/chat-web": "^0.9.0"
56
+ "@love-moon/chat-web": "^0.10.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^22.10.2",
60
60
  "tsx": "^4.20.6",
61
61
  "typescript": "^5.6.3"
62
62
  },
63
- "gitCommitId": "606e5a6"
63
+ "gitCommitId": "bc7b3a5"
64
64
  }