@lawaty/lacode 2.2.0-beta.0 → 2.3.0-beta.1

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/dist/lacode.js CHANGED
@@ -53,6 +53,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
53
53
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
54
54
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
55
55
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
56
+ var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
57
+ var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
56
58
  var COOLDOWN_BASE_MS = 60 * 1e3;
57
59
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
58
60
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
@@ -63,6 +65,8 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
63
65
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
64
66
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
65
67
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
68
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
69
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
66
70
  var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
67
71
  var CHILD_MEMORY_MS = 30 * 6e4;
68
72
 
package/dist/pool.js CHANGED
@@ -25,6 +25,7 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
25
 
26
26
  // src/lib/pool.ts
27
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
28
+ var HEARTBEAT_PUBLISH_MS = 3e4;
28
29
  var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
29
30
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
30
31
  var ORPHAN_TMP_MS = 60 * 1e3;
@@ -36,7 +37,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
36
37
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
37
38
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
38
39
  var UNAVAILABLE_REASON = "endpoint-unavailable";
40
+ var PROVIDER_DEGRADED_REASON = "provider-degraded";
41
+ var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
42
+ var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
43
+ var PROVIDER_DEGRADED_MIN_MODELS = 2;
44
+ var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
45
+ var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
39
46
  var MAX_WEIGHT = 100;
47
+ var MIN_SLOT_CONTEXT = 1e5;
48
+ var MIN_SLOT_TPM = 2e4;
40
49
  var slotCeiling = (slot) => slot.weight;
41
50
  var slotBucket = (slot) => slot.bucket ?? slot.model;
42
51
  var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
@@ -67,6 +76,7 @@ var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
67
76
  var AUTH_FAILURE_PATTERN = /\b401\b|\b403\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]+api[\s_-]?key/i;
68
77
  var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
69
78
  var BASES = ["explore", "implement", "context"];
79
+ var LEARNED_TPM_FILE = "learned-tpm.json";
70
80
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
71
81
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
72
82
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
@@ -116,7 +126,22 @@ function parseSlots(parsed, origin) {
116
126
  if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
117
127
  else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
118
128
  }
119
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context } });
129
+ if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
130
+ problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
131
+ return;
132
+ }
133
+ const rawTpm = entry?.tpm;
134
+ let tpm;
135
+ if (rawTpm !== void 0) {
136
+ const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
137
+ if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
138
+ else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
139
+ }
140
+ if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
141
+ problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
142
+ return;
143
+ }
144
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
120
145
  });
121
146
  if (slots.length > SLOTS_WARN_AT) {
122
147
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
@@ -137,6 +162,7 @@ function parseTextSlots(text, origin) {
137
162
  let bucket;
138
163
  let notify;
139
164
  let context;
165
+ let tpm;
140
166
  for (const token of rest) {
141
167
  if (token.startsWith("notify:")) {
142
168
  if (notify === void 0) notify = token.slice("notify:".length);
@@ -149,13 +175,20 @@ function parseTextSlots(text, origin) {
149
175
  }
150
176
  continue;
151
177
  }
178
+ if (token.startsWith("tpm:")) {
179
+ if (tpm === void 0) {
180
+ const parsedTpm = Number(token.slice(4));
181
+ if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
182
+ }
183
+ continue;
184
+ }
152
185
  if (token.startsWith("b:")) {
153
186
  if (bucket === void 0) bucket = token.slice(2);
154
187
  } else if (weight === void 0) {
155
188
  weight = token;
156
189
  }
157
190
  }
158
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context } });
191
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
159
192
  }
160
193
  return parseSlots({ slots: entries }, origin);
161
194
  }
@@ -275,6 +308,43 @@ var LIMIT_PATTERNS = [
275
308
  ];
276
309
  var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
277
310
  var CONTEXT_LIMIT_PATTERN = /(?:context|token|length|window|prompt|char|character|byte|size)\s*(?:limit|length|size|window)/i;
