@wrongstack/core 0.306.3 → 0.306.4

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.
@@ -9828,6 +9828,24 @@ import {
9828
9828
  } from "@wrongstack/persistence";
9829
9829
 
9830
9830
  // src/security/secret-scrubber.ts
9831
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
9832
+ 'Key"',
9833
+ 'key"',
9834
+ 'KEY"',
9835
+ 'token"',
9836
+ 'Token"',
9837
+ 'TOKEN"',
9838
+ 'secret"',
9839
+ 'Secret"',
9840
+ 'SECRET"',
9841
+ 'password"',
9842
+ 'Password"',
9843
+ 'PASSWORD"',
9844
+ 'authorization"',
9845
+ 'Authorization"',
9846
+ 'bearer"',
9847
+ 'Bearer"'
9848
+ ];
9831
9849
  var PATTERNS = [
9832
9850
  // Anchored at the start where possible so partial matches inside larger
9833
9851
  // strings don't trigger false positives.
@@ -9930,6 +9948,30 @@ var PATTERNS = [
9930
9948
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
9931
9949
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
9932
9950
  },
9951
+ {
9952
+ type: "json_credential_key",
9953
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
9954
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
9955
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
9956
+ // "this text may hold a secret", every pattern then declined to match, and
9957
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
9958
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
9959
+ // never matched.
9960
+ //
9961
+ // Tool results are routinely serialised as JSON, and a credential with no
9962
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
9963
+ // has no other pattern that can catch it — this is the only thing standing
9964
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
9965
+ // the model's own context.
9966
+ //
9967
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
9968
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
9969
+ // the closing quote has to follow the word immediately.
9970
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
9971
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
9972
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
9973
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
9974
+ },
9933
9975
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
9934
9976
  // The plugin runtime carried 37 patterns while this scrubber — the one that
9935
9977
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -10012,9 +10054,12 @@ var PATTERNS = [
10012
10054
  anchor: "GOCSPX-"
10013
10055
  }
10014
10056
  ];
10015
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
10057
+ var SIMPLE_PATTERNS = PATTERNS.filter(
10058
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
10059
+ );
10016
10060
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
10017
10061
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
10062
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
10018
10063
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
10019
10064
  var SCRUB_CHUNK_BYTES = 64 * 1024;
10020
10065
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -10025,20 +10070,7 @@ var PATTERN_ANCHORS = [
10025
10070
  )
10026
10071
  )
10027
10072
  ];
10028
- var JSON_KEY_ANCHORS = [
10029
- '"apiKey"',
10030
- '"api_key"',
10031
- '"token"',
10032
- '"secret"',
10033
- '"password"',
10034
- '"authorization"',
10035
- '"bearer"',
10036
- '"private_key"',
10037
- '"access_token"',
10038
- '"refresh_token"',
10039
- '"client_secret"'
10040
- ];
10041
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
10073
+ var ALL_ANCHORS = PATTERN_ANCHORS;
10042
10074
  function hasCredentialAnchors(text) {
10043
10075
  for (const anchor of ALL_ANCHORS) {
10044
10076
  if (text.includes(anchor)) return true;
@@ -10087,6 +10119,9 @@ var DefaultSecretScrubber = class {
10087
10119
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
10088
10120
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
10089
10121
  });
10122
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
10123
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
10124
+ });
10090
10125
  return out;
10091
10126
  }
10092
10127
  /**
@@ -11017,6 +11052,7 @@ function isEmptyMessage(msg) {
11017
11052
  }
11018
11053
 
11019
11054
  // src/core/context.ts
11055
+ import { realpathSync } from "node:fs";
11020
11056
  import * as path13 from "node:path";
11021
11057
 
11022
11058
  // src/utils/tool-wire-compact.ts
@@ -11446,12 +11482,15 @@ var ConversationState = class {
11446
11482
  * cap determines the starting index for the sum but does not gate it.
11447
11483
  */
