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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lacode.js CHANGED
@@ -63,6 +63,8 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
63
63
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
64
64
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
65
65
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
66
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
67
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
66
68
  var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
67
69
  var CHILD_MEMORY_MS = 30 * 6e4;
68
70
 
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;
@@ -37,6 +38,8 @@ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
37
38
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
38
39
  var UNAVAILABLE_REASON = "endpoint-unavailable";
39
40
  var MAX_WEIGHT = 100;
41
+ var MIN_SLOT_CONTEXT = 1e5;
42
+ var MIN_SLOT_TPM = 2e4;
40
43
  var slotCeiling = (slot) => slot.weight;
41
44
  var slotBucket = (slot) => slot.bucket ?? slot.model;
42
45
  var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
@@ -67,6 +70,7 @@ var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
67
70
  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
71
  var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
69
72
  var BASES = ["explore", "implement", "context"];
73
+ var LEARNED_TPM_FILE = "learned-tpm.json";
70
74
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
71
75
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
72
76
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
@@ -116,7 +120,22 @@ function parseSlots(parsed, origin) {
116
120
  if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
117
121
  else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
118
122
  }
119
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context } });
123
+ if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
124
+ problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
125
+ return;
126
+ }
127
+ const rawTpm = entry?.tpm;
128
+ let tpm;
129
+ if (rawTpm !== void 0) {
130
+ const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
131
+ if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
132
+ else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
133
+ }
134
+ if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
135
+ problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
136
+ return;
137
+ }
138
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
120
139
  });
121
140
  if (slots.length > SLOTS_WARN_AT) {
122
141
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
@@ -137,6 +156,7 @@ function parseTextSlots(text, origin) {
137
156
  let bucket;
138
157
  let notify;
139
158
  let context;
159
+ let tpm;
140
160
  for (const token of rest) {
141
161
  if (token.startsWith("notify:")) {
142
162
  if (notify === void 0) notify = token.slice("notify:".length);
@@ -149,13 +169,20 @@ function parseTextSlots(text, origin) {
149
169
  }
150
170
  continue;
151
171
  }
172
+ if (token.startsWith("tpm:")) {
173
+ if (tpm === void 0) {
174
+ const parsedTpm = Number(token.slice(4));
175
+ if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
176
+ }
177
+ continue;
178
+ }
152
179
  if (token.startsWith("b:")) {
153
180
  if (bucket === void 0) bucket = token.slice(2);
154
181
  } else if (weight === void 0) {
155
182
  weight = token;
156
183
  }
157
184
  }
158
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context } });
185
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
159
186
  }
160
187
  return parseSlots({ slots: entries }, origin);
161
188
  }
@@ -275,6 +302,43 @@ var LIMIT_PATTERNS = [
275
302
  ];
276
303
  var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
277
304
  var CONTEXT_LIMIT_PATTERN = /(?:context|token|length|window|prompt|char|character|byte|size)\s*(?:limit|length|size|window)/i;
305
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
306
+ var CONTEXT_OVERFLOW_PATTERNS = [
307
+ /context[_\s-]*(?:length|window|limit|overflow|exceed)/i,
308
+ /input\s+too\s+long/i,
309
+ /prompt\s+is\s+too\s+long/i,
310
+ /maximum\s+(?:context|prompt|token)/i,
311
+ /reduce\s+the\s+length\s+of\s+(?:the\s+)?messages/i
312
+ ];
313
+ var isContextOverflowText = (text) => CONTEXT_OVERFLOW_PATTERNS.some((re) => re.test(text));
314
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
315
+ var REQUEST_TOO_LARGE_PATTERNS = [
316
+ /request\s+too\s+large/i,
317
+ /please\s+reduce\s+your\s+message\s+size/i,
318
+ /limit\s+\d+\s*,\s*requested\s+\d+/i,
319
+ /\(tpm\)/i
320
+ ];
321
+ var isRequestTooLargeText = (text) => REQUEST_TOO_LARGE_PATTERNS.some((re) => re.test(text));
322
+ var parseStatedTokenLimit = (text) => {
323
+ for (const re of [/limit\s+(\d+)\s*,\s*requested\s+\d+/i, /limit\s+is\s+(\d+)/i]) {
324
+ const match = text.match(re);
325
+ if (match) {
326
+ const value = Number(match[1]);
327
+ if (Number.isFinite(value) && value > 0) return value;
328
+ }
329
+ }
330
+ return void 0;
331
+ };
332
+ function parseRateLimitHeaders(headers) {
333
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return void 0;
334
+ for (const [key, value] of Object.entries(headers)) {
335
+ if (key.toLowerCase() !== "x-ratelimit-limit-tokens") continue;
336
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : Number.NaN;
337
+ if (Number.isFinite(parsed) && parsed > 0 && Number.isInteger(parsed)) return parsed;
338
+ return void 0;
339
+ }
340
+ return void 0;
341
+ }
278
342
  var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