311
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
312
+ var CONTEXT_OVERFLOW_PATTERNS = [
313
+ /context[_\s-]*(?:length|window|limit|overflow|exceed)/i,
314
+ /input\s+too\s+long/i,
315
+ /prompt\s+is\s+too\s+long/i,
316
+ /maximum\s+(?:context|prompt|token)/i,
317
+ /reduce\s+the\s+length\s+of\s+(?:the\s+)?messages/i
318
+ ];
319
+ var isContextOverflowText = (text) => CONTEXT_OVERFLOW_PATTERNS.some((re) => re.test(text));
320
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
321
+ var REQUEST_TOO_LARGE_PATTERNS = [
322
+ /request\s+too\s+large/i,
323
+ /please\s+reduce\s+your\s+message\s+size/i,
324
+ /limit\s+\d+\s*,\s*requested\s+\d+/i,
325
+ /\(tpm\)/i
326
+ ];
327
+ var isRequestTooLargeText = (text) => REQUEST_TOO_LARGE_PATTERNS.some((re) => re.test(text));
328
+ var parseStatedTokenLimit = (text) => {
329
+ for (const re of [/limit\s+(\d+)\s*,\s*requested\s+\d+/i, /limit\s+is\s+(\d+)/i]) {
330
+ const match = text.match(re);
331
+ if (match) {
332
+ const value = Number(match[1]);
333
+ if (Number.isFinite(value) && value > 0) return value;
334
+ }
335
+ }
336
+ return void 0;
337
+ };
338
+ function parseRateLimitHeaders(headers) {
339
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return void 0;
340
+ for (const [key, value] of Object.entries(headers)) {
341
+ if (key.toLowerCase() !== "x-ratelimit-limit-tokens") continue;
342
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : Number.NaN;
343
+ if (Number.isFinite(parsed) && parsed > 0 && Number.isInteger(parsed)) return parsed;
344
+ return void 0;
345
+ }
346
+ return void 0;
347
+ }
278
348
  var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