11448
11484
  overflowCount(arr) {
11449
- let drop = Context.MAX_MESSAGES > 0 ? Math.max(0, arr.length - Context.MAX_MESSAGES) : 0;
11450
- if (Context.MAX_MESSAGE_TOKENS <= 0) return this.protocolSafeDropCount(arr, drop);
11485
+ const contextClass = this.ctx.constructor;
11486
+ const maxMessages = contextClass.MAX_MESSAGES;
11487
+ const maxMessageTokens = contextClass.MAX_MESSAGE_TOKENS;
11488
+ let drop = maxMessages > 0 ? Math.max(0, arr.length - maxMessages) : 0;
11489
+ if (maxMessageTokens <= 0) return this.protocolSafeDropCount(arr, drop);
11451
11490
  let total = 0;
11452
11491
  for (let i = drop; i < arr.length; i++) total += arr[i]?._estTokens ?? 0;
11453
- if (total <= Context.MAX_MESSAGE_TOKENS) return this.protocolSafeDropCount(arr, drop);
11454
- while (drop < arr.length - 1 && total > Context.MAX_MESSAGE_TOKENS) {
11492
+ if (total <= maxMessageTokens) return this.protocolSafeDropCount(arr, drop);
11493
+ while (drop < arr.length - 1 && total > maxMessageTokens) {
11455
11494
  total -= arr[drop]?._estTokens ?? 0;
11456
11495
  drop++;
11457
11496
  }
@@ -12274,6 +12313,19 @@ var Context = class _Context {
12274
12313
  if (rel.startsWith("..") || path13.isAbsolute(rel)) {
12275
12314
  throw new Error(`Working directory "${resolved}" is outside project root "${root}"`);
12276
12315
  }
12316
+ let realTarget = resolved;
12317
+ let realRoot = root;
12318
+ try {
12319
+ realTarget = realpathSync.native(resolved);
12320
+ realRoot = realpathSync.native(root);
12321
+ } catch {
12322
+ }
12323
+ const realRel = path13.relative(realRoot, realTarget);
12324
+ if (realRel.startsWith("..") || path13.isAbsolute(realRel)) {
12325
+ throw new Error(
12326
+ `Working directory "${resolved}" resolves to "${realTarget}", outside project root "${realRoot}"`
12327
+ );
12328
+ }
12277
12329
  }
12278
12330
  const old = this.workingDir;
12279
12331
  this.workingDir = resolved;
@@ -16526,9 +16578,40 @@ async function readOrBuildShardManifestEntry(opts) {
16526
16578
  return entry;
16527
16579
  }
16528
16580
 
16529
- // src/storage/session-store/summary-builder.ts
16581
+ // src/storage/session-store/strict-empty-check.ts
16530
16582
  import { createReadStream as createReadStream3 } from "node:fs";
16531
16583
  import { createInterface as createInterface3 } from "node:readline";
16584
+ var EMPTY_SESSION_EVENT_TYPES = /* @__PURE__ */ new Set(["session_start", "session_resumed", "session_end"]);
16585
+ async function isStrictlyEmptySessionFile(file) {
16586
+ const input = createReadStream3(file, { encoding: "utf8" });
16587
+ const lines = createInterface3({ input, crlfDelay: Infinity });
16588
+ let sawSessionStart = false;
16589
+ try {
16590
+ for await (const line of lines) {
16591
+ if (!line.trim()) continue;
16592
+ let event;
16593
+ try {
16594
+ event = JSON.parse(line);
16595
+ } catch {
16596
+ return false;
16597
+ }
16598
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
16599
+ const type = event.type;
16600
+ if (typeof type !== "string" || !EMPTY_SESSION_EVENT_TYPES.has(type)) return false;
16601
+ if (type === "session_start") sawSessionStart = true;
16602
+ }
16603
+ } catch {
16604
+ return false;
16605
+ } finally {
16606
+ lines.close();
16607
+ input.destroy();
16608
+ }
16609
+ return sawSessionStart;
16610
+ }
16611
+
16612
+ // src/storage/session-store/summary-builder.ts
16613
+ import { createReadStream as createReadStream4 } from "node:fs";
16614
+ import { createInterface as createInterface4 } from "node:readline";
16532
16615
  async function summarizeSessionFile(opts) {
16533
16616
  return summarizeSessionEventSequence({
16534
16617
  id: opts.id,
@@ -16645,8 +16728,8 @@ async function summarizeSessionEventSequence(opts) {
16645
16728
  }
16646
16729
  }
16647
16730
  async function* iterateSessionEvents(file, secretScrubber) {
16648
- const stream = createReadStream3(file, { encoding: "utf8" });
16649
- const lines = createInterface3({ input: stream, crlfDelay: Infinity });
16731
+ const stream = createReadStream4(file, { encoding: "utf8" });
16732
+ const lines = createInterface4({ input: stream, crlfDelay: Infinity });
16650
16733
  try {
16651
16734
  for await (const line of lines) {
16652
16735
  if (!line.trim()) continue;
@@ -17542,6 +17625,10 @@ var DefaultSessionStore = class _DefaultSessionStore {
17542
17625
  await deleteSessionArtifacts({ rootDir: this.dir, id, jsonlPath });
17543
17626
  await this.writeTombstone(id);
17544
17627
  }
17628
+ async isEmpty(id) {
17629
+ const canonicalId = await this.resolveId(id);
17630
+ return isStrictlyEmptySessionFile(this.sessionPath(canonicalId, ".jsonl"));
17631
+ }
17545
17632
  async delete(id) {
17546
17633
  if (this.catalogClient) {
17547
17634
  const canonical = await this.resolveId(id);
@@ -21471,6 +21558,12 @@ function findExchangeStart(messages, userIndex) {
21471
21558
 
21472
21559
  // src/execution/auto-compaction-middleware.ts
21473
21560
  var LEVEL_RANK = { warn: 0, soft: 1, hard: 2 };
21561
+ function pressureLevelFor(load, thresholds) {
21562
+ if (load >= thresholds.hard) return "hard";
21563
+ if (load >= thresholds.soft) return "soft";
21564
+ if (load >= thresholds.warn) return "warn";
21565
+ return null;
21566
+ }
21474
21567
  var MAX_DIGEST_LOG_CHARS = 4e3;
21475
21568
  function truncateDigest(digest) {
21476
21569
  if (digest.length <= MAX_DIGEST_LOG_CHARS) return digest;
@@ -21516,8 +21609,19 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21516
21609
  * 1 / 2.5 = 0.4.
21517
21610
  */
21518
21611
  static GUARD_GATE_LOAD = 0.4;
21612
+ /**
21613
+ * How much the context must grow between two history-rewriting hygiene
21614
+ * passes, as a fraction of the available input window and as an absolute
21615
+ * floor. Every pass rewrites already-transmitted messages, which forces the
21616
+ * provider to re-cache the whole prompt; spacing the passes out is what lets
21617
+ * the conversation prefix stay cached for the turns in between.
21618
+ */
21619
+ static HYGIENE_GROWTH_RATIO = 0.15;
21620
+ static HYGIENE_MIN_GROWTH_TOKENS = 2e4;
21519
21621
  /** Tracks the most recent no-op attempt so we can avoid re-firing per turn. */
21520
21622
  lastNoopAttempt = null;
21623
+ /** Context size at the last hygiene pass; anchors the growth interval. */
21624
+ lastHygieneTokens = null;
21521
21625
  /**
21522
21626
  * Cached token estimate from the last handler() invocation. When the
21523
21627
  * message count and tool count haven't changed since the last estimate
@@ -21577,55 +21681,9 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21577
21681
  handler() {
21578
21682
  return async (ctx, next) => {
21579
21683
  if (!this._enabled) return next(ctx);
21580
- const rawHygiene = eliseAcknowledgedToolResults(ctx.messages, {
21581
- maxRetainedTokens: this.resolveToolResultRetention(ctx)
21582
- });
21583
- const receiptHygiene = collapseAcknowledgedToolReceipts(rawHygiene.messages, {
21584
- maxPairs: this.resolveToolReceiptRetention(ctx)
21585
- });
21586
- if (rawHygiene.changed || receiptHygiene.changed) {
21587
- ctx.state.replaceMessages(receiptHygiene.messages);
21588
- ctx.clearFileTracking();
21589
- this.invalidateTokenCaches(ctx);
21590
- }
21591
- const msgCount = ctx.messages.length;
21592
- const toolCount = (ctx.tools ?? []).length;
21593
- const revision = ctx.state?.revision ?? -1;
21594
- let tokens;
21595
- const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
21596
- const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
21597
- if (anchored !== null) {
21598
- tokens = anchored;
21599
- } else if (this._estimator) {
21600
- tokens = this._estimator(ctx);
21601
- } else if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
21602
- tokens = this._cachedTokens;
21603
- } else if (this.tryStashedTokens(ctx, msgCount, toolCount, revision) !== null) {
21604
- const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
21605
- const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
21606
- tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
21607
- this._cachedTokens = tokens;
21608
- this._cachedMsgCount = msgCount;
21609
- this._cachedToolCount = toolCount;
21610
- this._cachedRevision = revision;
21611
- this._cachedSystemRef = ctx.systemPrompt;
21612
- this._cachedToolsRef = ctx.tools;
21613
- } else {
21614
- tokens = estimateRequestTokensCalibrated(
21615
- ctx.messages,
21616
- ctx.systemPrompt,
21617
- ctx.tools ?? [],
21618
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21619
- ).total;
21620
- this._cachedTokens = tokens;
21621
- this._cachedMsgCount = msgCount;
21622
- this._cachedToolCount = toolCount;
21623
- this._cachedRevision = revision;
21624
- this._cachedSystemRef = ctx.systemPrompt;
21625
- this._cachedToolsRef = ctx.tools;
21626
- }
21684
+ let tokens = this.estimateContextTokens(ctx);
21627
21685
  const runtimeMaxContext = effectiveMaxContext(ctx, this._maxContext);
21628
- const budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21686
+ let budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21629
21687
  const calibratedLoad = budget.load;
21630
21688
  const policy = this.policyProvider?.(ctx);
21631
21689
  const thresholds = policy?.thresholds ?? {
@@ -21639,22 +21697,27 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21639
21697
  });
21640
21698
  const aggressiveOn = policy?.aggressiveOn ?? this.aggressiveOn;
21641
21699
  const targetLoad = normalizeTargetLoad(policy?.targetLoad, adaptiveThresholds);
21642
- let load = calibratedLoad;
21643
- if (calibratedLoad >= _AutoCompactionMiddleware.GUARD_GATE_LOAD) {
21644
- const guardTotal = estimateRequestTokensUpperBound(
21645
- ctx.messages,
21646
- ctx.systemPrompt,
21647
- ctx.tools ?? [],
21648
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21649
- ).total;
21650
- const guardLoad = guardTotal / budget.availableInputTokens;
21651
- if (guardLoad > load) load = guardLoad;
21652
- }
21653
- const level = load >= adaptiveThresholds.hard ? "hard" : load >= adaptiveThresholds.soft ? "soft" : load >= adaptiveThresholds.warn ? "warn" : null;
21700
+ let load = this.applySendGuard(ctx, calibratedLoad, budget.availableInputTokens);
21701
+ let level = pressureLevelFor(load, adaptiveThresholds);
21654
21702
  if (!level) {
21655
21703
  this.lastNoopAttempt = null;
21656
21704
  return next(ctx);
21657
21705
  }
21706
+ if (this.shouldRunHygiene(level, tokens, budget.availableInputTokens)) {
21707
+ const changed = this.runHistoryHygiene(ctx);
21708
+ tokens = changed ? this.estimateContextTokens(ctx) : tokens;
21709
+ this.lastHygieneTokens = tokens;
21710
+ if (changed) {
21711
+ budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21712
+ load = this.applySendGuard(ctx, budget.load, budget.availableInputTokens);
21713
+ const relevelled = pressureLevelFor(load, adaptiveThresholds);
21714
+ if (!relevelled) {
21715
+ this.lastNoopAttempt = null;
21716
+ return next(ctx);
21717
+ }
21718
+ level = relevelled;
21719
+ }
21720
+ }
21658
21721
  if (this.shouldSkipNoopRetry(level, tokens)) {
21659
21722
  return next(ctx);
21660
21723
  }
@@ -21671,6 +21734,123 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21671
21734
  return next(ctx);
21672
21735
  };
21673
21736
  }
21737
+ /**
21738
+ * Full-request token total for the current context.
21739
+ *
21740
+ * Reuses the last estimate when the context hasn't grown since the previous
21741
+ * check — common in autonomous idle loops. The cached value is invalidated
21742
+ * whenever messages or tools change.
21743
+ *
21744
+ * IMPORTANT: the cache is only valid for the deterministic
21745
+ * `estimateRequestTokensCalibrated` path (messages+system+tools → fixed
21746
+ * output). When a custom `_estimator` is provided (e.g. in tests with a
21747
+ * mutable closure, or a dynamic policy provider), always call it fresh — the
21748
+ * estimator owns its own semantics and the middleware cannot safely cache its
21749
+ * result across calls.
21750
+ */
21751
+ estimateContextTokens(ctx) {
21752
+ const msgCount = ctx.messages.length;
21753
+ const toolCount = (ctx.tools ?? []).length;
21754
+ const revision = ctx.state?.revision ?? -1;
21755
+ const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
21756
+ const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
21757
+ if (anchored !== null) return anchored;
21758
+ if (this._estimator) return this._estimator(ctx);
21759
+ if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
21760
+ return this._cachedTokens;
21761
+ }
21762
+ const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
21763
+ let tokens;
21764
+ if (stashed !== null) {
21765
+ const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
21766
+ tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
21767
+ } else {
21768
+ tokens = estimateRequestTokensCalibrated(
21769
+ ctx.messages,
21770
+ ctx.systemPrompt,
21771
+ ctx.tools ?? [],
21772
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21773
+ ).total;
21774
+ }
21775
+ this._cachedTokens = tokens;
21776
+ this._cachedMsgCount = msgCount;
21777
+ this._cachedToolCount = toolCount;
21778
+ this._cachedRevision = revision;
21779
+ this._cachedSystemRef = ctx.systemPrompt;
21780
+ this._cachedToolsRef = ctx.tools;
21781
+ return tokens;
21782
+ }
21783
+ /**
21784
+ * Never-undercount send guard.
21785
+ *
21786
+ * The calibrated estimate can under-count dense content (CJK, base64,
21787
+ * minified) by >1.5×, which would let an over-limit request slip past the
21788
+ * thresholds and reach the provider. Once the calibrated load is high enough
21789
+ * that even the max density factor (2.5×) *could* overflow (load ≥ 1/2.5 =
21790
+ * 0.4), re-check with the upper-bound estimator and escalate to whichever
21791
+ * load is larger. Below 0.4 an overflow is arithmetically impossible, so the
21792
+ * extra scan is skipped.
21793
+ */
21794
+ applySendGuard(ctx, calibratedLoad, availableInputTokens) {
21795
+ if (calibratedLoad < _AutoCompactionMiddleware.GUARD_GATE_LOAD) return calibratedLoad;
21796
+ const guardTotal = estimateRequestTokensUpperBound(
21797
+ ctx.messages,
21798
+ ctx.systemPrompt,
21799
+ ctx.tools ?? [],
21800
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21801
+ ).total;
21802
+ const guardLoad = guardTotal / availableInputTokens;
21803
+ return guardLoad > calibratedLoad ? guardLoad : calibratedLoad;
21804
+ }
21805
+ /**
21806
+ * Rewrite acknowledged tool protocol in place.
21807
+ *
21808
+ * Tool results are protocol inputs for the immediately following model
21809
+ * response, not unlimited durable prompt memory. Once a later assistant
21810
+ * message proves the provider consumed them, keep a mode-sized raw window and
21811
+ * replace the rest with semantic head/tail receipts, then fold fully-aged
21812
+ * pairs into the history digest.
21813
+ *
21814
+ * Every one of those edits touches a message the provider has already seen,
21815
+ * so each call costs a full prompt re-cache — it belongs behind
21816
+ * {@link shouldRunHygiene}, never on the per-turn path. Content staleness is
21817
+ * not a reason to bypass that gate: the edit/replace tools guard against
21818
+ * acting on an out-of-date read with their own mtime + sha-256 check.
21819
+ *
21820
+ * @returns whether the conversation was rewritten.
21821
+ */
21822
+ runHistoryHygiene(ctx) {
21823
+ const raw = eliseAcknowledgedToolResults(ctx.messages, {
21824
+ maxRetainedTokens: this.resolveToolResultRetention(ctx)
21825
+ });
21826
+ const receipts = collapseAcknowledgedToolReceipts(raw.messages, {
21827
+ maxPairs: this.resolveToolReceiptRetention(ctx)
21828
+ });
21829
+ if (!raw.changed && !receipts.changed) return false;
21830
+ ctx.state.replaceMessages(receipts.messages);
21831
+ ctx.clearFileTracking();
21832
+ this.invalidateTokenCaches(ctx);
21833
+ return true;
21834
+ }
21835
+ /**
21836
+ * Whether the history-rewriting hygiene pass may run this turn.
21837
+ *
21838
+ * Hard pressure always runs it — staying under the window outranks caching.
21839
+ * Otherwise it runs at most once per growth interval, so the conversation
21840
+ * stays append-only (and therefore cacheable by the provider) in between.
21841
+ */
21842
+ shouldRunHygiene(level, tokens, availableInputTokens) {
21843
+ if (level === "hard") return true;
21844
+ const last = this.lastHygieneTokens;
21845
+ if (last === null) return true;
21846
+ const anchor = Math.min(last, tokens);
21847
+ this.lastHygieneTokens = anchor;
21848
+ const interval = Math.max(
21849
+ _AutoCompactionMiddleware.HYGIENE_MIN_GROWTH_TOKENS,
21850
+ Math.floor(availableInputTokens * _AutoCompactionMiddleware.HYGIENE_GROWTH_RATIO)
21851
+ );
21852
+ return tokens - anchor >= interval;
21853
+ }
21674
21854
  /**
21675
21855
  * H1: try to read a pre-computed token total from `ctx.lastRequestTokens`
21676
21856
  * (set by the agent loop's pre-flight or its restash in emitContextPct).
@@ -26767,26 +26947,29 @@ var DefaultProviderRunner = class {
26767
26947
  // src/execution/retry-policy.ts
26768
26948
  import { randomInt } from "node:crypto";
26769
26949
  var MAX_RETRY_AFTER_MS = 6e4;
26950
+ var MODEL_RETRIES = 3;
26770
26951
  var MAX_ATTEMPTS_BY_KIND = {
26771
- rate_limit: 5,
26952
+ rate_limit: MODEL_RETRIES,
26953
+ overloaded: MODEL_RETRIES,
26954
+ server: MODEL_RETRIES,
26955
+ timeout: MODEL_RETRIES,
26956
+ network: MODEL_RETRIES,
26957
+ stream_hang: MODEL_RETRIES,
26772
26958
  quota_exhausted: 0,
26773
- stream_hang: 2,
26774
- // proxy-level timeout — retrying 5x wastes ~40s before fallback kicks in
26775
- overloaded: 3,
26776
- server: 3,
26777
- timeout: 2,
26778
- network: 2,
26779
26959
  auth: 0,
26780
26960
  invalid_request: 0,
26781
26961
  context_overflow: 0,
26782
26962
  content_filter: 0,
26783
26963
  unknown: 0
26784
26964
  };
26965
+ var FAILOVER_RETRY_AFTER_MS = 15e3;
26785
26966
  var DefaultRetryPolicy = class {
26786
26967
  shouldRetry(err, attempt) {
26787
26968
  const isProviderErr = err instanceof ProviderError || ProviderError.isProviderError(err);
26788
26969
  if (isProviderErr) {
26789
26970
  if (!err.retryable) return false;
26971
+ const hint = retryAfterMsFromError(err);
26972
+ if (hint !== void 0 && hint >= FAILOVER_RETRY_AFTER_MS) return false;
26790
26973
  return attempt < this.maxAttempts(err);
26791
26974
  }
26792
26975
  const msg = err.message ?? "";
@@ -32074,7 +32257,7 @@ var AutoApprovePermissionPolicy = class _AutoApprovePermissionPolicy {
32074
32257
  };
32075
32258
 
32076
32259
  // src/security/permission-helpers.ts
32077
- import { realpathSync } from "node:fs";
32260
+ import { realpathSync as realpathSync2 } from "node:fs";
32078
32261
  import * as path38 from "node:path";
32079
32262
  function matchesTrust(patterns, subject) {
32080
32263
  return patterns.includes(subject) || matchAny(patterns, subject);
@@ -32159,7 +32342,7 @@ function realpathOfNearestExisting(p) {
32159
32342
  const tail = [];
32160
32343
  for (; ; ) {
32161
32344
  try {
32162
- return tail.length === 0 ? realpathSync(probe) : path38.join(realpathSync(probe), ...tail);
32345
+ return tail.length === 0 ? realpathSync2(probe) : path38.join(realpathSync2(probe), ...tail);
32163
32346
  } catch {
32164
32347
  const parent = path38.dirname(probe);
32165
32348
  if (parent === probe) return p;
@@ -33922,6 +34105,10 @@ var IN_PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
33922
34105
  "fallbackModels",
33923
34106
  "fallbackBridge",
33924
34107
  "fallbackProfiles",
34108
+ // The profile SELECTOR. No broader than its siblings: a repo that can write
34109
+ // `fallbackModels` and `fallbackProfiles` already controls the chain outright,
34110
+ // and this one can only name a profile the user already defined.
34111
+ "fallbackProfile",
33925
34112
  "favoriteModels",
33926
34113
  "favoriteModelsOnly",
33927
34114
  "modelAvailabilitySchedule",
@@ -33969,6 +34156,10 @@ var KNOWN_DENIED_IN_PROJECT = [
33969
34156
  {
33970
34157
  key: "git",
33971
34158
  reason: "Carries git.identity (GIT_AUTHOR_*/GIT_COMMITTER_* injection): a repo-committed config could spoof the author identity written into the victim's commit history (impersonation)."
34159
+ },
34160
+ {
34161
+ key: "fallbackMaxLastResortCandidates",
34162
+ reason: "Bounds how many of the user's OTHER configured providers may be swept in as last-resort failover. Setting it to 0 from a repo-committed config would silently strip that depth during an outage. It was already stripped in practice (absent from the allow-list) but was missing from the key registry, so this gate never checked it."
33972
34163
  }
33973
34164
  ];
33974
34165
  var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
@@ -33989,11 +34180,13 @@ var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
33989
34180
  "fallbackModels",
33990
34181
  "fallbackBridge",
33991
34182
  "fallbackProfiles",
34183
+ "fallbackProfile",
33992
34184
  "favoriteModels",
33993
34185
  "favoriteModelsOnly",
33994
34186
  "modelAvailabilitySchedule",
33995
34187
  "fallbackAuto",
33996
34188
  "fallbackStickiness",
34189
+ "fallbackMaxLastResortCandidates",
33997
34190
  "hooks",
33998
34191
  "plugins",
33999
34192
  "pluginManager",
@@ -34079,6 +34272,17 @@ var IN_PROJECT_DENIED_PATHS = [
34079
34272
  // operator owns, not the checked-out repository.
34080
34273
  path: "tools.kanbanGovernance",
34081
34274
  reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
34275
+ },
34276
+ {
34277
+ // The bridge spawn path resolves the CLI entry by walking UP from the
34278
+ // project root, so a repo that ships its own `packages/cli/dist/index.js`
34279
+ // gets that file spawned with `process.execPath` on WebUI boot — no
34280
+ // prompt, no banner. Turning the feature on is therefore equivalent to
34281
+ // arbitrary code execution for a hostile checkout, which makes this an
34282
+ // operator-owned switch and never a repo-owned one.
34283
+ // See discover-mailbox-bridge.ts:findWorkspaceCliEntry.
34284
+ path: "features.mailboxBridge",
34285
+ reason: "Enables the mailbox bridge, whose CLI-entry resolution walks up from the project root \u2014 a repo-supplied packages/cli/dist/index.js would be spawned on WebUI boot."
34082
34286
  }
34083
34287
  ];
34084
34288
  function deleteNestedPath(target, path45) {
@@ -78,8 +78,19 @@ export declare class AutoCompactionMiddleware {
78
78
  * 1 / 2.5 = 0.4.
79
79
  */
80
80
  private static readonly GUARD_GATE_LOAD;
81
+ /**
82
+ * How much the context must grow between two history-rewriting hygiene
83
+ * passes, as a fraction of the available input window and as an absolute
84
+ * floor. Every pass rewrites already-transmitted messages, which forces the
85
+ * provider to re-cache the whole prompt; spacing the passes out is what lets
86
+ * the conversation prefix stay cached for the turns in between.
87
+ */
88
+ private static readonly HYGIENE_GROWTH_RATIO;
89
+ private static readonly HYGIENE_MIN_GROWTH_TOKENS;
81
90
  /** Tracks the most recent no-op attempt so we can avoid re-firing per turn. */
82
91
  private lastNoopAttempt;
92
+ /** Context size at the last hygiene pass; anchors the growth interval. */
93
+ private lastHygieneTokens;
83
94
  /**
84
95
  * Cached token estimate from the last handler() invocation. When the
85
96
  * message count and tool count haven't changed since the last estimate
@@ -128,6 +139,59 @@ export declare class AutoCompactionMiddleware {
128
139
  * tokens or compacting. */
129
140
  setEnabled(enabled: boolean): void;
130
141
  handler(): MiddlewareHandler<Context>;
142
+ /**
143
+ * Full-request token total for the current context.
144
+ *
145
+ * Reuses the last estimate when the context hasn't grown since the previous
146
+ * check — common in autonomous idle loops. The cached value is invalidated
147
+ * whenever messages or tools change.
148
+ *
149
+ * IMPORTANT: the cache is only valid for the deterministic
150
+ * `estimateRequestTokensCalibrated` path (messages+system+tools → fixed
151
+ * output). When a custom `_estimator` is provided (e.g. in tests with a
152
+ * mutable closure, or a dynamic policy provider), always call it fresh — the
153
+ * estimator owns its own semantics and the middleware cannot safely cache its
154
+ * result across calls.
155
+ */
156
+ private estimateContextTokens;
157
+ /**
158
+ * Never-undercount send guard.
159
+ *
160
+ * The calibrated estimate can under-count dense content (CJK, base64,
161
+ * minified) by >1.5×, which would let an over-limit request slip past the
162
+ * thresholds and reach the provider. Once the calibrated load is high enough
163
+ * that even the max density factor (2.5×) *could* overflow (load ≥ 1/2.5 =
164
+ * 0.4), re-check with the upper-bound estimator and escalate to whichever
165
+ * load is larger. Below 0.4 an overflow is arithmetically impossible, so the
166
+ * extra scan is skipped.
167
+ */
168
+ private applySendGuard;
169
+ /**
170
+ * Rewrite acknowledged tool protocol in place.
171
+ *
172
+ * Tool results are protocol inputs for the immediately following model
173
+ * response, not unlimited durable prompt memory. Once a later assistant
174
+ * message proves the provider consumed them, keep a mode-sized raw window and
175
+ * replace the rest with semantic head/tail receipts, then fold fully-aged
176
+ * pairs into the history digest.
177
+ *
178
+ * Every one of those edits touches a message the provider has already seen,
179
+ * so each call costs a full prompt re-cache — it belongs behind
180
+ * {@link shouldRunHygiene}, never on the per-turn path. Content staleness is
181
+ * not a reason to bypass that gate: the edit/replace tools guard against
182
+ * acting on an out-of-date read with their own mtime + sha-256 check.
183
+ *
184
+ * @returns whether the conversation was rewritten.
185
+ */
186
+ private runHistoryHygiene;
187
+ /**
188
+ * Whether the history-rewriting hygiene pass may run this turn.
189
+ *
190
+ * Hard pressure always runs it — staying under the window outranks caching.
191
+ * Otherwise it runs at most once per growth interval, so the conversation
192
+ * stays append-only (and therefore cacheable by the provider) in between.
193
+ */
194
+ private shouldRunHygiene;
131
195
  /**
132
196
  * H1: try to read a pre-computed token total from `ctx.lastRequestTokens`
133
197
  * (set by the agent loop's pre-flight or its restash in emitContextPct).
@@ -63,6 +63,10 @@ export interface AcknowledgedToolReceiptCollapse extends EliseResult {
63
63
  * become semantic receipts while their exact payload remains in the session
64
64
  * log. Tool ids and provider metadata stay intact so strict provider replay
65
65
  * adjacency remains valid.
66
+ *
67
+ * Every edit here rewrites a message the provider has already seen, so callers
68
+ * must run this on a pressure-gated interval rather than per turn — see
69
+ * `AutoCompactionMiddleware.shouldRunHygiene`.
66
70
  */
67
71
  export declare function eliseAcknowledgedToolResults(messages: readonly Message[], opts: {
68
72
  maxRetainedTokens: number;
@@ -25,7 +25,7 @@ export { OneShotOrchestrator } from './one-shot-llm.js';
25
25
  export { type ParallelEngineState, ParallelEternalEngine, type ParallelEternalOptions, type ParallelIterationStage, } from './parallel-eternal-engine.js';
26
26
  export { buildRefinerContextSections, DEFAULT_REFINER_RETRY_FEEDBACK, type EnhanceFailureKind, enhanceUserPrompt, gatedEnhancerReasoning, isValidEnglishRefinement, normalizedEqual, parseBilingualEnhancement, recentTextTurns, shouldEnhance, } from './prompt-enhancer.js';
27
27
  export { DefaultPromptLoader, type PromptLoaderOptions, renderPrompt } from './prompt-loader.js';
28
- export { DefaultRetryPolicy } from './retry-policy.js';
28
+ export { DefaultRetryPolicy, MODEL_RETRIES } from './retry-policy.js';
29
29
  export { SelectiveCompactor, type SelectiveCompactorOptions } from './selective-compactor.js';
30
30
  export { DefaultSkillLoader, type SkillLoaderOptions } from './skill-loader.js';
31
31
  export { type CompactorStrategy, createStrategyCompactor, type StrategyCompactorOptions, } from './strategy-compactor.js';