aegis-desktop 0.8.13 → 0.8.15

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/vendor/aegis.js CHANGED
@@ -65,6 +65,50 @@ function envIdleMs(name) {
65
65
  return Number.isFinite(n) && n > 0 ? n : 0;
66
66
  }
67
67
 
68
+ /**
69
+ * DeepSeek's chat template wraps tool calls and turn boundaries in special
70
+ * tokens — fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁
71
+ * (U+2581) as its word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|,
72
+ * |Assistant|, |begin▁of▁sentence|. These are chat-template internals, never
73
+ * user-facing text; when the provider's streaming tool-call path leaks them
74
+ * into `delta.content` they used to reach the screen verbatim — reported as
75
+ * the pasted "||DSML||" envelope on Windows/macOS. Returns a per-stream
76
+ * stripper closure with a short lookback buffer so a token split across two
77
+ * SSE chunks isn't half-rendered before its closing pipe arrives.
78
+ */
79
+ const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
80
+ function makeDeepSeekEnvelopeStripper() {
81
+ let buf = '';
82
+ const strip = (chunk) => {
83
+ buf += chunk;
84
+ buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
85
+ const openIdx = buf.lastIndexOf('|');
86
+ if (openIdx !== -1) {
87
+ const tail = buf.slice(openIdx);
88
+ // Held back only while the tail still looks like an in-progress token
89
+ // (no closing pipe yet); a bare '|' or genuine prose flushes normally.
90
+ if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
91
+ const out = buf.slice(0, openIdx);
92
+ buf = tail;
93
+ return out;
94
+ }
95
+ }
96
+ const out = buf;
97
+ buf = '';
98
+ return out;
99
+ };
100
+ // Unconditional release of whatever is left buffered — called once the
101
+ // stream is fully drained, when a held-back '|…' is known to never close
102
+ // (dropped connection, or it was real prose all along) rather than an
103
+ // in-progress token.
104
+ strip.flush = () => {
105
+ const out = buf;
106
+ buf = '';
107
+ return out;
108
+ };
109
+ return strip;
110
+ }
111
+
68
112
  /**
69
113
  * UUID v4 that works everywhere: Web Crypto first (browsers, Node ≥ 19),
70
114
  * then Node's CJS crypto module (Node < 19), then a Math.random fallback for
@@ -221,10 +265,26 @@ function createClient(opts = {}) {
221
265
  }
222
266
  throw err;
223
267
  } finally {
268
+ // The timeout is time-to-*headers* only, so it is cleared the moment the
269
+ // response resolves: a long generation must never be cut off by it.
224
270
  if (timer) clearTimeout(timer);
225
- if (controller && callerSignal && typeof callerSignal.removeEventListener === 'function') {
226
- callerSignal.removeEventListener('abort', onAbort);
227
- }
271
+ // …but the caller's abort bridge is deliberately NOT removed here.
272
+ //
273
+ // `fetch` resolves at the status line, while the RESPONSE BODY is still
274
+ // bound to `controller.signal` and keeps being read long after this
275
+ // function has returned. Unhooking the caller's signal at that point
276
+ // detached Stop/Escape from the stream it was supposed to stop: the
277
+ // engine's own controller really did abort — `engine.cancel()` answered
278
+ // `{ ok: true }`, so the right controller was found and `abort()` was
279
+ // really called — and nothing happened, because the only thing wired to
280
+ // the live body had already been told to stop listening. The turn then
281
+ // streamed to completion while the UI sat on "stopping…", never settled,
282
+ // and never produced the salvaged bubble, because no error ever reached
283
+ // the caller's catch.
284
+ //
285
+ // `once: true` plus one signal per turn bounds the cost of leaving it
286
+ // attached to a single listener per turn — a far smaller price than a
287
+ // cancel button that does nothing.
228
288
  }
229
289
  }
230
290
 
@@ -838,6 +898,15 @@ function createClient(opts = {}) {
838
898
  // resolve with text only and the caller could never see the calls.
839
899
  const toolCallsByIndex = new Map();
840
900
  let finishReason = null;
901
+ // `fullText` above stays RAW (it is also the `seen` cursor the snapshot
902
+ // reconciliation below compares against the provider's own un-stripped
903
+ // snapshots); `cleanText` is what the caller actually receives — the same
904
+ // stream, with DeepSeek's chat-template envelope removed — and is what the
905
+ // final `message.content` below is built from, so a non-streaming caller
906
+ // reading the resolved result sees the same clean text a streaming caller
907
+ // already saw via `onStream`.
908
+ let cleanText = '';
909
+ const stripEnvelope = makeDeepSeekEnvelopeStripper();
841
910
 
842
911
  // Idle watchdog: if the server holds the connection open without ever
843
912
  // sending another byte (a stuck upstream call, a proxy that swallows the
@@ -863,6 +932,7 @@ function createClient(opts = {}) {
863
932
  let keepAlives = 0;
864
933
  async function readWithIdleTimeout() {
865
934
  let timer;
935
+ let onAbort = null;
866
936
  const remaining = Math.max(0, idleMs - (Date.now() - lastPayloadAt));
867
937
  const timeout = new Promise((_, reject) => {
868
938
  timer = setTimeout(() => {
@@ -873,10 +943,37 @@ function createClient(opts = {}) {
873
943
  ));
874
944
  }, remaining);
875
945
  });
946
+ // The caller's abort, raced against the read directly rather than trusted
947
+ // to propagate through the body stream.
948
+ //
949
+ // Whether an abort of the caller's signal reaches a response body that is
950
+ // already being read is a property of `boundedFetch`, not of this loop —
951
+ // and it is not a property this loop can observe. Racing here makes "the
952
+ // caller stopped" a fact the read loop establishes for itself, so Stop
953
+ // and Escape end the turn at the next chunk boundary regardless of how
954
+ // the request's signal was assembled. Without it an aborted turn kept
955
+ // consuming a stream nobody wanted and `chat()` never settled at all.
956
+ const aborted = signal && typeof signal.addEventListener === 'function'
957
+ ? new Promise((_, reject) => {
958
+ onAbort = () => {
959
+ const e = new Error('The turn was stopped.');
960
+ e.name = 'AbortError';
961
+ e.code = 'ABORT_ERR';
962
+ reject(e);
963
+ };
964
+ if (signal.aborted) onAbort();
965
+ else signal.addEventListener('abort', onAbort, { once: true });
966
+ })
967
+ : null;
876
968
  try {
877
- return await Promise.race([reader.read(), timeout]);
969
+ return await (aborted
970
+ ? Promise.race([reader.read(), timeout, aborted])
971
+ : Promise.race([reader.read(), timeout]));
878
972
  } finally {
879
973
  clearTimeout(timer);
974
+ if (signal && onAbort && typeof signal.removeEventListener === 'function') {
975
+ signal.removeEventListener('abort', onAbort);
976
+ }
880
977
  }
881
978
  }
882
979
 
@@ -969,7 +1066,13 @@ function createClient(opts = {}) {
969
1066
  choice && choice.delta && choice.delta.content,
970
1067
  choice && choice.message && choice.message.content,
971
1068
  fullText,
972
- (chunk) => onStream({ delta: chunk })
1069
+ (chunk) => {
1070
+ const clean = stripEnvelope(chunk);
1071
+ if (clean) {
1072
+ cleanText += clean;
1073
+ onStream({ delta: clean });
1074
+ }
1075
+ }
973
1076
  );
974
1077
  const fragments =
975
1078
  (choice && choice.delta && choice.delta.tool_calls) ||
@@ -999,13 +1102,22 @@ function createClient(opts = {}) {
999
1102
  throw err;
1000
1103
  }
1001
1104
 
1105
+ // A trailing '|…' that never closed (stream ended right after it, or it
1106
+ // was real prose all along) is real content, not an unfinished envelope
1107
+ // token — release it rather than dropping it silently.
1108
+ const strayTail = stripEnvelope.flush();
1109
+ if (strayTail) {
1110
+ cleanText += strayTail;
1111
+ onStream({ delta: strayTail });
1112
+ }
1113
+
1002
1114
  // An id-less fragment stream still yields usable calls, but the caller
1003
1115
  // needs *an* id to pair results back, so synthesize one.
1004
1116
  const toolCalls = [...toolCallsByIndex.entries()]
1005
1117
  .sort((a, b) => a[0] - b[0])
1006
1118
  .map(([idx, c]) => ({ ...c, id: c.id || `call_${idx}` }));
1007
1119
 
1008
- const message = { content: fullText };
1120
+ const message = { content: cleanText };
1009
1121
  if (toolCalls.length) message.tool_calls = toolCalls;
1010
1122
  const result = {
1011
1123
  model: resultModel,