279
349
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
280
350
  var limitReason = (text) => {
@@ -341,6 +411,7 @@ function state(opts = {}) {
341
411
  reaping: false,
342
412
  lastSweep: 0,
343
413
  lastDirSweep: 0,
414
+ lastHeartbeatPublish: 0,
344
415
  connected: /* @__PURE__ */ new Set(),
345
416
  connectedAt: 0,
346
417
  connectedReady: false,
@@ -359,26 +430,75 @@ function state(opts = {}) {
359
430
  function ensureSlots(s, onWarn) {
360
431
  if (s.slots.length > 0) return s.slots;
361
432
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
362
- s.slots = slots;
433
+ s.slots = applyLearnedTpm(s, slots, onWarn);
363
434
  s.origin = origin;
364
435
  for (const problem of problems) onWarn(problem);
365
436
  return slots;
366
437
  }
367
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
438
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}:${slot.tpm ?? ""}`).join("|")}`;
368
439
  function reloadSlots(s, onWarn) {
369
440
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
370
- const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
371
- if (!changed) return { changed, slots, origin };
372
- s.slots = slots;
441
+ const effective = applyLearnedTpm(s, slots, onWarn);
442
+ const changed = slotSignature(origin, effective) !== slotSignature(s.origin, s.slots);
443
+ if (!changed) return { changed, slots: s.slots, origin: s.origin };
444
+ s.slots = effective;
373
445
  s.origin = origin;
374
446
  for (const problem of problems) onWarn(problem);
375
- return { changed, slots, origin };
447
+ return { changed, slots: effective, origin };
376
448
  }
377
449
  function slotSources(input = {}) {
378
450
  if (input.models !== void 0) return [];
379
451
  if (input.modelsFile) return [input.modelsFile];
380
452
  return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
381
453
  }
454
+ var learnedTpmPath = (s) => join2(s.dir, LEARNED_TPM_FILE);
455
+ function readLearnedTpm(s) {
456
+ try {
457
+ const raw = JSON.parse(readFileSync2(learnedTpmPath(s), "utf8"));
458
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
459
+ const out = {};
460
+ for (const [model, value] of Object.entries(raw)) {
461
+ const n = typeof value === "number" ? value : Number(value);
462
+ if (Number.isFinite(n) && n > 0) out[model] = n;
463
+ }
464
+ return out;
465
+ } catch {
466
+ return {};
467
+ }
468
+ }
469
+ function applyLearnedTpm(s, slots, onWarn) {
470
+ const learned = readLearnedTpm(s);
471
+ const out = [];
472
+ for (const slot of slots) {
473
+ const tpm = learned[slot.model];
474
+ if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
475
+ onWarn?.(`learned-tpm: dropping ${slot.model} (tpm ${tpm} < ${MIN_SLOT_TPM})`);
476
+ continue;
477
+ }
478
+ if (tpm !== void 0) out.push({ ...slot, tpm: Math.min(slot.tpm ?? tpm, tpm) });
479
+ else out.push(slot);
480
+ }
481
+ return out.map((slot, i) => ({ ...slot, index: i + 1 }));
482
+ }
483
+ function noteTpm(s, model, tpm) {
484
+ if (!Number.isFinite(tpm) || tpm <= 0) return false;
485
+ const map = readLearnedTpm(s);
486
+ map[model] = tpm;
487
+ if (s.dirReady) {
488
+ try {
489
+ const path = learnedTpmPath(s);
490
+ writeFileSync(`${path}.tmp`, JSON.stringify(map));
491
+ renameSync(`${path}.tmp`, path);
492
+ } catch {
493
+ }
494
+ }
495
+ if (tpm < MIN_SLOT_TPM) {
496
+ s.slots = s.slots.filter((slot) => slot.model !== model).map((slot, i) => ({ ...slot, index: i + 1 }));
497
+ return true;
498
+ }
499
+ s.slots = s.slots.map((slot) => slot.model === model ? { ...slot, tpm: Math.min(slot.tpm ?? tpm, tpm) } : slot);
500
+ return false;
501
+ }
382
502
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
383
503
  var selfFile = (id) => `claims.${id}.json`;
384
504
  var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
@@ -597,7 +717,7 @@ function readLimits(s) {
597
717
  }
598
718
  function recordStrike(s, model, reason, stated) {
599
719
  if (!s.dirReady) return;
600
- if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
720
+ if (FLAT_OUTAGE_REASONS.has(reason)) return coolUnavailable(s, model, stated, reason);
601
721
  const now = Date.now();
602
722
  const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
603
723
  const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
@@ -628,13 +748,14 @@ function recordStrike(s, model, reason, stated) {
628
748
  }
629
749
  return strike;
630
750
  }
631
- function coolUnavailable(s, model) {
751
+ function coolUnavailable(s, model, windowMs = UNAVAILABLE_COOLDOWN_MS, reason = UNAVAILABLE_REASON) {
632
752
  if (!s.dirReady) return;
633
753
  const now = Date.now();
754
+ const requested = Number.isFinite(windowMs) && windowMs > 0 ? Math.min(windowMs, STATED_COOLDOWN_MAX_MS) : UNAVAILABLE_COOLDOWN_MS;
634
755
  const prior = s.cooling.get(model);
635
- const until = now + UNAVAILABLE_COOLDOWN_MS;
756
+ const until = now + requested;
636
757
  if (prior && prior.until > until) return prior;
637
- const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
758
+ const strike = { model, until, strikes: 1, reason, last: now };
638
759
  s.cooling.set(model, strike);
639
760
  const path = join2(s.dir, limitFile(model));
640
761
  try {
@@ -730,24 +851,23 @@ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
730
851
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
731
852
  function pickSlot(s, snap, neededContext) {
732
853
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
733
- const configured = slots.filter((slot) => providerReady(s, slot.model));
734
- const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
735
- const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
854
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
855
+ if (usable.length === 0) return void 0;
856
+ const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
736
857
  if (neededContext === void 0) {
737
858
  for (const slot of usable) {
738
859
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
739
860
  }
740
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
861
+ return usable.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, usable[0]);
741
862
  }
742
- const adequate = usable.filter((slot) => slot.context == null || slot.context >= neededContext);
743
- const adequateKnown = adequate.filter((slot) => slot.context != null);
863
+ const adequate = usable.filter((slot) => effective(slot) >= neededContext);
864
+ const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
744
865
  const candidates = adequate.length > 0 ? adequate : usable;
745
- const fallbackPool = adequate.length > 0 ? adequate : pool;
746
866
  if (adequateKnown.length === 0) {
747
- const known = usable.filter((slot) => slot.context != null);
867
+ const known = usable.filter((slot) => Number.isFinite(effective(slot)));
748
868
  if (known.length > 0) {
749
- const largest = Math.max(...known.map((slot) => slot.context));
750
- const largestSlots = known.filter((slot) => slot.context === largest);
869
+ const largest = Math.max(...known.map((slot) => effective(slot)));
870
+ const largestSlots = known.filter((slot) => effective(slot) === largest);
751
871
  for (const slot of largestSlots) {
752
872
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
753
873
  }
@@ -756,20 +876,22 @@ function pickSlot(s, snap, neededContext) {
756
876
  for (const slot of candidates) {
757
877
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
758
878
  }
759
- return fallbackPool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, fallbackPool[0]);
879
+ return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
760
880
  }
761
881
  const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
762
882
  if (underWeight.length > 0) {
763
- const known = underWeight.filter((slot) => slot.context != null);
883
+ const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
764
884
  if (known.length === 0) return underWeight[0];
765
- return known.reduce((best, slot) => slot.context < best.context ? slot : best, known[0]);
885
+ return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
766
886
  }
767
887
  return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
768
888
  }
769
889
  function poolPass(s, snap, neededContext) {
770
890
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
771
891
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
772
- const candidates = neededContext === void 0 ? usable : usable.filter((slot) => slot.context == null || slot.context >= neededContext);
892
+ if (usable.length === 0) return "none";
893
+ const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
894
+ const candidates = neededContext === void 0 ? usable : usable.filter((slot) => effective(slot) >= neededContext);
773
895
  const pool = candidates.length > 0 ? candidates : usable;
774
896
  if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
775
897
  return "overflow";
@@ -831,7 +953,10 @@ function noteActivity(s, sessionID) {
831
953
  s.activity.set(sessionID, now);
832
954
  let touched = false;
833
955
  for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
834
- if (touched) publish(s);
956
+ if (touched && now - s.lastHeartbeatPublish >= HEARTBEAT_PUBLISH_MS) {
957
+ s.lastHeartbeatPublish = now;
958
+ publish(s);
959
+ }
835
960
  }
836
961
  function idleFor(s, claim, now) {
837
962
  const target = claim.g;
@@ -877,6 +1002,24 @@ function coolProvider(s, provider, until, reason) {
877
1002
  }
878
1003
  return affected;
879
1004
  }
1005
+ function noteProviderDegradation(s, model, reason) {
1006
+ if (!s.dirReady) return [];
1007
+ if (!TRANSIENT_DEGRADATION_REASONS.test(String(reason))) return [];
1008
+ const provider = providerOf(model);
1009
+ const slots = (s.slots.length > 0 ? s.slots : FALLBACK_SLOTS).filter((slot) => providerOf(slot.model) === provider);
1010
+ if (slots.length < PROVIDER_DEGRADED_MIN_MODELS) return [];
1011
+ const now = Date.now();
1012
+ const failing = /* @__PURE__ */ new Set();
1013
+ for (const slot of slots) {
1014
+ const record = s.cooling.get(slot.model);
1015
+ if (!record || record.until <= now) continue;
1016
+ if (now - record.last > PROVIDER_DEGRADED_WINDOW_MS) continue;
1017
+ if (!TRANSIENT_DEGRADATION_REASONS.test(String(record.reason))) continue;
1018
+ failing.add(slot.model);
1019
+ }
1020
+ if (failing.size < PROVIDER_DEGRADED_MIN_MODELS) return [];
1021
+ return coolProvider(s, provider, now + PROVIDER_DEGRADED_COOLDOWN_MS, PROVIDER_DEGRADED_REASON);
1022
+ }
880
1023
  function recordFailure(s, f) {
881
1024
  try {
882
1025
  const path = join2(s.dir, "failures.jsonl");
@@ -931,19 +1074,27 @@ export {
931
1074
  AUTH_FAILURE_COOLDOWN_MS,
932
1075
  AUTH_FAILURE_REASON,
933
1076
  BASES,
1077
+ BELOW_MIN_CONTEXT_COOLDOWN_MS,
934
1078
  CATALOG_TTL_MS,
935
1079
  CLAIM_TTL_MS,
1080
+ CONTEXT_OVERFLOW_COOLDOWN_MS,
936
1081
  DEAD_FILE_MS,
937
1082
  DEFAULT_PRESET,
938
1083
  FALLBACK_SLOTS,
939
1084
  HANG_COOLDOWN_MS,
1085
+ HEARTBEAT_PUBLISH_MS,
1086
+ LEARNED_TPM_FILE,
940
1087
  MAX_CLAIM_AGE_MS,
941
1088
  MAX_WEIGHT,
1089
+ MIN_SLOT_CONTEXT,
1090
+ MIN_SLOT_TPM,
942
1091
  MODEL_MISSING_COOLDOWN_MS,
943
1092
  MODEL_MISSING_REASON,
944
1093
  ORPHAN_TMP_MS,
945
1094
  PACKAGE_ROOT,
946
1095
  PRESETS_DIR,
1096
+ PROVIDER_DEGRADED_COOLDOWN_MS,
1097
+ PROVIDER_DEGRADED_REASON,
947
1098
  PROVIDER_LIST_TTL_MS,
948
1099
  QUOTA_COOLDOWN_BASE_MS,
949
1100
  QUOTA_COOLDOWN_MAX_MS,
@@ -961,6 +1112,7 @@ export {
961
1112
  acquire,
962
1113
  agentFor,
963
1114
  announcesReset,
1115
+ applyLearnedTpm,
964
1116
  backfillChild,
965
1117
  bindChild,
966
1118
  claimKey,
@@ -975,19 +1127,25 @@ export {
975
1127
  init,
976
1128
  isAuthFailureText,
977
1129
  isAvailable,
1130
+ isContextOverflowText,
978
1131
  isLimitText,
979
1132
  isModelMissingText,
980
1133
  isProbe,
981
1134
  isQuotaReason,
1135
+ isRequestTooLargeText,
982
1136
  isTransientGatewayText,
983
1137
  isUnavailableText,
984
1138
  limitReason,
985
1139
  manualVariant,
986
1140
  markProbe,
987
1141
  noteActivity,
1142
+ noteProviderDegradation,
1143
+ noteTpm,
988
1144
  notifyDefault,
1145
+ parseRateLimitHeaders,
989
1146
  parseSlotText,
990
1147
  parseSlots,
1148
+ parseStatedTokenLimit,
991
1149
  parseTextSlots,
992
1150
  pickSlot,
993
1151
  poolPass,
@@ -997,6 +1155,7 @@ export {
997
1155
  pruneSessions,
998
1156
  publish,
999
1157
  readFailures,
1158
+ readLearnedTpm,
1000
1159
  readLimits,
1001
1160
  readSiblings,
1002
1161
  readSlotFile,
package/dist/tui.js CHANGED
@@ -822,33 +822,12 @@ function createVoiceClient(options, deps) {
822
822
  }
823
823
 
824
824
  // src/tui/timeline.tsx
825
- import { readFileSync as readFileSync3 } from "node:fs";
825
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
826
826
  import { homedir as homedir2 } from "node:os";
827
827
  import { join as join3, resolve } from "node:path";
828
828
  import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
829
829
  var registered = false;
830
830
  var ENTRY_LINES = 14;
831
- var readEntries = (id) => {
832
- if (typeof id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(id)) return [];
833
- const rawDir = process.env.LACODE_TIMELINE_DIR;
834
- const dir = rawDir ? resolve(rawDir) : join3(homedir2(), ".local/share/opencode/lacode/timeline");
835
- try {
836
- const raw = readFileSync3(join3(dir, id + ".jsonl"), "utf8");
837
- const parsed = [];
838
- for (const line of raw.split("\n")) {
839
- const trimmed = line.trim();
840
- if (!trimmed) continue;
841
- try {
842
- const obj = JSON.parse(trimmed);
843
- if (obj && typeof obj.ts === "number" && typeof obj.text === "string") parsed.push(obj.text);
844
- } catch {
845
- }
846
- }
847
- return parsed.slice(-ENTRY_LINES).reverse();
848
- } catch {
849
- return [];
850
- }
851
- };
852
831
  var tui = async (api) => {
853
832
  if (registered) return;
854
833
  registered = true;
@@ -862,15 +841,80 @@ var tui = async (api) => {
862
841
  sidebar_content(ctx, props) {
863
842
  const rows = [];
864
843
  const muted = ctx.theme.current.textMuted;
844
+ const cache = { path: null, size: -1, mtimeMs: -1, ino: -1, entries: [], rendered: [] };
845
+ const readEntries = (id) => {
846
+ if (typeof id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(id)) return [];
847
+ const rawDir = process.env.LACODE_TIMELINE_DIR;
848
+ const dir = rawDir ? resolve(rawDir) : join3(homedir2(), ".local/share/opencode/lacode/timeline");
849
+ const path = join3(dir, id + ".jsonl");
850
+ try {
851
+ const stat = statSync(path);
852
+ if (cache.path === path && cache.size === stat.size && cache.mtimeMs === stat.mtimeMs && cache.ino === stat.ino) {
853
+ return cache.entries;
854
+ }
855
+ const maxRead = 64 * 1024;
856
+ const start = Math.max(0, stat.size - maxRead);
857
+ const length = stat.size - start;
858
+ const fd = openSync(path, "r");
859
+ try {
860
+ const buf = Buffer.alloc(length);
861
+ let total = 0;
862
+ while (total < length) {
863
+ const n = readSync(fd, buf, total, length - total, start + total);
864
+ if (n <= 0) break;
865
+ total += n;
866
+ }
867
+ if (total < length) {
868
+ return [];
869
+ }
870
+ let raw = buf.toString("utf8");
871
+ if (start > 0) {
872
+ const firstNl = raw.indexOf("\n");
873
+ raw = firstNl === -1 ? "" : raw.slice(firstNl + 1);
874
+ }
875
+ const parsed = [];
876
+ for (const line of raw.split("\n")) {
877
+ const trimmed = line.trim();
878
+ if (!trimmed) continue;
879
+ try {
880
+ const obj = JSON.parse(trimmed);
881
+ if (obj && typeof obj.ts === "number" && typeof obj.text === "string") {
882
+ parsed.push({ ts: obj.ts, text: obj.text });
883
+ }
884
+ } catch {
885
+ }
886
+ }
887
+ const entries = parsed.slice(-ENTRY_LINES).reverse();
888
+ cache.path = path;
889
+ cache.size = stat.size;
890
+ cache.mtimeMs = stat.mtimeMs;
891
+ cache.ino = stat.ino;
892
+ cache.entries = entries;
893
+ return entries;
894
+ } finally {
895
+ closeSync(fd);
896
+ }
897
+ } catch {
898
+ return [];
899
+ }
900
+ };
865
901
  const paint = () => {
866
902
  const entries = readEntries(props.session_id);
903
+ const next = [];
904
+ for (let i = 0; i < ENTRY_LINES; i++) {
905
+ const text = entries[i]?.text;
906
+ next.push(text ? `\u2022 ${text}` : i === 0 && entries.length === 0 ? "No timeline yet" : "");
907
+ }
908
+ if (cache.rendered.length === ENTRY_LINES && next.every((value, i) => value === cache.rendered[i])) {
909
+ return;
910
+ }
867
911
  for (let i = 0; i < ENTRY_LINES; i++) {
868
912
  const row = rows[i];
869
913
  if (!row) continue;
870
- const text = entries[i];
871
- row.content = text ? `\u2022 ${text}` : i === 0 && entries.length === 0 ? "No timeline yet" : "";
914
+ row.content = next[i];
872
915
  row.requestRender?.();
873
916
  }
917
+ cache.rendered = next;
874
918
  };
875
919
  const timer = setInterval(paint, 1e3);
876
920
  timer.unref?.();
@@ -899,6 +943,47 @@ var plugin = {
899
943
  };
900
944
  var timeline_default = plugin;
901
945
 
946
+ // src/lib/modes.ts
947
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
948
+ import { homedir as homedir3 } from "node:os";
949
+ import { dirname, join as join4 } from "node:path";
950
+ var MODES_FILE = process.env.LACODE_MODES_FILE ?? join4(homedir3(), ".config", "lacode", "modes.json");
951
+ var bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
952
+ function readModes(defaults = {}) {
953
+ const fallback = { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
954
+ try {
955
+ const raw = JSON.parse(readFileSync3(MODES_FILE, "utf8"));
956
+ return { yolo: bool(raw?.yolo, fallback.yolo), ykb: bool(raw?.ykb, fallback.ykb) };
957
+ } catch {
958
+ return fallback;
959
+ }
960
+ }
961
+ function writeModes(modes) {
962
+ const path = MODES_FILE;
963
+ mkdirSync(dirname(path), { recursive: true });
964
+ const tmp = `${path}.tmp`;
965
+ writeFileSync(tmp, `${JSON.stringify({ yolo: modes.yolo, ykb: modes.ykb }, null, 2)}
966
+ `);
967
+ renameSync(tmp, path);
968
+ }
969
+ function toggleMode(name, defaults = {}) {
970
+ const current = readModes(defaults);
971
+ const next = { ...current, [name]: !current[name] };
972
+ writeModes(next);
973
+ return next;
974
+ }
975
+ function recommendedAnswer(question) {
976
+ const q = question;
977
+ const options = (Array.isArray(q?.options) ? q.options : []).filter(
978
+ (option) => typeof option?.label === "string" && option.label.length > 0
979
+ );
980
+ if (options.length === 0) return [];
981
+ const recommended = options.find(
982
+ (option) => /\(recommended\)/i.test(option.label) || typeof option.description === "string" && /\(recommended\)/i.test(option.description)
983
+ );
984
+ return [(recommended ?? options[0]).label];
985
+ }
986
+
902
987
  // src/tui/index.ts
903
988
  var DEFAULT_MODEL = "whisper-large-v3-turbo";
904
989
  var DEFAULT_LANGUAGE = "en";
@@ -1328,6 +1413,46 @@ var tui2 = async (api, options) => {
1328
1413
  toast(api, { variant: "error", title: "lacode voice", message: readableError(err) });
1329
1414
  }
1330
1415
  };
1416
+ async function drainPendingMode(name) {
1417
+ try {
1418
+ const pending = await fetchPending(api);
1419
+ for (const item of pending) {
1420
+ if (name === "yolo" && item.kind === "permission") {
1421
+ await api.client.permission.reply({ requestID: item.request.id, reply: "once" });
1422
+ }
1423
+ if (name === "ykb" && item.kind === "question") {
1424
+ const answers = item.request.questions.map((question) => recommendedAnswer(question));
1425
+ if (answers.some((answer) => answer.length > 0)) {
1426
+ await api.client.question.reply({ requestID: item.request.id, answers });
1427
+ }
1428
+ }
1429
+ }
1430
+ } catch {
1431
+ }
1432
+ }
1433
+ async function runToggleMode(name) {
1434
+ try {
1435
+ const next = toggleMode(name);
1436
+ toast(api, {
1437
+ variant: next[name] ? "success" : "info",
1438
+ message: `${name.toUpperCase()} mode ${next[name] ? "on" : "off"}`
1439
+ });
1440
+ if (next[name]) await drainPendingMode(name);
1441
+ } catch (error) {
1442
+ toast(api, { variant: "error", message: `Could not toggle ${name.toUpperCase()}: ${String(error)}` });
1443
+ }
1444
+ }
1445
+ async function runModeStatus() {
1446
+ try {
1447
+ const modes = readModes();
1448
+ toast(api, {
1449
+ variant: "info",
1450
+ message: `YOLO ${modes.yolo ? "on" : "off"} \xB7 YKB ${modes.ykb ? "on" : "off"}`
1451
+ });
1452
+ } catch (error) {
1453
+ toast(api, { variant: "error", message: `Could not read mode state: ${String(error)}` });
1454
+ }
1455
+ }
1331
1456
  const commands = [
1332
1457
  {
1333
1458
  name: "lacode.voice.dictate",
@@ -1349,7 +1474,10 @@ var tui2 = async (api, options) => {
1349
1474
  category: "Voice",
1350
1475
  namespace: "palette",
1351
1476
  run: async () => runCheck()
1352
- }
1477
+ },
1478
+ { name: "lacode.mode.yolo", title: "Mode: toggle YOLO (auto-approve permissions)", category: "Modes", namespace: "palette", slashName: "yolo", run: async () => runToggleMode("yolo") },
1479
+ { name: "lacode.mode.ykb", title: "Mode: toggle YKB (no questions, decide itself)", category: "Modes", namespace: "palette", slashName: "ykb", run: async () => runToggleMode("ykb") },
1480
+ { name: "lacode.mode.status", title: "Mode: show YOLO/YKB status", category: "Modes", namespace: "palette", slashName: "modes", run: async () => runModeStatus() }
1353
1481
  ];
1354
1482
  const bindings = [];
1355
1483
  if (opts.dictation) {
package/dist/tunnel.js CHANGED
@@ -46,6 +46,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
46
46
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
47
47
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
48
48
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
49
+ var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
50
+ var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
49
51
  var COOLDOWN_BASE_MS = 60 * 1e3;
50
52
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
51
53
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
@@ -56,6 +58,8 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
56
58
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
57
59
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
58
60
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
61
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
62
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
59
63
  var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
60
64
  var CHILD_MEMORY_MS = 30 * 6e4;
61
65