279
343
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
280
344
  var limitReason = (text) => {
@@ -341,6 +405,7 @@ function state(opts = {}) {
341
405
  reaping: false,
342
406
  lastSweep: 0,
343
407
  lastDirSweep: 0,
408
+ lastHeartbeatPublish: 0,
344
409
  connected: /* @__PURE__ */ new Set(),
345
410
  connectedAt: 0,
346
411
  connectedReady: false,
@@ -359,26 +424,75 @@ function state(opts = {}) {
359
424
  function ensureSlots(s, onWarn) {
360
425
  if (s.slots.length > 0) return s.slots;
361
426
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
362
- s.slots = slots;
427
+ s.slots = applyLearnedTpm(s, slots, onWarn);
363
428
  s.origin = origin;
364
429
  for (const problem of problems) onWarn(problem);
365
430
  return slots;
366
431
  }
367
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
432
+ 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
433
  function reloadSlots(s, onWarn) {
369
434
  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;
435
+ const effective = applyLearnedTpm(s, slots, onWarn);
436
+ const changed = slotSignature(origin, effective) !== slotSignature(s.origin, s.slots);
437
+ if (!changed) return { changed, slots: s.slots, origin: s.origin };
438
+ s.slots = effective;
373
439
  s.origin = origin;
374
440
  for (const problem of problems) onWarn(problem);
375
- return { changed, slots, origin };
441
+ return { changed, slots: effective, origin };
376
442
  }
377
443
  function slotSources(input = {}) {
378
444
  if (input.models !== void 0) return [];
379
445
  if (input.modelsFile) return [input.modelsFile];
380
446
  return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
381
447
  }
448
+ var learnedTpmPath = (s) => join2(s.dir, LEARNED_TPM_FILE);
449
+ function readLearnedTpm(s) {
450
+ try {
451
+ const raw = JSON.parse(readFileSync2(learnedTpmPath(s), "utf8"));
452
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
453
+ const out = {};
454
+ for (const [model, value] of Object.entries(raw)) {
455
+ const n = typeof value === "number" ? value : Number(value);
456
+ if (Number.isFinite(n) && n > 0) out[model] = n;
457
+ }
458
+ return out;
459
+ } catch {
460
+ return {};
461
+ }
462
+ }
463
+ function applyLearnedTpm(s, slots, onWarn) {
464
+ const learned = readLearnedTpm(s);
465
+ const out = [];
466
+ for (const slot of slots) {
467
+ const tpm = learned[slot.model];
468
+ if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
469
+ onWarn?.(`learned-tpm: dropping ${slot.model} (tpm ${tpm} < ${MIN_SLOT_TPM})`);
470
+ continue;
471
+ }
472
+ if (tpm !== void 0) out.push({ ...slot, tpm: Math.min(slot.tpm ?? tpm, tpm) });
473
+ else out.push(slot);
474
+ }
475
+ return out.map((slot, i) => ({ ...slot, index: i + 1 }));
476
+ }
477
+ function noteTpm(s, model, tpm) {
478
+ if (!Number.isFinite(tpm) || tpm <= 0) return false;
479
+ const map = readLearnedTpm(s);
480
+ map[model] = tpm;
481
+ if (s.dirReady) {
482
+ try {
483
+ const path = learnedTpmPath(s);
484
+ writeFileSync(`${path}.tmp`, JSON.stringify(map));
485
+ renameSync(`${path}.tmp`, path);
486
+ } catch {
487
+ }
488
+ }
489
+ if (tpm < MIN_SLOT_TPM) {
490
+ s.slots = s.slots.filter((slot) => slot.model !== model).map((slot, i) => ({ ...slot, index: i + 1 }));
491
+ return true;
492
+ }
493
+ s.slots = s.slots.map((slot) => slot.model === model ? { ...slot, tpm: Math.min(slot.tpm ?? tpm, tpm) } : slot);
494
+ return false;
495
+ }
382
496
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
383
497
  var selfFile = (id) => `claims.${id}.json`;
384
498
  var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
@@ -730,24 +844,23 @@ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
730
844
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
731
845
  function pickSlot(s, snap, neededContext) {
732
846
  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;
847
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
848
+ if (usable.length === 0) return void 0;
849
+ const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
736
850
  if (neededContext === void 0) {
737
851
  for (const slot of usable) {
738
852
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
739
853
  }
740
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
854
+ return usable.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, usable[0]);
741
855
  }
742
- const adequate = usable.filter((slot) => slot.context == null || slot.context >= neededContext);
743
- const adequateKnown = adequate.filter((slot) => slot.context != null);
856
+ const adequate = usable.filter((slot) => effective(slot) >= neededContext);
857
+ const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
744
858
  const candidates = adequate.length > 0 ? adequate : usable;
745
- const fallbackPool = adequate.length > 0 ? adequate : pool;
746
859
  if (adequateKnown.length === 0) {
747
- const known = usable.filter((slot) => slot.context != null);
860
+ const known = usable.filter((slot) => Number.isFinite(effective(slot)));
748
861
  if (known.length > 0) {
749
- const largest = Math.max(...known.map((slot) => slot.context));
750
- const largestSlots = known.filter((slot) => slot.context === largest);
862
+ const largest = Math.max(...known.map((slot) => effective(slot)));
863
+ const largestSlots = known.filter((slot) => effective(slot) === largest);
751
864
  for (const slot of largestSlots) {
752
865
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
753
866
  }
@@ -756,20 +869,22 @@ function pickSlot(s, snap, neededContext) {
756
869
  for (const slot of candidates) {
757
870
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
758
871
  }
759
- return fallbackPool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, fallbackPool[0]);
872
+ return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
760
873
  }
761
874
  const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
762
875
  if (underWeight.length > 0) {
763
- const known = underWeight.filter((slot) => slot.context != null);
876
+ const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
764
877
  if (known.length === 0) return underWeight[0];
765
- return known.reduce((best, slot) => slot.context < best.context ? slot : best, known[0]);
878
+ return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
766
879
  }
767
880
  return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
768
881
  }
769
882
  function poolPass(s, snap, neededContext) {
770
883
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
771
884
  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);
885
+ if (usable.length === 0) return "none";
886
+ const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
887
+ const candidates = neededContext === void 0 ? usable : usable.filter((slot) => effective(slot) >= neededContext);
773
888
  const pool = candidates.length > 0 ? candidates : usable;
774
889
  if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
775
890
  return "overflow";
@@ -831,7 +946,10 @@ function noteActivity(s, sessionID) {
831
946
  s.activity.set(sessionID, now);
832
947
  let touched = false;
833
948
  for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
834
- if (touched) publish(s);
949
+ if (touched && now - s.lastHeartbeatPublish >= HEARTBEAT_PUBLISH_MS) {
950
+ s.lastHeartbeatPublish = now;
951
+ publish(s);
952
+ }
835
953
  }
836
954
  function idleFor(s, claim, now) {
837
955
  const target = claim.g;
@@ -931,14 +1049,20 @@ export {
931
1049
  AUTH_FAILURE_COOLDOWN_MS,
932
1050
  AUTH_FAILURE_REASON,
933
1051
  BASES,
1052
+ BELOW_MIN_CONTEXT_COOLDOWN_MS,
934
1053
  CATALOG_TTL_MS,
935
1054
  CLAIM_TTL_MS,
1055
+ CONTEXT_OVERFLOW_COOLDOWN_MS,
936
1056
  DEAD_FILE_MS,
937
1057
  DEFAULT_PRESET,
938
1058
  FALLBACK_SLOTS,
939
1059
  HANG_COOLDOWN_MS,
1060
+ HEARTBEAT_PUBLISH_MS,
1061
+ LEARNED_TPM_FILE,
940
1062
  MAX_CLAIM_AGE_MS,
941
1063
  MAX_WEIGHT,
1064
+ MIN_SLOT_CONTEXT,
1065
+ MIN_SLOT_TPM,
942
1066
  MODEL_MISSING_COOLDOWN_MS,
943
1067
  MODEL_MISSING_REASON,
944
1068
  ORPHAN_TMP_MS,
@@ -961,6 +1085,7 @@ export {
961
1085
  acquire,
962
1086
  agentFor,
963
1087
  announcesReset,
1088
+ applyLearnedTpm,
964
1089
  backfillChild,
965
1090
  bindChild,
966
1091
  claimKey,
@@ -975,19 +1100,24 @@ export {
975
1100
  init,
976
1101
  isAuthFailureText,
977
1102
  isAvailable,
1103
+ isContextOverflowText,
978
1104
  isLimitText,
979
1105
  isModelMissingText,
980
1106
  isProbe,
981
1107
  isQuotaReason,
1108
+ isRequestTooLargeText,
982
1109
  isTransientGatewayText,
983
1110
  isUnavailableText,
984
1111
  limitReason,
985
1112
  manualVariant,
986
1113
  markProbe,
987
1114
  noteActivity,
1115
+ noteTpm,
988
1116
  notifyDefault,
1117
+ parseRateLimitHeaders,
989
1118
  parseSlotText,
990
1119
  parseSlots,
1120
+ parseStatedTokenLimit,
991
1121
  parseTextSlots,
992
1122
  pickSlot,
993
1123
  poolPass,
@@ -997,6 +1127,7 @@ export {
997
1127
  pruneSessions,
998
1128
  publish,
999
1129
  readFailures,
1130
+ readLearnedTpm,
1000
1131
  readLimits,
1001
1132
  readSiblings,
1002
1133
  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
@@ -56,6 +56,8 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
56
56
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
57
57
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
58
58
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
59
+ var CONTEXT_OVERFLOW_COOLDOWN_MS = 10 * 60 * 1e3;
60
+ var BELOW_MIN_CONTEXT_COOLDOWN_MS = 6 * 60 * 60 * 1e3;
59
61
  var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
60
62
  var CHILD_MEMORY_MS = 30 * 6e4;
61
63
 
@@ -50,6 +50,18 @@ warning. The flag gates only the desktop reset notice, not pool routing or
50
50
  status, and pool files are hot-reloaded so changes take effect without a
51
51
  restart.
52
52
 
53
+ ## Modes
54
+
55
+ Two runtime modes can be seeded from plugin options and toggled from the TUI:
56
+
57
+ | Command | Effect |
58
+ | --- | --- |
59
+ | `/yolo` | Toggle YOLO. Every permission request that would `ask` is approved automatically; an explicit `deny` still wins. |
60
+ | `/ykb` | Toggle YKB (You Know Better). The agent is told not to ask questions and to continue with its recommendation. A question that still appears is answered with its `(Recommended)` option, or its first option when none is marked. |
61
+ | `/modes` | Show both switches. |
62
+
63
+ The toggles write `~/.config/lacode/modes.json` (override with `LACODE_MODES_FILE`) and the server picks the change up on the next decision — no restart. Turning a mode on also answers requests that are already pending. The `yolo` / `ykb` plugin options only seed the file on first run; after that, the file wins. A free-text question with no options is left for the user, since there is no option to choose.
64
+
53
65
 
54
66
  ## Which models run which agent
55
67