@love-moon/ai-sdk 0.13.2 → 0.14.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/client.d.ts +5 -0
  3. package/dist/client.js +22 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/providers/chat-web-session.d.ts +18 -0
  7. package/dist/providers/chat-web-session.js +44 -1
  8. package/dist/providers/claude-agent-sdk-session.d.ts +34 -0
  9. package/dist/providers/claude-agent-sdk-session.js +33 -0
  10. package/dist/providers/codex-app-server-session.d.ts +76 -6
  11. package/dist/providers/codex-app-server-session.js +135 -21
  12. package/dist/providers/codex-exec-session.d.ts +14 -0
  13. package/dist/providers/codex-exec-session.js +20 -1
  14. package/dist/providers/copilot-sdk-session.d.ts +21 -0
  15. package/dist/providers/copilot-sdk-session.js +55 -12
  16. package/dist/providers/dsh-sdk-session.d.ts +53 -3
  17. package/dist/providers/dsh-sdk-session.js +24 -1
  18. package/dist/providers/kimi-cli-session.d.ts +22 -0
  19. package/dist/providers/kimi-cli-session.js +23 -1
  20. package/dist/providers/kimi-print-session.d.ts +13 -0
  21. package/dist/providers/kimi-print-session.js +29 -0
  22. package/dist/providers/kimi-slash-commands.d.ts +1 -0
  23. package/dist/providers/kimi-slash-commands.js +23 -0
  24. package/dist/providers/opencode-sdk-session.d.ts +52 -3
  25. package/dist/providers/opencode-sdk-session.js +33 -1
  26. package/dist/shared.d.ts +46 -0
  27. package/dist/shared.js +18 -1
  28. package/dist/transports/kimi-wire-transport.d.ts +12 -0
  29. package/dist/transports/kimi-wire-transport.js +40 -1
  30. package/dist/turn-usage.d.ts +31 -0
  31. package/dist/turn-usage.js +44 -0
  32. package/dist/worker.js +1 -1
  33. package/package.json +3 -3
@@ -60,7 +60,39 @@ function readTokenCount(breakdown) {
60
60
  const value = Number(breakdown?.totalTokens);
61
61
  return Number.isFinite(value) ? value : null;
62
62
  }
