@love-moon/ai-sdk 0.13.1 → 0.13.2

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.
@@ -8,6 +8,11 @@ import { emitLog, getBoundedEnvInt, loadEnvConfig, normalizeLogger, noteToolFini
8
8
  const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
9
9
  const MIN_TURN_DEADLINE_MS = 30 * 1000;
10
10
  const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
11
+ const SUMMARY_EVENT_GRACE_MS = 2000;
12
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ function isHeadersTimeoutError(error) {
14
+ return [error?.code, error?.cause?.code].includes("UND_ERR_HEADERS_TIMEOUT");
15
+ }
11
16
  function waitForever() {
12
17
  return new Promise(() => { });
13
18
  }
@@ -226,7 +231,7 @@ export class OpencodeSdkSession extends EventEmitter {
226
231
  resumeReady: Boolean(this.sessionId),
227
232
  manualResume: null,
228
233
  currentTurnStatus: this.getCurrentTurnStatus(),
229
- capabilities: { media: PROVIDER_MEDIA_CAPABILITIES[OPENCODE_PROVIDER_VARIANT] },
234
+ capabilities: { compact: true, media: PROVIDER_MEDIA_CAPABILITIES[OPENCODE_PROVIDER_VARIANT] },
230
235
  pid: this.transport.pid || undefined,
231
236
  };
232
237
  }
@@ -729,7 +734,9 @@ export class OpencodeSdkSession extends EventEmitter {
729
734
  phase: "message_aggregation",
730
735
  reply_in_progress: true,
731
736
  status_line: statusLineForPhase("message_aggregation"),
732
- reply_preview: sanitizeSummary(this.collectAssistantText(messageState), 120),
737
+ reply_preview: currentTurn.suppressReply || messageState.info?.summary === true
738
+ ? undefined
739
+ : sanitizeSummary(this.collectAssistantText(messageState), 120),
733
740
  }, onProgress);
734
741
  return;
735
742
  }
@@ -797,7 +804,9 @@ export class OpencodeSdkSession extends EventEmitter {
797
804
  phase: "message_aggregation",
798
805
  reply_in_progress: true,
799
806
  status_line: statusLineForPhase("message_aggregation"),
800
- reply_preview: sanitizeSummary(this.collectAssistantText(messageState), 120),
807
+ reply_preview: currentTurn.suppressReply || messageState.info?.summary === true
808
+ ? undefined
809
+ : sanitizeSummary(this.collectAssistantText(messageState), 120),
801
810
  }, onProgress);
802
811
  return;
803
812
  }
