billion-context 0.1.65 → 0.1.66-pr.373.227

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/README.md CHANGED
@@ -307,6 +307,23 @@ recommended** for many concurrent conversations because of the collision
307
307
  risk — until pi grows its own session-id signal. For pi multi-agent use,
308
308
  pass an explicit `x-acp-session` header per conversation to avoid collisions.
309
309
 
310
+ ### Windows: exclude the sessions dir from antivirus (#362)
311
+
312
+ The proxy persists each session's compression state to the sessions dir
313
+ (`%USERPROFILE%\.local\share\billion-context\` by default) and rewrites the
314
+ file every turn of a long session. On Windows, real-time antivirus (Windows
315
+ Defender), the search indexer, or a sync tool (OneDrive) can lock that
316
+ directory mid-write, so the rename fails with `EPERM` and every persist for
317
+ that session fails until the lock clears.
318
+
319
+ When the same session fails N consecutive writes (default `5`), the proxy
320
+ logs a one-time, actionable alert naming the directory to exclude. To fix it
321
+ at the root: add `%USERPROFILE%\.local\share\billion-context\` to your
322
+ antivirus **exclusions** (Windows Defender: Settings → Virus & threat
323
+ protection → Manage settings → Exclusions → Add an exclusion → Folder) and
324
+ make sure no sync tool (OneDrive / Dropbox / …) is syncing that path. Full
325
+ steps in [CONFIGURATION.md](CONFIGURATION.md#windows-exclude-the-sessions-dir-from-antivirus-362).
326
+
310
327
  ## Status
311
328
 
312
329
  Early. Protocol handling and compression work against mock tests (500+ passing). Real-model integration testing is the next milestone. Expect rough edges.
package/README.zh-CN.md CHANGED
@@ -254,6 +254,12 @@ Windows 下会自动发现常见 Clash/Mihomo 静态系统代理;Web UI 会显
254
254
 
255
255
  **建议:** Codex 和 OpenCode 可以安全地通过代理并发跑很多会话。pi 单个 agent 没问题,但因碰撞风险**不建议**并发多会话 —— 直到 pi 自己长出 session-id 信号。pi 多 agent 场景下,每个会话发一个显式 `x-acp-session` header 来避免碰撞。
256
256
 
257
+ ### Windows:把会话目录加入杀软排除项(#362)
258
+
259
+ 代理把每个会话的压缩状态持久化到会话目录(默认 `%USERPROFILE%\.local\share\billion-context\`),长会话每一轮都会重写该文件。在 Windows 上,实时杀毒(Windows Defender)、搜索索引器或同步工具(OneDrive)可能在写入中途锁住该目录,导致 rename 以 `EPERM` 失败,在锁解除前该会话的每次持久化都会失败。
260
+
261
+ 当同一会话连续 N 次写失败(默认 `5`)时,代理会打一条一次性、可操作的告警,明确指出要排除的目录。要从根上修复:把 `%USERPROFILE%\.local\share\billion-context\` 加入杀软**排除项**(Windows Defender:设置 → 病毒和威胁防护 → 管理设置 → 排除项 → 添加排除 → 文件夹),并确认没有同步工具(OneDrive / Dropbox / …)在同步该路径。完整步骤见 [CONFIGURATION.zh-CN.md](CONFIGURATION.zh-CN.md) 的「Windows:把会话目录加入杀软排除项」章节。
262
+
257
263
  ## 状态
258
264
 
259
265
  早期。协议处理和压缩已通过 mock 测试(500+ 项通过)。真实模型集成测试是下一里程碑。预期会有粗糙的地方。
package/dist/index.js CHANGED
@@ -46551,9 +46551,9 @@ function parseObjectValue(value, callId, diag) {
46551
46551
  const content = value["content"];
46552
46552
  if (content === void 0) {
46553
46553
  const single = validateEntry(value, callId);
46554
- if (single) {
46554
+ if ("range" in single) {
46555
46555
  diag.kind = "ok";
46556
- return finish([single], diag);
46556
+ return finish([single.range], diag);
46557
46557
  }
46558
46558
  diag.kind = "missing-content";
46559
46559
  return finish([], diag);
@@ -46574,7 +46574,7 @@ function parseObjectValue(value, callId, diag) {
46574
46574
  diag.kind = "content-not-array";
46575
46575
  return finish([], diag);
46576
46576
  }
46577
- const { ranges, invalid } = validateEntries(entries, callId);
46577
+ const { ranges, invalid, reasons } = validateEntries(entries, callId);
46578
46578
  const topTopic = stringOr(value["topic"]);
46579
46579
  const topMaxChars = value["summaryMaxChars"];
46580
46580
  const hasTopMaxChars = typeof topMaxChars === "number" && Number.isFinite(topMaxChars);
@@ -46585,6 +46585,7 @@ function parseObjectValue(value, callId, diag) {
46585
46585
  }
46586
46586
  }
46587
46587
  diag.invalidItems = invalid;
46588
+ if (reasons.length > 0) diag.invalidReasons = reasons;
46588
46589
  diag.kind = salvaged ? "truncated" : ranges.length > 0 ? "ok" : "no-valid-ranges";
46589
46590
  return finish(ranges, diag);
46590
46591
  }
@@ -46608,36 +46609,43 @@ function finish(ranges, diag) {
46608
46609
  return { ranges, diagnostics: diag };
46609
46610
  }
46610
46611
  function finishSalvage(entries, callId, diag, truncatedShape) {
46611
- const { ranges, invalid } = validateEntries(entries, callId);
46612
+ const { ranges, invalid, reasons } = validateEntries(entries, callId);
46612
46613
  diag.invalidItems = invalid;
46614
+ if (reasons.length > 0) diag.invalidReasons = reasons;
46613
46615
  diag.kind = entries.length > 0 || truncatedShape ? "truncated" : "malformed-json";
46614
46616
  return finish(ranges, diag);
46615
46617
  }
46616
46618
  function validateEntries(entries, callId) {
46617
46619
  const ranges = [];
46620
+ const reasons = [];
46618
46621
  let invalid = 0;
46619
- for (const entry of entries) {
46620
- const range = validateEntry(entry, callId);
46621
- if (range !== null) ranges.push(range);
46622
- else invalid++;
46622
+ for (let i = 0; i < entries.length; i++) {
46623
+ const outcome = validateEntry(entries[i], callId);
46624
+ if ("range" in outcome) ranges.push(outcome.range);
46625
+ else {
46626
+ invalid++;
46627
+ reasons.push(`entry ${i}: ${outcome.reason}`);
46628
+ }
46623
46629
  }
46624
- return { ranges, invalid };
46630
+ return { ranges, invalid, reasons };
46625
46631
  }
46626
46632
  function validateEntry(entry, callId) {
46627
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
46633
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { reason: "not an object" };
46628
46634
  const e = entry;
46629
46635
  const start = stringOr(e["startRef"]) ?? stringOr(e["startId"]) ?? stringOr(e["messageId"]);
46630
46636
  const end = stringOr(e["endRef"]) ?? stringOr(e["endId"]) ?? stringOr(e["messageId"]);
46631
- if (start === void 0 || end === void 0) return null;
46637
+ if (start === void 0 || end === void 0) {
46638
+ return { reason: "missing range bounds (need startRef/startId and endRef/endId)" };
46639
+ }
46632
46640
  const summary = stringOr(e["summary"]);
46633
- if (summary === void 0) return null;
46641
+ if (summary === void 0) return { reason: "missing summary" };
46634
46642
  const range = { startRef: start, endRef: end, summary };
46635
46643
  const topic = stringOr(e["topic"]);
46636
46644
  if (topic !== void 0) range.topic = topic;
46637
46645
  const maxChars = e["summaryMaxChars"];
46638
46646
  if (typeof maxChars === "number" && Number.isFinite(maxChars)) range.summaryMaxChars = maxChars;
46639
46647
  if (callId !== void 0) range.compressCallId = callId;
46640
- return range;
46648
+ return { range };
46641
46649
  }
46642
46650
  function stringOr(value) {
46643
46651
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -49260,11 +49268,16 @@ var StateStore = class {
49260
49268
  legacyFn;
49261
49269
  relPathFn;
49262
49270
  validateFn;
49271
+ retryAttempts;
49272
+ retryBaseMs;
49273
+ retryMaxMs;
49263
49274
  timers = /* @__PURE__ */ new Map();
49264
49275
  pending = /* @__PURE__ */ new Map();
49265
49276
  writeChains = /* @__PURE__ */ new Map();
49266
49277
  /** id → absolute path, populated by writes and loadAll. */
49267
49278
  discovered = /* @__PURE__ */ new Map();
49279
+ /** id → cumulative write-failure count, for rate-limited alerting. */
49280
+ failCounts = /* @__PURE__ */ new Map();
49268
49281
  /** Monotonic counter for unique temp filenames within a process. */
49269
49282
  tmpSeq = 0;
49270
49283
  constructor(opts) {
@@ -49277,6 +49290,9 @@ var StateStore = class {
49277
49290
  this.relPathFn = opts.relPath;
49278
49291
  this.legacyFn = opts.legacy;
49279
49292
  this.validateFn = opts.validate ?? defaultValidate;
49293
+ this.retryAttempts = Math.max(1, opts.retryAttempts ?? 6);
49294
+ this.retryBaseMs = Math.max(1, opts.retryBaseMs ?? 50);
49295
+ this.retryMaxMs = Math.max(this.retryBaseMs, opts.retryMaxMs ?? 1600);
49280
49296
  }
49281
49297
  /** Debounced save. Coalesces bursts; the builder runs at write time, so
49282
49298
  * the freshest state is always persisted. Never throws. */
@@ -49336,32 +49352,42 @@ var StateStore = class {
49336
49352
  } catch (e) {
49337
49353
  this.log("warn", `[persist] could not create dir ${path4.dirname(file)}: ${errText(e)}`);
49338
49354
  }
49339
- const tmp = this.tempPath(file);
49340
- try {
49341
- fs.writeFileSync(tmp, data, "utf8");
49342
- let lastErr;
49343
- for (let attempt = 0; attempt < 3; attempt++) {
49355
+ let lastErr;
49356
+ for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
49357
+ const tmp = this.tempPath(file);
49358
+ try {
49359
+ fs.writeFileSync(tmp, data, "utf8");
49360
+ fs.renameSync(tmp, file);
49361
+ lastErr = void 0;
49362
+ break;
49363
+ } catch (e) {
49364
+ lastErr = e;
49344
49365
  try {
49345
- fs.renameSync(tmp, file);
49346
- lastErr = void 0;
49347
- break;
49348
- } catch (e) {
49349
- lastErr = e;
49350
- const code = e.code;
49351
- if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") break;
49352
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
49366
+ fs.unlinkSync(tmp);
49367
+ } catch {
49353
49368
  }
49369
+ const code = e.code;
49370
+ if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") break;
49371
+ if (attempt === this.retryAttempts - 1) break;
49372
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.backoffMs(attempt));
49354
49373
  }
49355
- if (lastErr) throw lastErr;
49374
+ }
49375
+ if (!lastErr) {
49376
+ this.discovered.set(id, file);
49377
+ this.clearFailure(id);
49378
+ this.removeSpillSync(file);
49356
49379
  return true;
49357
- } catch (e) {
49358
- this.log("error", `[persist] flushSync failed for ${id}: ${errText(e)}`);
49359
- try {
49360
- fs.unlinkSync(tmp);
49361
- } catch {
49362
- }
49363
- return false;
49364
49380
  }
49381
+ let spillPath = null;
49382
+ try {
49383
+ const spill = this.spillPathFor(file);
49384
+ fs.writeFileSync(spill, data, "utf8");
49385
+ spillPath = spill;
49386
+ this.discovered.set(id, spill);
49387
+ } catch {
49388
+ }
49389
+ this.recordFailure(id, lastErr, spillPath);
49390
+ return spillPath !== null;
49365
49391
  }
49366
49392
  /** Load one record. Checks the discovered path (from a prior
49367
49393
  * write/loadAll), an optional relative-path hint, and the flat default
@@ -49395,13 +49421,27 @@ var StateStore = class {
49395
49421
  for (const file of files) {
49396
49422
  const envelope = this.readEnvelope(file);
49397
49423
  if (!envelope) continue;
49398
- const expected = path4.basename(this.relPathOf(envelope.id, envelope.payload)) === path4.basename(file) || flatFileNameFor(envelope.id) === path4.basename(file);
49399
- if (!expected) {
49424
+ const base = path4.basename(file);
49425
+ const relBase = path4.basename(this.relPathOf(envelope.id, envelope.payload));
49426
+ const flatBase = flatFileNameFor(envelope.id);
49427
+ let owner = null;
49428
+ if (base === relBase || base === flatBase) {
49429
+ owner = envelope.id;
49430
+ } else if (base.endsWith(".fb.json")) {
49431
+ const canonicalBase = `${base.slice(0, -".fb.json".length)}.json`;
49432
+ if (canonicalBase === relBase || canonicalBase === flatBase) {
49433
+ owner = envelope.id;
49434
+ }
49435
+ }
49436
+ if (owner === null) {
49400
49437
  this.log("warn", `[persist] skipping ${rel(file, this.dir)}: filename does not match record id`);
49401
49438
  continue;
49402
49439
  }
49403
- this.discovered.set(envelope.id, file);
49404
- out.set(envelope.id, envelope);
49440
+ const existing = out.get(owner);
49441
+ if (!existing || envelope.savedAt >= existing.savedAt) {
49442
+ out.set(owner, envelope);
49443
+ this.discovered.set(owner, file);
49444
+ }
49405
49445
  }
49406
49446
  return out;
49407
49447
  }
@@ -49455,14 +49495,25 @@ var StateStore = class {
49455
49495
  this.log("warn", `[persist] could not create dir ${path4.dirname(file)}: ${errText(e)}`);
49456
49496
  }
49457
49497
  const tmp = this.tempPath(file);
49498
+ const env = this.envelope(id, payload);
49458
49499
  try {
49459
- await fsp.writeFile(tmp, JSON.stringify(this.envelope(id, payload)), "utf8");
49460
- await renameWithRetry(tmp, file);
49500
+ await fsp.writeFile(tmp, JSON.stringify(env), "utf8");
49501
+ await this.renameWithRetry(tmp, file);
49461
49502
  this.discovered.set(id, file);
49503
+ this.clearFailure(id);
49504
+ await this.removeSpill(file);
49462
49505
  } catch (e) {
49463
49506
  await fsp.unlink(tmp).catch(() => {
49464
49507
  });
49465
- this.log("error", `[persist] write failed for ${id}: ${errText(e)}`);
49508
+ let spillPath = null;
49509
+ try {
49510
+ const spill = this.spillPathFor(file);
49511
+ await fsp.writeFile(spill, JSON.stringify(env), "utf8");
49512
+ spillPath = spill;
49513
+ this.discovered.set(id, spill);
49514
+ } catch {
49515
+ }
49516
+ this.recordFailure(id, e, spillPath);
49466
49517
  throw e;
49467
49518
  }
49468
49519
  }
@@ -49490,6 +49541,67 @@ var StateStore = class {
49490
49541
  const seq = this.tmpSeq++;
49491
49542
  return path4.join(path4.dirname(dest), `.tmp-${path4.basename(dest, ".json")}-${process.pid}-${seq}`);
49492
49543
  }
49544
+ backoffMs(attempt) {
49545
+ return Math.min(this.retryBaseMs * 2 ** attempt, this.retryMaxMs);
49546
+ }
49547
+ /** Side file for a record whose canonical write keeps failing: the
49548
+ * canonical name with a `.fb` (fallback) infix, e.g. `a.json` →
49549
+ * `a.fb.json`. One slot per id, overwritten on each spill, so a stuck
49550
+ * lock never accumulates files. Ends in `.json` so loadAll discovers it. */
49551
+ spillPathFor(file) {
49552
+ const base = path4.basename(file);
49553
+ const dot = base.lastIndexOf(".");
49554
+ const stem2 = dot > 0 ? base.slice(0, dot) : base;
49555
+ const ext = dot > 0 ? base.slice(dot) : ".json";
49556
+ return path4.join(path4.dirname(file), `${stem2}.fb${ext}`);
49557
+ }
49558
+ /** Remove a stale spill after a successful canonical write (best-effort). */
49559
+ async removeSpill(file) {
49560
+ await fsp.unlink(this.spillPathFor(file)).catch(() => {
49561
+ });
49562
+ }
49563
+ removeSpillSync(file) {
49564
+ try {
49565
+ fs.unlinkSync(this.spillPathFor(file));
49566
+ } catch {
49567
+ }
49568
+ }
49569
+ /** Rate-limited failure alerting: log on the first failure and at each
49570
+ * power-of-two count (1,2,4,8,…), so a long lock yields ~log2(N) lines
49571
+ * instead of one per write. Includes the spill path so the operator can
49572
+ * see where the data landed. */
49573
+ recordFailure(id, err2, spillPath) {
49574
+ const count = (this.failCounts.get(id) ?? 0) + 1;
49575
+ this.failCounts.set(id, count);
49576
+ const alertAt = count === 1 || count >= 2 && (count & count - 1) === 0;
49577
+ if (!alertAt) return;
49578
+ const where = spillPath ? `; data spilled to ${rel(spillPath, this.dir)}` : "; SPILLOVER ALSO FAILED \u2014 data at risk";
49579
+ this.log(count === 1 ? "error" : "warn", `[persist] write failed for ${id} (total ${count}x): ${errText(err2)}${where}`);
49580
+ }
49581
+ clearFailure(id) {
49582
+ this.failCounts.delete(id);
49583
+ }
49584
+ /** fs.rename with exponential-backoff retries on Windows transient locks
49585
+ * (EPERM/EBUSY/EACCES from AV scan, search indexer, SMB). Other errors
49586
+ * are real and rethrown immediately. */
49587
+ async renameWithRetry(src, dest) {
49588
+ let lastErr;
49589
+ for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
49590
+ try {
49591
+ await fsp.rename(src, dest);
49592
+ return;
49593
+ } catch (e) {
49594
+ lastErr = e;
49595
+ const code = e.code;
49596
+ if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
49597
+ if (attempt === this.retryAttempts - 1) throw e;
49598
+ const { promise, resolve } = Promise.withResolvers();
49599
+ setTimeout(resolve, this.backoffMs(attempt));
49600
+ await promise;
49601
+ }
49602
+ }
49603
+ throw lastErr;
49604
+ }
49493
49605
  /** Parse + validate one file. Corrupt or invalid records return null
49494
49606
  * (logged) instead of throwing — load paths must never block boot. */
49495
49607
  readEnvelope(file) {
@@ -49565,23 +49677,6 @@ function isEnvelopeLike(value) {
49565
49677
  function flatFileNameFor(id) {
49566
49678
  return createHash2("sha256").update(id, "utf8").digest("hex").slice(0, 24) + ".json";
49567
49679
  }
49568
- async function renameWithRetry(src, dest) {
49569
- let lastErr;
49570
- for (let attempt = 0; attempt < 3; attempt++) {
49571
- try {
49572
- await fsp.rename(src, dest);
49573
- return;
49574
- } catch (e) {
49575
- lastErr = e;
49576
- const code = e.code;
49577
- if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
49578
- const { promise, resolve } = Promise.withResolvers();
49579
- setTimeout(resolve, 20 * (attempt + 1));
49580
- await promise;
49581
- }
49582
- }
49583
- throw lastErr;
49584
- }
49585
49680
  function errText(e) {
49586
49681
  return e instanceof Error ? e.message : String(e);
49587
49682
  }
@@ -49590,6 +49685,57 @@ function rel(p2, base) {
49590
49685
  return r && !r.startsWith("..") ? r : p2;
49591
49686
  }
49592
49687
 
49688
+ // src/persist-eperm.ts
49689
+ var LOCK_CODES = /\b(EPERM|EBUSY|EACCES)\b/;
49690
+ var WRITE_FAIL_RE = /^\[persist\] write failed for (.+?) \(total (\d+)x\): (.+)$/;
49691
+ var PersistEpermAlert = class {
49692
+ dir;
49693
+ threshold;
49694
+ repeatMs;
49695
+ platform;
49696
+ now;
49697
+ onAlert;
49698
+ hasAlerted = false;
49699
+ alertedAt = 0;
49700
+ constructor(opts) {
49701
+ this.dir = opts.dir;
49702
+ this.threshold = opts.threshold ?? 5;
49703
+ this.repeatMs = opts.repeatMs ?? 0;
49704
+ this.platform = opts.platform ?? process.platform;
49705
+ this.now = opts.now ?? Date.now;
49706
+ this.onAlert = opts.onAlert ?? ((d, c) => log("warn", buildAlertMessage(d, c)));
49707
+ }
49708
+ // Returns true iff an alert was emitted. Runs on every kernel log line, so
49709
+ // the non-matching cases bail before any allocation.
49710
+ observe(level, msg) {
49711
+ if (this.platform !== "win32") return false;
49712
+ if (level !== "error" && level !== "warn") return false;
49713
+ const m2 = WRITE_FAIL_RE.exec(msg);
49714
+ if (!m2) return false;
49715
+ const count = Number(m2[2]);
49716
+ if (count < this.threshold) return false;
49717
+ if (!LOCK_CODES.test(m2[3])) return false;
49718
+ if (!this.shouldAlert()) return false;
49719
+ this.hasAlerted = true;
49720
+ this.alertedAt = this.now();
49721
+ this.onAlert(this.dir, count);
49722
+ return true;
49723
+ }
49724
+ shouldAlert() {
49725
+ if (!this.hasAlerted) return true;
49726
+ if (this.repeatMs <= 0) return false;
49727
+ return this.now() - this.alertedAt >= this.repeatMs;
49728
+ }
49729
+ };
49730
+ function buildAlertMessage(dir, count) {
49731
+ return [
49732
+ `[persist] session dir ${dir} failed ${count} consecutive writes (EPERM \u2014 rename is locked).`,
49733
+ "Likely cause: Windows Defender / antivirus / indexer / a sync tool is locking the directory.",
49734
+ `Fix: add ${dir} to Windows Defender exclusions (Settings \u2192 Virus & threat protection \u2192 Manage settings \u2192 Exclusions \u2192 Add an exclusion),`,
49735
+ "and make sure no OneDrive / sync tool is syncing this path."
49736
+ ].join("\n");
49737
+ }
49738
+
49593
49739
  // src/persist.ts
49594
49740
  var PERSIST_VERSION = 3;
49595
49741
  function mergeState(parsed) {
@@ -49626,12 +49772,21 @@ var SessionStore = class {
49626
49772
  const debounceMs = opts?.debounceMs ?? defaultDebounce();
49627
49773
  this.enabled = (opts?.enabled ?? true) && debounceMs >= 0;
49628
49774
  this.dir = opts?.dir ?? defaultDir();
49775
+ const baseLog = opts?.log ?? defaultLogger;
49776
+ const epermAlert = new PersistEpermAlert({
49777
+ dir: this.dir,
49778
+ threshold: epermAlertThreshold(),
49779
+ repeatMs: epermAlertRepeatMs()
49780
+ });
49629
49781
  this.store = new StateStore({
49630
49782
  dir: this.dir,
49631
49783
  version: PERSIST_VERSION,
49632
49784
  debounceMs: Math.max(0, debounceMs),
49633
49785
  enabled: this.enabled,
49634
- log: opts?.log ?? defaultLogger,
49786
+ log: (level, msg) => {
49787
+ epermAlert.observe(level, msg);
49788
+ baseLog(level, msg);
49789
+ },
49635
49790
  relPath: (id, payload) => relPathFor(id, payload.meta?.protocol ?? payload.protocol, payload.meta?.upstreamOrigin ?? payload.upstreamOrigin),
49636
49791
  // Adopt the pre-envelope flat format this store itself wrote
49637
49792
  // before the kernel extraction (and every v1/v2 file before it).
@@ -49840,6 +49995,22 @@ function persistEnabled() {
49840
49995
  if (env === "0" || env === "false") return false;
49841
49996
  return true;
49842
49997
  }
49998
+ function epermAlertThreshold() {
49999
+ const env = process.env.BILI_PERSIST_EPERM_ALERT_THRESHOLD;
50000
+ if (env) {
50001
+ const n = Number.parseInt(env, 10);
50002
+ if (Number.isFinite(n) && n > 0) return n;
50003
+ }
50004
+ return 5;
50005
+ }
50006
+ function epermAlertRepeatMs() {
50007
+ const env = process.env.BILI_PERSIST_EPERM_ALERT_REPEAT_MS;
50008
+ if (env) {
50009
+ const n = Number.parseInt(env, 10);
50010
+ if (Number.isFinite(n) && n >= 0) return n;
50011
+ }
50012
+ return 0;
50013
+ }
49843
50014
  function defaultLogger(level, m2) {
49844
50015
  log(level, m2);
49845
50016
  }
@@ -50005,9 +50176,9 @@ async function flushAllSessions() {
50005
50176
  function parseCompressInput(input, callId) {
50006
50177
  const { ranges, diagnostics } = parseCompressArgs(input, { callId });
50007
50178
  if (!diagnostics.ok && diagnostics.kind !== "ok") {
50008
- log("warn", `[acp-compress-input] rejected: kind=${diagnostics.kind} invalidItems=${diagnostics.invalidItems}${diagnostics.keys ? ` keys=[${diagnostics.keys.join(",")}]` : ""}${diagnostics.length !== void 0 ? ` len=${diagnostics.length}` : ""}`);
50179
+ log("warn", `[acp-compress-input] rejected: kind=${diagnostics.kind} invalidItems=${diagnostics.invalidItems}${diagnostics.keys ? ` keys=[${diagnostics.keys.join(",")}]` : ""}${diagnostics.length !== void 0 ? ` len=${diagnostics.length}` : ""}${diagnostics.invalidReasons && diagnostics.invalidReasons.length > 0 ? ` reasons=[${diagnostics.invalidReasons.join(" | ")}]` : ""}`);
50009
50180
  }
50010
- return ranges;
50181
+ return { ranges, diagnostics };
50011
50182
  }
50012
50183
  var STAGED_COMPRESS_GUIDANCE = "\n\n[Smooth-transition guidance: when you compress, prefer a SMALLER, TAIL-biased range \u2014 compress the most recent large content and keep the stable prefix (the earliest messages) intact. A large single rewrite changes the request shape sharply and can trip provider risk-control; smaller tail-biased folds keep the prefix cache alive and the transition gentle.]";
50013
50184
  function withStagedCompressGuidance(text) {
@@ -50103,17 +50274,23 @@ function normalizeSseLineEndings(buf) {
50103
50274
  var PAIRED = /\x3cacp\s[^<>]{0,256}>([^<>]{0,64})<\/acp>/;
50104
50275
  var LONE_OPEN = /\x3cacp(?:\s[^<>]{0,256})?>/;
50105
50276
  var LONE_CLOSE = /<\/acp(?=[\s>])[^<>]{0,32}>/;
50106
- var PARTIAL_TAIL = /(\x3cacp\s[^<>]*|<\/?a?c?p?)$/;
50277
+ var PARTIAL_TAIL = /(\x3cacp\s[^<>]*|\x3c\/acp(?:\s[^<>]*)?|<\/?a?c?p?)$/;
50107
50278
  var TRUNC_OPEN = /\x3cacp\s[^<>]*$/;
50279
+ var TRUNC_CLOSE = /\x3c\/acp(?:\s[^<>]*)?$/;
50108
50280
  var CLOSE_TAG = "</acp";
50109
50281
  var HOLD_LIMIT = 128;
50110
50282
  var TAG_OPEN_CAP = 4096;
50111
50283
  var SWALLOW_CAP = 80;
50112
50284
  function stripAcpTags(text) {
50113
- return text.replace(new RegExp(PAIRED.source, "g"), "").replace(new RegExp(LONE_OPEN.source, "g"), "").replace(new RegExp(LONE_CLOSE.source, "g"), "").replace(new RegExp(TRUNC_OPEN.source), "");
50285
+ return text.replace(new RegExp(PAIRED.source, "g"), "").replace(new RegExp(LONE_OPEN.source, "g"), "").replace(new RegExp(LONE_CLOSE.source, "g"), "").replace(new RegExp(TRUNC_OPEN.source), "").replace(new RegExp(TRUNC_CLOSE.source), "");
50114
50286
  }
50287
+ var RENDER_TAG_DETECT = /\x3c\/?acp(?=[\s>])|\\u003c\/?acp(?=[\s>\\])/;
50115
50288
  function containsRenderTagText(s3) {
50116
- return /\x3c\/?acp(?=[\s>])/.test(s3) || /\\u003c\/?acp(?=[\s>\\])/.test(s3);
50289
+ return RENDER_TAG_DETECT.test(s3);
50290
+ }
50291
+ var TOOL_CALL_XML = /\x3c\/?(?:antml:)?(invoke|tool_calls|tool_call|parameter|parameters)\b[^<>]*\x3e|\\u003c\/?(?:antml:)?(invoke|tool_calls|tool_call|parameter|parameters)\b|\x3c\/?antml:[a-z_]+/i;
50292
+ function containsToolCallXmlFragment(s3) {
50293
+ return TOOL_CALL_XML.test(s3);
50117
50294
  }
50118
50295
  function createTagEchoFilter(onDrop) {
50119
50296
  let held = "";
@@ -50162,7 +50339,7 @@ function createTagEchoFilter(onDrop) {
50162
50339
  if (!m2) {
50163
50340
  const t = PARTIAL_TAIL.exec(buf);
50164
50341
  if (t) {
50165
- const definite = /^\x3cacp\s/.test(t[0]);
50342
+ const definite = /^\x3cacp\s/.test(t[0]) || /^\x3c\/acp/.test(t[0]);
50166
50343
  const cap = definite ? TAG_OPEN_CAP : HOLD_LIMIT;
50167
50344
  if (t[0].length <= cap) {
50168
50345
  held = t[0];
@@ -50209,6 +50386,11 @@ function createTagEchoFilter(onDrop) {
50209
50386
  drop(t[0]);
50210
50387
  return rest.slice(0, t.index);
50211
50388
  }
50389
+ const tc = new RegExp(TRUNC_CLOSE.source).exec(rest);
50390
+ if (tc) {
50391
+ drop(tc[0]);
50392
+ return rest.slice(0, tc.index);
50393
+ }
50212
50394
  return rest;
50213
50395
  },
50214
50396
  dropped() {
@@ -50291,10 +50473,15 @@ ${lines.join("\n\n")}`;
50291
50473
  function refNum2(ref) {
50292
50474
  return Number(ref.replace(/\D/g, "")) || 0;
50293
50475
  }
50294
- function applyRanges(ranges, ctx) {
50476
+ function applyRanges(parsed, ctx) {
50477
+ const { ranges, diagnostics } = parsed;
50295
50478
  if (ranges.length === 0) {
50296
50479
  ctx.log("[acp-proxy: compress call had no valid ranges; nothing compressed.]");
50297
- return "[Compression FAILED: no valid ranges parsed. compress requires a non-empty 'content' array of {startId, endId, summary} ranges, where startId/endId are mNNNNN message refs from the conversation. Re-issue the compress call with a valid content array.]";
50480
+ const reasons = diagnostics.invalidReasons?.slice(0, 8).map((r) => r.length > 200 ? r.slice(0, 200) + "..." : r) ?? [];
50481
+ const why = reasons.length > 0 ? ` Rejected entries:
50482
+ ${reasons.map((r) => `- ${r}`).join("\n")}` : "";
50483
+ return `[Compression FAILED: no valid ranges parsed (kind=${diagnostics.kind}, dropped=${diagnostics.invalidItems}).${why}
50484
+ compress requires a non-empty 'content' array of {startId, endId, summary} ranges, where startId/endId are mNNNNN message refs from the conversation. Re-issue the compress call with a valid content array.]`;
50298
50485
  }
50299
50486
  ctx.log(`[acp-proxy: compress requested ${ranges.length} range(s): ${ranges.map((r) => `${r.startRef}\u2013${r.endRef}`).join(", ")}]`);
50300
50487
  ctx.log(`[acp-proxy: ctx has ${ctx.messages.length} message(s), state has ${ctx.session.state.messageRefs?.byRef?.size ?? "?"} ref(s) mapped]`);
@@ -55420,6 +55607,28 @@ async function decodeRequestBody(contentEncoding, input, maxOutputBytes) {
55420
55607
  function bodyDumpEnabled() {
55421
55608
  return process.env.ACP_DUMP_BODY === "1";
55422
55609
  }
55610
+ var dumpFailCount = 0;
55611
+ var lastDumpFailLog = 0;
55612
+ function logDumpFailure(where, err2) {
55613
+ dumpFailCount++;
55614
+ const now = Date.now();
55615
+ if (dumpFailCount === 1 || now - lastDumpFailLog >= 6e4) {
55616
+ lastDumpFailLog = now;
55617
+ const msg = err2 instanceof Error ? err2.message : String(err2);
55618
+ log("warn", `[dump] ${where} failed (total ${dumpFailCount}x): ${msg}`);
55619
+ }
55620
+ }
55621
+ var unrecognizedPathCounts = /* @__PURE__ */ new Map();
55622
+ function logUnrecognizedPath(log2, url) {
55623
+ const key = maskUrlsInText(url.split("?")[0]);
55624
+ const n = (unrecognizedPathCounts.get(key) ?? 0) + 1;
55625
+ unrecognizedPathCounts.set(key, n);
55626
+ if (n <= 3) {
55627
+ log2("warn", `unrecognized path ${key} \u2014 not a known protocol (/chat/completions, /v1/messages, /responses, /responses/compact); forwarding unchanged`);
55628
+ } else if (n === 4) {
55629
+ log2("info", `unrecognized path ${key}: forwarding unchanged; further occurrences suppressed`);
55630
+ }
55631
+ }
55423
55632
  var BILI_HOP_HEADER = "x-bili-hop";
55424
55633
  function parseLauncherModelWindows(raw) {
55425
55634
  if (!raw) return {};
@@ -55867,7 +56076,8 @@ async function handle(req, res, opts, core, config, log2, instanceId) {
55867
56076
  ${hdrText}
55868
56077
 
55869
56078
  ${bodyBuffer.toString("utf8")}`);
55870
- } catch {
56079
+ } catch (err2) {
56080
+ logDumpFailure("INCOMING dump", err2);
55871
56081
  }
55872
56082
  }
55873
56083
  let reqConfig = config;
@@ -56072,7 +56282,7 @@ ${bodyBuffer.toString("utf8")}`);
56072
56282
  }
56073
56283
  if (!prepared) {
56074
56284
  if (protocol === null && !opts.passthrough) {
56075
- log2("warn", `unrecognized path ${maskUrlsInText(req.url ?? "")} \u2014 not a known protocol (/chat/completions, /v1/messages, /responses, /responses/compact); forwarding unchanged`);
56285
+ logUnrecognizedPath(log2, req.url ?? "");
56076
56286
  }
56077
56287
  await forward(req, res, opts, bodyBuffer, null, core, reqConfig, log2, route, instanceId, void 0);
56078
56288
  }
@@ -56697,7 +56907,8 @@ ${hdrText}
56697
56907
 
56698
56908
  ${bodyText}`);
56699
56909
  log2("info", `[debug] RAW request dump: ${reqPath}`);
56700
- } catch {
56910
+ } catch (err2) {
56911
+ logDumpFailure("REQ dump", err2);
56701
56912
  }
56702
56913
  }
56703
56914
  const dispatcher = proxyDispatcher(proxyUrl);
@@ -56745,7 +56956,8 @@ ${bodyText}`);
56745
56956
  ${hdrText}
56746
56957
  `);
56747
56958
  log2("info", `[debug] RAW response dump: ${resPath}`);
56748
- } catch {
56959
+ } catch (err2) {
56960
+ logDumpFailure("RES dump", err2);
56749
56961
  }
56750
56962
  }
56751
56963
  if (!upstream.ok) {
@@ -56881,12 +57093,16 @@ ${hdrText}
56881
57093
  systemPrompt,
56882
57094
  clientAbort.signal
56883
57095
  );
57096
+ let protocolFragmentWarned = false;
56884
57097
  for await (const chunk of loop) {
56885
57098
  if (res.destroyed || res.writableEnded) break;
56886
57099
  {
56887
57100
  const s3 = chunk.toString("utf8");
56888
57101
  if (s3.includes("<acp ") || s3.includes("</acp")) {
56889
57102
  log2("warn", `[${prepared.session.id}] tag echo: ${prepared.protocol} response stream contains <acp tag`);
57103
+ } else if (!protocolFragmentWarned && containsToolCallXmlFragment(s3)) {
57104
+ protocolFragmentWarned = true;
57105
+ log2("warn", `[${prepared.session.id}] tag echo: ${prepared.protocol} response stream contains tool-call XML fragment (possible tag echo; not stripped)`);
56890
57106
  }
56891
57107
  }
56892
57108
  res.write(chunk);
@@ -56994,7 +57210,7 @@ async function dumpStreamToFile(stream2, dir, name) {
56994
57210
  mkdirSync6(dir, { recursive: true });
56995
57211
  const ws2 = createWriteStream2(join4(dir, name));
56996
57212
  ws2.on("error", (e) => {
56997
- log("debug", `[dump] write stream error: ${e.message ?? e}`);
57213
+ logDumpFailure("SSE stream dump", e);
56998
57214
  });
56999
57215
  const reader = stream2.getReader();
57000
57216
  try {
@@ -57007,7 +57223,8 @@ async function dumpStreamToFile(stream2, dir, name) {
57007
57223
  reader.releaseLock();
57008
57224
  ws2.end();
57009
57225
  }
57010
- } catch {
57226
+ } catch (err2) {
57227
+ logDumpFailure("SSE stream dump", err2);
57011
57228
  }
57012
57229
  }
57013
57230
  function deriveTitle(messages) {