63
- /** Adds tokens spent outside the reported attempt (an overflowed first try) to a turn result or error. */
63
+ /**
64
+ * The thread counters a turn's usage is a delta of; null when the total is
65
+ * unknown. A breakdown without the input split carries the total alone, so a
66
+ * build that omits it reports no cache share rather than a bogus one.
67
+ */
68
+ function readTokenCounts(breakdown) {
69
+ const totalTokens = readTokenCount(breakdown);
70
+ if (totalTokens === null) {
71
+ return null;
72
+ }
73
+ const inputTokens = Number(breakdown.inputTokens);
74
+ const cachedInputTokens = Number(breakdown.cachedInputTokens);
75
+ return Number.isFinite(inputTokens) && Number.isFinite(cachedInputTokens)
76
+ ? { totalTokens, inputTokens, cachedInputTokens }
77
+ : { totalTokens };
78
+ }
79
+ function subtractTokenCounts(a, b) {
80
+ if (!a || !b) {
81
+ return null;
82
+ }
83
+ const split = a.inputTokens !== undefined && b.inputTokens !== undefined;
84
+ return {
85
+ totalTokens: a.totalTokens - b.totalTokens,
86
+ ...(split
87
+ ? { inputTokens: a.inputTokens - b.inputTokens, cachedInputTokens: a.cachedInputTokens - b.cachedInputTokens }
88
+ : {}),
89
+ };
90
+ }
91
+ /**
92
+ * Adds tokens spent outside the reported attempt (an overflowed first try) to a
93
+ * turn result or error. Only the total grows: the input split, and so the cache
94
+ * share, stays that of the attempt the fresh thread reported.
95
+ */
64
96
  function addTurnTokens(holder, extraTokens) {
65
97
  if (extraTokens > 0 && holder && typeof holder === "object") {
66
98
  const counted = Number(holder.usage?.turnTotalTokens);
@@ -121,7 +153,7 @@ function resolveItemPhase(item) {
121
153
  includesCandidate("reason", "summary")) {
122
154
  return "reasoning";
123
155
  }
124
- if (hasCandidate("message", "agent_message", "output_text", "final_answer")) {
156
+ if (hasCandidate("message", "agentmessage", "agent_message", "output_text", "final_answer")) {
125
157
  return "assistant_message";
126
158
  }
127
159
  if (hasCandidate("compaction", "context_compacted", "compacted") || includesCandidate("compact")) {
@@ -204,6 +236,7 @@ export class CodexAppServerSession extends EventEmitter {
204
236
  static capabilities = Object.freeze({
205
237
  goal: true,
206
238
  compact: true,
239
+ clear: true,
207
240
  media: PROVIDER_MEDIA_CAPABILITIES[CODEX_APP_SERVER_VARIANT],
208
241
  });
209
242
  /**
@@ -217,6 +250,7 @@ export class CodexAppServerSession extends EventEmitter {
217
250
  this.backend = normalizeCodexBackend(backend);
218
251
  this.options = options;
219
252
  this.logger = normalizeLogger(options.logger);
253
+ this.traceReplyLatency = /^(1|true|yes)$/i.test(String(options.env?.CONDUCTOR_DEBUG ?? process.env.CONDUCTOR_DEBUG ?? ""));
220
254
  this.cwd =
221
255
  typeof options.cwd === "string" && options.cwd.trim()
222
256
  ? options.cwd.trim()
@@ -414,8 +448,7 @@ export class CodexAppServerSession extends EventEmitter {
414
448
  this.bootPromise = null;
415
449
  }
416
450
  }
417
- async bootInternal() {
418
- await this.transport.boot();
451
+ buildThreadParams() {
419
452
  const params = {
420
453
  cwd: this.cwd,
421
454
  approvalPolicy: "never",
@@ -426,6 +459,11 @@ export class CodexAppServerSession extends EventEmitter {
426
459
  if (this.options.model) {
427
460
  params.model = this.options.model;
428
461
  }
462
+ return params;
463
+ }
464
+ async bootInternal() {
465
+ await this.transport.boot();
466
+ const params = this.buildThreadParams();
429
467
  let result;
430
468
  if (this.resumeSessionId) {
431
469
  result = await this.transport.request("thread/resume", {
@@ -492,24 +530,35 @@ export class CodexAppServerSession extends EventEmitter {
492
530
  }
493
531
  /** Start counting a turn's tokens from the thread total known right now. */
494
532
  beginTurnTokenAccounting() {
495
- this.turnTokenBaseline = readTokenCount(this.tokenUsage?.total);
533
+ this.turnTokenBaseline = readTokenCounts(this.tokenUsage?.total);
496
534
  this.turnTokensSeen = false;
497
535
  }
498
536
  /**
499
- * Thread token usage plus `turnTotalTokens`: tokens spent since the current
500
- * turn (or goal) started. Codex only reports thread-cumulative totals, and
501
- * only when a response completes, so a turn that ended before any response
502
- * did has unknown usage.
537
+ * Thread token usage plus `turnTotalTokens` (and its `turnInputTokens` /
538
+ * `turnCachedInputTokens` split): tokens spent since the current turn (or
539
+ * goal) started. Codex only reports thread-cumulative totals, and only when
540
+ * a response completes, so a turn that ended before any response did has
541
+ * unknown usage.
503
542
  */
504
543
  snapshotTokenUsage() {
505
544
  if (!this.tokenUsage) {
506
545
  return null;
507
546
  }
508
- const total = readTokenCount(this.tokenUsage.total);
547
+ const turn = this.turnTokensSeen
548
+ ? subtractTokenCounts(readTokenCounts(this.tokenUsage.total), this.turnTokenBaseline)
549
+ : null;
509
550
  return {
510
551
  ...this.tokenUsage,
511
- ...(total !== null && this.turnTokenBaseline !== null && this.turnTokensSeen
512
- ? { turnTotalTokens: Math.max(0, total - this.turnTokenBaseline) }
552
+ ...(turn
553
+ ? {
554
+ turnTotalTokens: Math.max(0, turn.totalTokens),
555
+ ...(turn.inputTokens !== undefined
556
+ ? {
557
+ turnInputTokens: Math.max(0, turn.inputTokens),
558
+ turnCachedInputTokens: Math.max(0, turn.cachedInputTokens),
559
+ }
560
+ : {}),
561
+ }
513
562
  : {}),
514
563
  };
515
564
  }
@@ -646,6 +695,8 @@ export class CodexAppServerSession extends EventEmitter {
646
695
  return false;
647
696
  }
648
697
  const text = currentTurn.activeAssistantMessageText || "";
698
+ const firstDeltaAt = currentTurn.activeAssistantFirstDeltaAt;
699
+ currentTurn.activeAssistantFirstDeltaAt = undefined;
649
700
  currentTurn.activeAssistantMessageText = "";
650
701
  if (!finalizedMessageId || !activeMessageId || activeMessageId === finalizedMessageId) {
651
702
  currentTurn.activeAssistantMessageId = "";
@@ -653,6 +704,13 @@ export class CodexAppServerSession extends EventEmitter {
653
704
  if (!text || currentTurn.suppressReply) {
654
705
  return false;
655
706
  }
707
+ if (this.traceReplyLatency && firstDeltaAt !== undefined) {
708
+ this.writeLog(`[codex-reply-latency] ${JSON.stringify({
709
+ event: "reply_emit", replyTo: this.getCurrentReplyTarget(),
710
+ turnId: currentTurn.turnId, itemId: finalizedMessageId,
711
+ firstDeltaToEmitMs: performance.now() - firstDeltaAt,
712
+ })}`);
713
+ }
656
714
  await this.emitAssistantMessage(text);
657
715
  return true;
658
716
  }
@@ -831,6 +889,17 @@ export class CodexAppServerSession extends EventEmitter {
831
889
  if (normalizedMessageId) {
832
890
  currentTurn.activeAssistantMessageId = normalizedMessageId;
833
891
  }
892
+ if (this.traceReplyLatency && delta && currentTurn.activeAssistantFirstDeltaAt === undefined) {
893
+ const firstDeltaAt = performance.now();
894
+ currentTurn.activeAssistantFirstDeltaAt = firstDeltaAt;
895
+ if (!currentTurn.suppressReply) {
896
+ this.writeLog(`[codex-reply-latency] ${JSON.stringify({
897
+ event: "first_delta", replyTo: this.getCurrentReplyTarget(),
898
+ turnId: currentTurn.turnId, itemId: normalizedMessageId,
899
+ turnStartToDeltaMs: currentTurn.startedAt === undefined ? null : firstDeltaAt - currentTurn.startedAt,
900
+ })}`);
901
+ }
902
+ }
834
903
  currentTurn.fullText += delta;
835
904
  currentTurn.activeAssistantMessageText += delta;
836
905
  }
@@ -838,8 +907,19 @@ export class CodexAppServerSession extends EventEmitter {
838
907
  if (this.closeRequested) {
839
908
  return;
840
909
  }
910
+ // Multi-agent mode streams spawned sub-agent threads over this connection.
911
+ // Only our own thread may drive turn/message state: a sub-agent's
912
+ // turn/completed must not end the parent turn and drop its final answer.
913
+ const threadId = typeof params?.threadId === "string" ? params.threadId.trim() : "";
914
+ if (threadId && this.sessionId && threadId !== this.sessionId) {
915
+ this.touchTurnActivity();
916
+ return;
917
+ }
841
918
  switch (method) {
842
919
  case "thread/started":
920
+ if (params?.thread?.parentThreadId) {
921
+ return;
922
+ }
843
923
  this.applyThreadInfo(params, { resumeReady: Boolean(this.resumeSessionId) });
844
924
  return;
845
925
  case "sessionConfigured":
@@ -965,12 +1045,16 @@ export class CodexAppServerSession extends EventEmitter {
965
1045
  }
966
1046
  return;
967
1047
  }
968
- await this.emitWorkingStatus({
969
- phase: "message_aggregation",
970
- reply_in_progress: true,
971
- status_line: statusLineForPhase("message_aggregation"),
972
- });
973
- await this.queueAssistantDelta(delta, { messageId });
1048
+ // Notifications from one stdout batch run concurrently. Buffer the
1049
+ // delta before yielding so item/completed cannot flush a partial reply.
1050
+ await Promise.all([
1051
+ this.queueAssistantDelta(delta, { messageId }),
1052
+ this.emitWorkingStatus({
1053
+ phase: "message_aggregation",
1054
+ reply_in_progress: true,
1055
+ status_line: statusLineForPhase("message_aggregation"),
1056
+ }),
1057
+ ]);
974
1058
  return;
975
1059
  }
976
1060
  case "thread/tokenUsage/updated": {
@@ -984,9 +1068,8 @@ export class CodexAppServerSession extends EventEmitter {
984
1068
  // No total was known when the turn started (fresh boot or resume):
985
1069
  // a replayed total is the baseline; this turn's first response
986
1070
  // has `total - last` before it.
987
- const total = readTokenCount(tokenUsage.total);
988
- const last = readTokenCount(tokenUsage.last);
989
- this.turnTokenBaseline = total === null ? null : beforeTurn ? total : last === null ? null : total - last;
1071
+ const total = readTokenCounts(tokenUsage.total);
1072
+ this.turnTokenBaseline = beforeTurn ? total : subtractTokenCounts(total, readTokenCounts(tokenUsage.last));
990
1073
  }
991
1074
  if (!beforeTurn) {
992
1075
  this.turnTokensSeen = true;
@@ -1187,6 +1270,7 @@ export class CodexAppServerSession extends EventEmitter {
1187
1270
  reason: "turn_already_running",
1188
1271
  });
1189
1272
  }
1273
+ const startedAt = performance.now();
1190
1274
  this.markTurnStartedStatus();
1191
1275
  try {
1192
1276
  await this.boot();
@@ -1205,6 +1289,7 @@ export class CodexAppServerSession extends EventEmitter {
1205
1289
  rejectTurn = reject;
1206
1290
  });
1207
1291
  const currentTurn = {
1292
+ startedAt,
1208
1293
  turnId: "",
1209
1294
  fullText: "",
1210
1295
  activeAssistantMessageId: "",
@@ -1338,6 +1423,35 @@ export class CodexAppServerSession extends EventEmitter {
1338
1423
  * @param {import("../shared.js").CompactRequest} [request]
1339
1424
  * @returns {Promise<import("../shared.js").CompactResult>}
1340
1425
  */
1426
+ /**
1427
+ * Native clear: start a fresh thread on the SAME app-server connection. The
1428
+ * process, transport and model config stay up; only the conversation is
1429
+ * replaced, so the next turn runs against an empty thread.
1430
+ */
1431
+ async runClear() {
1432
+ if (this.closeRequested) {
1433
+ throw this.createSessionClosedError();
1434
+ }
1435
+ if (this.currentTurn || this.currentGoalRun) {
1436
+ throw createTurnError("Codex app-server turn already running", {
1437
+ reason: "turn_already_running",
1438
+ });
1439
+ }
1440
+ if (this.pendingHistorySeed || (!this.resumeSessionId && !this.sessionId && this.history.length === 0)) {
1441
+ return { clear: { status: "noop" }, usage: null, metadata: {} };
1442
+ }
1443
+ await this.boot();
1444
+ const result = await this.transport.request("thread/start", this.buildThreadParams());
1445
+ this.applyThreadInfo(result);
1446
+ // A later re-boot must not resume the thread we just walked away from.
1447
+ this.resumeSessionId = "";
1448
+ this.history = [];
1449
+ return {
1450
+ clear: { status: "cleared", sessionId: this.sessionId || undefined },
1451
+ usage: null,
1452
+ metadata: {},
1453
+ };
1454
+ }
1341
1455
  async runCompact(request = {}) {
1342
1456
  if (this.closeRequested) {
1343
1457
  throw this.createSessionClosedError();
@@ -53,6 +53,7 @@ export class CodexExecSession extends EventEmitter<[never]> {
53
53
  manualResume: null;
54
54
  currentTurnStatus: any;
55
55
  capabilities: {
56
+ clear: boolean;
56
57
  media: Readonly<{
57
58
  image: "native";
58
59
  }>;
@@ -90,6 +91,19 @@ export class CodexExecSession extends EventEmitter<[never]> {
90
91
  emitWorkingStatus(payload: any, onProgress?: null): Promise<any>;
91
92
  emitAssistantMessage(text: any): Promise<void>;
92
93
  buildPrompt(promptText: any): string;
94
+ /**
95
+ * Native clear: `codex exec` is stateless — every turn replays this history
96
+ * as the prompt prefix (see buildPrompt), so dropping it IS the context
97
+ * clear. No process to restart, no session to rebind.
98
+ */
99
+ runClear(): Promise<{
100
+ clear: {
101
+ status: string;
102
+ sessionId: any;
103
+ };
104
+ usage: null;
105
+ metadata: {};
106
+ }>;
93
107
  buildExecArgs({ media, schemaFilePath, lastMessageFilePath }?: {
94
108
  media?: never[] | undefined;
95
109
  schemaFilePath?: string | undefined;
@@ -242,7 +242,7 @@ export class CodexExecSession extends EventEmitter {
242
242
  resumeReady: false,
243
243
  manualResume: null,
244
244
  currentTurnStatus: this.getCurrentTurnStatus(),
245
- capabilities: { media: PROVIDER_MEDIA_CAPABILITIES[CODEX_EXEC_PROVIDER_VARIANT] },
245
+ capabilities: { clear: true, media: PROVIDER_MEDIA_CAPABILITIES[CODEX_EXEC_PROVIDER_VARIANT] },
246
246
  pid: this.currentTurn?.child?.pid || undefined,
247
247
  };
248
248
  }
@@ -342,6 +342,25 @@ export class CodexExecSession extends EventEmitter {
342
342
  buildPrompt(promptText) {
343
343
  return buildHistoryPrompt(this.history, promptText);
344
344
  }
345
+ /**
346
+ * Native clear: `codex exec` is stateless — every turn replays this history
347
+ * as the prompt prefix (see buildPrompt), so dropping it IS the context
348
+ * clear. No process to restart, no session to rebind.
349
+ */
350
+ async runClear() {
351
+ if (this.currentTurn) {
352
+ throw createTurnError("Codex exec turn already running", {
353
+ reason: "turn_already_running",
354
+ });
355
+ }
356
+ const hadContext = this.history.length > 0;
357
+ this.history = [];
358
+ return {
359
+ clear: { status: hadContext ? "cleared" : "noop", sessionId: this.sessionId || undefined },
360
+ usage: null,
361
+ metadata: {},
362
+ };
363
+ }
345
364
  buildExecArgs({ media = [], schemaFilePath = "", lastMessageFilePath = "" } = {}) {
346
365
  const args = [...this.baseArgs];
347
366
  args.push("exec");
@@ -85,6 +85,7 @@ export class CopilotSdkSession extends EventEmitter<[never]> {
85
85
  currentTurnStatus: any;
86
86
  capabilities: {
87
87
  compact: boolean;
88
+ clear: boolean;
88
89
  media: Readonly<{
89
90
  image: "native";
90
91
  }>;
@@ -147,6 +148,26 @@ export class CopilotSdkSession extends EventEmitter<[never]> {
147
148
  getSdkModule(): Promise<any>;
148
149
  boot(): Promise<void>;
149
150
  bootInternal(): Promise<void>;
151
+ /**
152
+ * Native clear: create a new session on the SAME CopilotClient, so the CLI
153
+ * subprocess keeps running. The old conversation is left on disk (resumable)
154
+ * and we simply stop listening to it.
155
+ */
156
+ runClear(): Promise<{
157
+ clear: {
158
+ status: string;
159
+ sessionId?: undefined;
160
+ };
161
+ usage: null;
162
+ metadata: {};
163
+ } | {
164
+ clear: {
165
+ status: string;
166
+ sessionId: any;
167
+ };
168
+ usage: null;
169
+ metadata: {};
170
+ }>;
150
171
  attachSessionEventHandlers(session: any): void;
151
172
  detachSessionEventHandlers(): void;
152
173
  requestOrThrow(promise: any): Promise<any>;
@@ -505,6 +505,21 @@ function buildCopilotSessionConfig(options, cwd, permissionHandler) {
505
505
  }
506
506
  return sessionConfig;
507
507
  }
508
+ function resolveCopilotPermissionHandler(sdkModule) {
509
+ return typeof sdkModule?.approveAll === "function"
510
+ ? sdkModule.approveAll
511
+ : () => ({ kind: "approve-once" });
512
+ }
513
+ function sessionInfoExtrasFromConfig(sessionConfig = {}) {
514
+ return {
515
+ model: typeof sessionConfig.model === "string" && sessionConfig.model.trim()
516
+ ? sessionConfig.model.trim()
517
+ : undefined,
518
+ reasoningEffort: typeof sessionConfig.reasoningEffort === "string" && sessionConfig.reasoningEffort.trim()
519
+ ? sessionConfig.reasoningEffort.trim()
520
+ : undefined,
521
+ };
522
+ }
508
523
  export class CopilotSdkSession extends EventEmitter {
509
524
  constructor(backend, options = {}) {
510
525
  super();
@@ -594,7 +609,7 @@ export class CopilotSdkSession extends EventEmitter {
594
609
  }
595
610
  : null,
596
611
  currentTurnStatus: this.getCurrentTurnStatus(),
597
- capabilities: { compact: true, media: PROVIDER_MEDIA_CAPABILITIES[COPILOT_PROVIDER_VARIANT] },
612
+ capabilities: { compact: true, clear: true, media: PROVIDER_MEDIA_CAPABILITIES[COPILOT_PROVIDER_VARIANT] },
598
613
  };
599
614
  }
600
615
  getSessionInfo() {
@@ -910,9 +925,7 @@ export class CopilotSdkSession extends EventEmitter {
910
925
  if (!sdkModule || typeof sdkModule.CopilotClient !== "function") {
911
926
  throw new Error("GitHub Copilot SDK client is unavailable");
912
927
  }
913
- const permissionHandler = typeof sdkModule.approveAll === "function"
914
- ? sdkModule.approveAll
915
- : () => ({ kind: "approve-once" });
928
+ const permissionHandler = resolveCopilotPermissionHandler(sdkModule);
916
929
  const clientOptions = buildCopilotClientOptions(this.options, this.cwd, this.env);
917
930
  this.client = new sdkModule.CopilotClient(clientOptions);
918
931
  const cleanupIfClosedDuringBoot = async (session = null) => {
@@ -941,14 +954,44 @@ export class CopilotSdkSession extends EventEmitter {
941
954
  : await this.requestOrThrow(this.client.createSession(sessionConfig));
942
955
  await cleanupIfClosedDuringBoot(this.session);
943
956
  this.attachSessionEventHandlers(this.session);
944
- this.applySessionInfo(this.session?.sessionId, {
945
- model: typeof sessionConfig.model === "string" && sessionConfig.model.trim()
946
- ? sessionConfig.model.trim()
947
- : undefined,
948
- reasoningEffort: typeof sessionConfig.reasoningEffort === "string" && sessionConfig.reasoningEffort.trim()
949
- ? sessionConfig.reasoningEffort.trim()
950
- : undefined,
951
- });
957
+ this.applySessionInfo(this.session?.sessionId, sessionInfoExtrasFromConfig(sessionConfig));
958
+ }
959
+ /**
960
+ * Native clear: create a new session on the SAME CopilotClient, so the CLI
961
+ * subprocess keeps running. The old conversation is left on disk (resumable)
962
+ * and we simply stop listening to it.
963
+ */
964
+ async runClear() {
965
+ if (this.currentTurn) {
966
+ throw this.createTurnAlreadyRunningError();
967
+ }
968
+ if (this.pendingHistorySeed || (!this.session && !this.resumeSessionId && !this.sessionId)) {
969
+ return { clear: { status: "noop" }, usage: null, metadata: {} };
970
+ }
971
+ await this.boot();
972
+ const sdkModule = await this.getSdkModule();
973
+ const sessionConfig = buildCopilotSessionConfig(this.options, this.cwd, resolveCopilotPermissionHandler(sdkModule));
974
+ const previousSession = this.session;
975
+ const session = await this.requestOrThrow(this.client.createSession(sessionConfig));
976
+ this.session = session;
977
+ this.attachSessionEventHandlers(session);
978
+ this.applySessionInfo(session?.sessionId, sessionInfoExtrasFromConfig(sessionConfig));
979
+ // A later re-boot must not reattach to the conversation we walked away from.
980
+ this.resumeSessionId = "";
981
+ this.history = [];
982
+ if (previousSession && previousSession !== session && typeof previousSession.disconnect === "function") {
983
+ try {
984
+ await previousSession.disconnect();
985
+ }
986
+ catch {
987
+ // best effort: the old conversation is already detached from this session
988
+ }
989
+ }
990
+ return {
991
+ clear: { status: "cleared", sessionId: this.sessionId || undefined },
992
+ usage: null,
993
+ metadata: {},
994
+ };
952
995
  }
953
996
  attachSessionEventHandlers(session) {
954
997
  if (!session || typeof session.on !== "function") {
@@ -19,11 +19,13 @@ export class DshSdkSession extends EventEmitter<[never]> {
19
19
  static capabilities: Readonly<{
20
20
  goal: false;
21
21
  compact: true;
22
+ clear: true;
22
23
  }>;
23
24
  constructor(backend: any, options?: {});
24
25
  getCapabilities(): {
25
26
  goal: false;
26
27
  compact: true;
28
+ clear: true;
27
29
  };
28
30
  backend: string;
29
31
  options: {};
@@ -92,6 +94,7 @@ export class DshSdkSession extends EventEmitter<[never]> {
92
94
  capabilities: {
93
95
  goal: false;
94
96
  compact: true;
97
+ clear: true;
95
98
  };
96
99
  };
97
100
  getSessionInfo(): {
@@ -180,9 +183,56 @@ export class DshSdkSession extends EventEmitter<[never]> {
180
183
  * @param {{ onProgress?: Function }} [options]
181
184
  * @returns {Promise<import("../shared.js").CompactResult>}
182
185
  */
183
- runCompact(request?: import("../shared.js").CompactRequest, { onProgress }?: {
184
- onProgress?: Function;
185
- }): Promise<import("../shared.js").CompactResult>;
186
+ /**
187
+ * Native clear: drop the conversation and rotate onto a fresh wire session id.
188
+ * The harness (and its runtime subprocess) stays up — the dsh runtime creates
189
+ * the new session lazily on its first prompt — so this costs no model call and
190
+ * no restart. Unlike runCompact there is nothing to carry over, so rotating
191
+ * here is safe: the new id has no persisted log, and resuming it restores an
192
+ * empty conversation, which is exactly what `/clear` promises.
193
+ */
194
+ runClear(): Promise<{
195
+ clear: {
196
+ status: string;
197
+ sessionId?: undefined;
198
+ };
199
+ usage: null;
200
+ metadata: {
201
+ source?: undefined;
202
+ };
203
+ } | {
204
+ clear: {
205
+ status: string;
206
+ sessionId: string;
207
+ };
208
+ usage: null;
209
+ metadata: {
210
+ source: string;
211
+ };
212
+ }>;
213
+ runCompact(request?: {}, { onProgress }?: {
214
+ onProgress?: null | undefined;
215
+ }): Promise<{
216
+ compact: {
217
+ status: string;
218
+ instructionsApplied: boolean;
219
+ };
220
+ usage: null;
221
+ metadata: {
222
+ source?: undefined;
223
+ sessionId?: undefined;
224
+ };
225
+ } | {
226
+ compact: {
227
+ status: string;
228
+ instructionsApplied: boolean;
229
+ };
230
+ usage: any;
231
+ metadata: {
232
+ source: string;
233
+ sessionId: string;
234
+ };
235
+ }>;
186
236
  runTurn(promptText: any, { useInitialImages, onProgress, suppressReply }?: {
187
237
  useInitialImages?: boolean | undefined;
188
238
  onProgress?: null | undefined;
@@ -132,7 +132,7 @@ function accumulateUsage(total, usage) {
132
132
  * the next runTurn spawns a fresh runtime resuming the same session id.
133
133
  */
134
134
  export class DshSdkSession extends EventEmitter {
135
- static capabilities = Object.freeze({ goal: false, compact: true });
135
+ static capabilities = Object.freeze({ goal: false, compact: true, clear: true });
136
136
  getCapabilities() {
137
137
  return { ...DshSdkSession.capabilities };
138
138
  }
@@ -807,6 +807,29 @@ export class DshSdkSession extends EventEmitter {
807
807
  * @param {{ onProgress?: Function }} [options]
808
808
  * @returns {Promise<import("../shared.js").CompactResult>}
809
809
  */
810
+ /**
811
+ * Native clear: drop the conversation and rotate onto a fresh wire session id.
812
+ * The harness (and its runtime subprocess) stays up — the dsh runtime creates
813
+ * the new session lazily on its first prompt — so this costs no model call and
814
+ * no restart. Unlike runCompact there is nothing to carry over, so rotating
815
+ * here is safe: the new id has no persisted log, and resuming it restores an
816
+ * empty conversation, which is exactly what `/clear` promises.
817
+ */
818
+ async runClear() {
819
+ const resumableLog = this.pendingResumeFromSessionId && this.findPersistedSessionLog(this.pendingResumeFromSessionId);
820
+ if (this.history.length === 0 && !resumableLog) {
821
+ return { clear: { status: "noop" }, usage: null, metadata: {} };
822
+ }
823
+ this.history = [];
824
+ // Without this the next turn would restore the old session's log.
825
+ this.pendingResumeFromSessionId = "";
826
+ this.rotateSessionId();
827
+ return {
828
+ clear: { status: "cleared", sessionId: this.sessionId },
829
+ usage: null,
830
+ metadata: { source: DSH_SDK_VARIANT },
831
+ };
832
+ }
810
833
  async runCompact(request = {}, { onProgress = null } = {}) {
811
834
  const instructions = typeof request?.instructions === "string" ? request.instructions.trim() : "";
812
835
  const resumableLog = this.pendingResumeFromSessionId && this.findPersistedSessionLog(this.pendingResumeFromSessionId);
@@ -87,6 +87,7 @@ export class KimiCliSession extends EventEmitter<[never]> {
87
87
  currentTurnStatus: any;
88
88
  capabilities: {
89
89
  compact: boolean;
90
+ clear: boolean;
90
91
  media: Readonly<{
91
92
  image: "native";
92
93
  }>;
@@ -169,6 +170,27 @@ export class KimiCliSession extends EventEmitter<[never]> {
169
170
  runCompact(request?: import("../shared.js").CompactRequest, { onProgress }?: {
170
171
  onProgress?: Function;
171
172
  }): Promise<import("../shared.js").CompactResult>;
173
+ /**
174
+ * Native `/clear`: the Kimi CLI answers the slash command locally (no model
175
+ * call) and keeps the same wire session, so only the context is dropped.
176
+ */
177
+ runClear(request?: {}, { onProgress }?: {
178
+ onProgress?: null | undefined;
179
+ }): Promise<{
180
+ clear: {
181
+ status: string;
182
+ sessionId?: undefined;
183
+ };
184
+ usage: null;
185
+ metadata: {};
186
+ } | {
187
+ clear: {
188
+ status: string;
189
+ sessionId: any;
190
+ };
191
+ usage: null;
192
+ metadata: any;
193
+ }>;
172
194
  runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema, suppressReply }?: {
173
195
  useInitialImages?: boolean | undefined;
174
196
  onProgress?: null | undefined;
@@ -5,6 +5,7 @@ import { appendContextFilesToPrompt } from "../context-files.js";
5
5
  import { PROVIDER_MEDIA_CAPABILITIES, buildKimiContent } from "../media-adapters.js";
6
6
  import { assertMediaCapabilities, defaultPromptForMedia, resolveTurnMedia, } from "../media-input.js";
7
7
  import { KimiWireTransport } from "../transports/kimi-wire-transport.js";
8
+ import { assertKimiClearReply } from "./kimi-slash-commands.js";
8
9
  import { emitLog, getBoundedEnvInt, loadEnvConfig, normalizeLogger, noteToolFinished, noteToolStarted, proxyToEnv, sanitizeForLog, withActiveTool, } from "../shared.js";
9
10
  const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
10
11
  const MIN_TURN_DEADLINE_MS = 30 * 1000;
@@ -233,7 +234,7 @@ export class KimiCliSession extends EventEmitter {
233
234
  }
234
235
  : null,
235
236
  currentTurnStatus: this.getCurrentTurnStatus(),
236
- capabilities: { compact: true, media: PROVIDER_MEDIA_CAPABILITIES[KIMI_PROVIDER_VARIANT] },
237
+ capabilities: { compact: true, clear: true, media: PROVIDER_MEDIA_CAPABILITIES[KIMI_PROVIDER_VARIANT] },
237
238
  pid: this.transport.pid || undefined,
238
239
  };
239
240
  }
@@ -905,6 +906,27 @@ export class KimiCliSession extends EventEmitter {
905
906
  metadata: turnResult.metadata,
906
907
  };
907
908
  }
909
+ /**
910
+ * Native `/clear`: the Kimi CLI answers the slash command locally (no model
911
+ * call) and keeps the same wire session, so only the context is dropped.
912
+ */
913
+ async runClear(request = {}, { onProgress = null } = {}) {
914
+ if (this.pendingHistorySeed) {
915
+ return { clear: { status: "noop" }, usage: null, metadata: {} };
916
+ }
917
+ const turnResult = await this.runTurn("/clear", { onProgress, suppressReply: true });
918
+ assertKimiClearReply(turnResult.text);
919
+ this.history = [];
920
+ // The previous turn's context reading describes a context that no longer
921
+ // exists; leave it unknown until kimi reports the next StatusUpdate.
922
+ this.lastContextUsagePercent = undefined;
923
+ return {
924
+ clear: { status: "cleared", sessionId: this.sessionId || undefined },
925
+ // A built-in slash command spends no tokens of its own.
926
+ usage: null,
927
+ metadata: turnResult.metadata,
928
+ };
929
+ }
908
930
  async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null, suppressReply = false } = {}) {
909
931
  if (this.closeRequested || this.closed) {
910
932
  throw this.createSessionClosedError();