@@ -838,7 +847,8 @@ export class OpencodeSdkSession extends EventEmitter {
838
847
  if (currentTurn.activeAssistantMessageId === targetId) {
839
848
  currentTurn.activeAssistantMessageId = "";
840
849
  }
841
- if (!text) {
850
+ // Compaction summaries (manual or automatic) are context, not a reply.
851
+ if (!text || currentTurn.suppressReply || messageState.info?.summary === true) {
842
852
  return false;
843
853
  }
844
854
  currentTurn.fullText += text;
@@ -1146,6 +1156,160 @@ export class OpencodeSdkSession extends EventEmitter {
1146
1156
  }
1147
1157
  return true;
1148
1158
  }
1159
+ createTurnState(abortController, onProgress) {
1160
+ const currentTurn = {
1161
+ abortController,
1162
+ assistantMessages: new Map(),
1163
+ assistantMessageOrder: [],
1164
+ activeAssistantMessageId: "",
1165
+ messageRoles: new Map(),
1166
+ pendingMessageEvents: new Map(),
1167
+ fullText: "",
1168
+ items: [],
1169
+ lastAssistantInfo: null,
1170
+ onProgress,
1171
+ resolve: null,
1172
+ reject: null,
1173
+ settled: false,
1174
+ terminalWorkingStatusEmitted: false,
1175
+ };
1176
+ const completionPromise = new Promise((resolve, reject) => {
1177
+ currentTurn.resolve = resolve;
1178
+ currentTurn.reject = reject;
1179
+ });
1180
+ return { currentTurn, completionPromise };
1181
+ }
1182
+ /**
1183
+ * Model to summarize with: `session.summarize` requires one explicitly.
1184
+ * Prefer what the session actually ran on (last turn, then its recent
1185
+ * messages); the configured model is only a fallback.
1186
+ */
1187
+ async resolveCompactionModel() {
1188
+ const fromInfo = (info) => {
1189
+ const providerID = info?.providerID || info?.model?.providerID;
1190
+ const modelID = info?.modelID || info?.model?.modelID;
1191
+ return typeof providerID === "string" && providerID && typeof modelID === "string" && modelID
1192
+ ? { providerID, modelID }
1193
+ : null;
1194
+ };
1195
+ const lastModel = fromInfo(this.lastAssistantInfo);
1196
+ if (lastModel) {
1197
+ return lastModel;
1198
+ }
1199
+ if (typeof this.client?.session?.messages === "function") {
1200
+ const messages = await this.requestOrThrow(this.client.session.messages({ sessionID: this.sessionId, limit: 20 }, { throwOnError: true, responseStyle: "data" })).catch((error) => {
1201
+ this.trace(`compaction model lookup failed: ${extractErrorMessage(error)}`);
1202
+ return [];
1203
+ });
1204
+ for (const entry of [...(Array.isArray(messages) ? messages : [])].reverse()) {
1205
+ const model = fromInfo(entry?.info);
1206
+ if (model) {
1207
+ return model;
1208
+ }
1209
+ }
1210
+ }
1211
+ const configured = typeof this.options.model === "string" ? this.options.model.trim() : "";
1212
+ const slash = configured.indexOf("/");
1213
+ if (slash > 0 && slash < configured.length - 1) {
1214
+ return { providerID: configured.slice(0, slash), modelID: configured.slice(slash + 1) };
1215
+ }
1216
+ throw createTurnError("Opencode compaction needs a model, but none could be resolved for this session", {
1217
+ reason: "compact_failed",
1218
+ });
1219
+ }
1220
+ /**
1221
+ * Manually compact via the server's `session.summarize` (what the TUI's
1222
+ * `/compact` calls). It takes no focus instructions and answers `true`
1223
+ * even when summarizing fails, so success is judged from the summary
1224
+ * message itself.
1225
+ *
1226
+ * @param {import("../shared.js").CompactRequest} [request]
1227
+ * @param {{ onProgress?: Function }} [options]
1228
+ * @returns {Promise<import("../shared.js").CompactResult>}
1229
+ */
1230
+ async runCompact(request = {}, { onProgress = null } = {}) {
1231
+ if (this.closeRequested) {
1232
+ throw this.createSessionClosedError();
1233
+ }
1234
+ if (this.currentTurn) {
1235
+ throw createTurnError("Opencode turn already running", {
1236
+ reason: "turn_already_running",
1237
+ });
1238
+ }
1239
+ if (this.pendingHistorySeed || (!this.resumeSessionId && this.history.length === 0)) {
1240
+ return { compact: { status: "noop", instructionsApplied: false }, usage: null, metadata: {} };
1241
+ }
1242
+ this.markTurnStartedStatus();
1243
+ let model;
1244
+ try {
1245
+ await this.boot();
1246
+ if (typeof this.client?.session?.summarize !== "function") {
1247
+ throw new Error("Opencode summarize API is unavailable");
1248
+ }
1249
+ model = await this.resolveCompactionModel();
1250
+ }
1251
+ catch (error) {
1252
+ await this.failPendingTurnStart(error, onProgress);
1253
+ throw error;
1254
+ }
1255
+ const abortController = new AbortController();
1256
+ const { currentTurn, completionPromise } = this.createTurnState(abortController, onProgress);
1257
+ currentTurn.suppressReply = true;
1258
+ this.currentTurn = currentTurn;
1259
+ const closeGuard = this.createCloseGuard(() => {
1260
+ abortController.abort();
1261
+ void this.interruptCurrentTurn();
1262
+ });
1263
+ const turnTimeoutGuard = this.createTurnTimeoutGuard(() => {
1264
+ abortController.abort();
1265
+ void this.interruptCurrentTurn();
1266
+ });
1267
+ try {
1268
+ await this.emitWorkingStatus({
1269
+ phase: "context_compaction",
1270
+ reply_in_progress: true,
1271
+ status_line: statusLineForPhase("context_compaction"),
1272
+ }, onProgress);
1273
+ // The response only arrives once summarizing finishes, which can outlive
1274
+ // fetch's header timeout; `session.idle` marks completion either way.
1275
+ const summarize = this.requestOrThrow(this.client.session.summarize({ sessionID: this.sessionId, ...model, auto: false }, { throwOnError: true, responseStyle: "data", signal: abortController.signal })).catch((error) => (isHeadersTimeoutError(error) ? completionPromise : Promise.reject(error)));
1276
+ await Promise.race([Promise.race([summarize, completionPromise]), closeGuard.promise, turnTimeoutGuard.promise]);
1277
+ // Events trail the HTTP response on a separate stream; let idle land.
1278
+ await Promise.race([completionPromise, delay(SUMMARY_EVENT_GRACE_MS)]);
1279
+ const summaryInfo = currentTurn.lastAssistantInfo?.summary === true ? currentTurn.lastAssistantInfo : null;
1280
+ if (summaryInfo?.error || summaryInfo?.finish === "error") {
1281
+ throw createTurnError(`Opencode compaction failed: ${extractErrorMessage(summaryInfo.error?.data || summaryInfo.error) || "unknown error"}`, { reason: "compact_failed" });
1282
+ }
1283
+ await this.emitTerminalWorkingStatus(currentTurn, { phase: "turn_completed", status_done_line: "opencode compacted context" }, onProgress);
1284
+ return {
1285
+ // An empty session never gets here, so a summary whose events are
1286
+ // still in flight counts as compacted.
1287
+ compact: { status: "compacted", instructionsApplied: false },
1288
+ usage: this.buildUsageFromAssistantInfo(summaryInfo),
1289
+ metadata: { source: OPENCODE_PROVIDER_VARIANT, sessionId: this.sessionId || undefined, ...model },
1290
+ };
1291
+ }
1292
+ catch (error) {
1293
+ if (error?.reason === "turn_timeout") {
1294
+ await this.interruptCurrentTurn();
1295
+ }
1296
+ if (!this.closeRequested && error?.reason !== "session_closed") {
1297
+ await this.emitTerminalWorkingStatus(currentTurn, { phase: "turn_failed", status_done_line: extractErrorMessage(error) }, onProgress);
1298
+ }
1299
+ if (this.closeRequested && error?.reason !== "session_closed") {
1300
+ throw this.createSessionClosedError();
1301
+ }
1302
+ throw error;
1303
+ }
1304
+ finally {
1305
+ this.activeReplyTarget = "";
1306
+ if (this.currentTurn === currentTurn) {
1307
+ this.currentTurn = null;
1308
+ }
1309
+ closeGuard.cleanup();
1310
+ turnTimeoutGuard.cleanup();
1311
+ }
1312
+ }
1149
1313
  async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null } = {}) {
1150
1314
  if (this.closeRequested) {
1151
1315
  throw this.createSessionClosedError();
@@ -1175,26 +1339,7 @@ export class OpencodeSdkSession extends EventEmitter {
1175
1339
  }
1176
1340
  this.history.push({ role: "user", content: promptText });
1177
1341
  const abortController = new AbortController();
1178
- const currentTurn = {
1179
- abortController,
1180
- assistantMessages: new Map(),
1181
- assistantMessageOrder: [],
1182
- activeAssistantMessageId: "",
1183
- messageRoles: new Map(),
1184
- pendingMessageEvents: new Map(),
1185
- fullText: "",
1186
- items: [],
1187
- lastAssistantInfo: null,
1188
- onProgress,
1189
- resolve: null,
1190
- reject: null,
1191
- settled: false,
1192
- terminalWorkingStatusEmitted: false,
1193
- };
1194
- const completionPromise = new Promise((resolve, reject) => {
1195
- currentTurn.resolve = resolve;
1196
- currentTurn.reject = reject;
1197
- });
1342
+ const { currentTurn, completionPromise } = this.createTurnState(abortController, onProgress);
1198
1343
  this.currentTurn = currentTurn;
1199
1344
  const closeGuard = this.createCloseGuard(() => {
1200
1345
  abortController.abort();
package/dist/shared.d.ts CHANGED
@@ -99,6 +99,30 @@ export const TERMINAL_GOAL_STATUSES: ReadonlyArray<GoalStatus>;
99
99
  *
100
100
  * @typedef {Object} SessionCapabilities
101
101
  * @property {boolean} [goal]
102
+ * @property {boolean} [compact]
103
+ */
104
+ /**
105
+ * Manual context compaction (`/compact`) contract for providers that
106
+ * implement the optional `runCompact` method and advertise
107
+ * `capabilities.compact === true`. Providers must NOT emit assistant messages
108
+ * while compacting; the caller renders the user-facing confirmation.
109
+ *
110
+ * @typedef {Object} CompactRequest
111
+ * @property {string} [instructions] Optional focus instructions; providers
112
+ * whose backend cannot take them ignore them and report
113
+ * `instructionsApplied: false`.
114
+ *
115
+ * @typedef {Object} CompactState
116
+ * @property {"compacted"|"noop"} status `noop` = nothing to compact.
117
+ * @property {boolean} instructionsApplied
118
+ * @property {number} [preTokens]
119
+ * @property {number} [postTokens]
120
+ * @property {number} [tokensRemoved]
121
+ *
122
+ * @typedef {Object} CompactResult
123
+ * @property {CompactState} compact
124
+ * @property {unknown} [usage]
125
+ * @property {Record<string, unknown>} [metadata]
102
126
  */
103
127
  /**
104
128
  * Default capabilities assigned to providers that do not opt into any optional
@@ -187,4 +211,46 @@ export type GoalResult = {
187
211
  */
188
212
  export type SessionCapabilities = {
189
213
  goal?: boolean | undefined;
214
+ compact?: boolean | undefined;
215
+ };
216
+ /**
217
+ * Manual context compaction (`/compact`) contract for providers that
218
+ * implement the optional `runCompact` method and advertise
219
+ * `capabilities.compact === true`. Providers must NOT emit assistant messages
220
+ * while compacting; the caller renders the user-facing confirmation.
221
+ */
222
+ export type CompactRequest = {
223
+ /**
224
+ * Optional focus instructions; providers
225
+ * whose backend cannot take them ignore them and report
226
+ * `instructionsApplied: false`.
227
+ */
228
+ instructions?: string | undefined;
229
+ };
230
+ /**
231
+ * Manual context compaction (`/compact`) contract for providers that
232
+ * implement the optional `runCompact` method and advertise
233
+ * `capabilities.compact === true`. Providers must NOT emit assistant messages
234
+ * while compacting; the caller renders the user-facing confirmation.
235
+ */
236
+ export type CompactState = {
237
+ /**
238
+ * `noop` = nothing to compact.
239
+ */
240
+ status: "compacted" | "noop";
241
+ instructionsApplied: boolean;
242
+ preTokens?: number | undefined;
243
+ postTokens?: number | undefined;
244
+ tokensRemoved?: number | undefined;
245
+ };
246
+ /**
247
+ * Manual context compaction (`/compact`) contract for providers that
248
+ * implement the optional `runCompact` method and advertise
249
+ * `capabilities.compact === true`. Providers must NOT emit assistant messages
250
+ * while compacting; the caller renders the user-facing confirmation.
251
+ */
252
+ export type CompactResult = {
253
+ compact: CompactState;
254
+ usage?: unknown;
255
+ metadata?: Record<string, unknown> | undefined;
190
256
  };
package/dist/shared.js CHANGED
@@ -80,6 +80,30 @@ export function isTerminalGoalStatus(value) {
80
80
  *
81
81
  * @typedef {Object} SessionCapabilities
82
82
  * @property {boolean} [goal]
83
+ * @property {boolean} [compact]
84
+ */
85
+ /**
86
+ * Manual context compaction (`/compact`) contract for providers that
87
+ * implement the optional `runCompact` method and advertise
88
+ * `capabilities.compact === true`. Providers must NOT emit assistant messages
89
+ * while compacting; the caller renders the user-facing confirmation.
90
+ *
91
+ * @typedef {Object} CompactRequest
92
+ * @property {string} [instructions] Optional focus instructions; providers
93
+ * whose backend cannot take them ignore them and report
94
+ * `instructionsApplied: false`.
95
+ *
96
+ * @typedef {Object} CompactState
97
+ * @property {"compacted"|"noop"} status `noop` = nothing to compact.
98
+ * @property {boolean} instructionsApplied
99
+ * @property {number} [preTokens]
100
+ * @property {number} [postTokens]
101
+ * @property {number} [tokensRemoved]
102
+ *
103
+ * @typedef {Object} CompactResult
104
+ * @property {CompactState} compact
105
+ * @property {unknown} [usage]
106
+ * @property {Record<string, unknown>} [metadata]
83
107
  */
84
108
  /**
85
109
  * Default capabilities assigned to providers that do not opt into any optional
@@ -87,7 +111,7 @@ export function isTerminalGoalStatus(value) {
87
111
  *
88
112
  * @type {Readonly<SessionCapabilities>}
89
113
  */
90
- export const DEFAULT_SESSION_CAPABILITIES = Object.freeze({ goal: false });
114
+ export const DEFAULT_SESSION_CAPABILITIES = Object.freeze({ goal: false, compact: false });
91
115
  /**
92
116
  * Resolve a session's capability snapshot, falling back to
93
117
  * {@link DEFAULT_SESSION_CAPABILITIES}.
package/dist/worker.js CHANGED
@@ -76,7 +76,7 @@ async function handleRequest(message) {
76
76
  throw new Error(`Unsupported worker method: ${method}`);
77
77
  }
78
78
  const args = Array.isArray(message.args) ? [...message.args] : [];
79
- if (method === "runTurn" || method === "runGoal") {
79
+ if (method === "runTurn" || method === "runGoal" || method === "runCompact") {
80
80
  const firstArg = args[0];
81
81
  const options = args[1] && typeof args[1] === "object" ? { ...args[1] } : {};
82
82
  options.onProgress = (payload) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/ai-sdk",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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.13.1"
56
+ "@love-moon/chat-web": "^0.13.2"
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": "b82ba1c"
63
+ "gitCommitId": "d60a6a2"
64
64
  }