@lawaty/lacode 2.1.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");
@@ -109,7 +113,29 @@ function parseSlots(parsed, origin) {
109
113
  problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
110
114
  }
111
115
  const notify = coerceNotify(entry.notify, at, model, problems);
112
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
116
+ const rawContext = entry?.context;
117
+ let context;
118
+ if (rawContext !== void 0) {
119
+ const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
120
+ if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
121
+ else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
122
+ }
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 } });
113
139
  });
114
140
  if (slots.length > SLOTS_WARN_AT) {
115
141
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
@@ -129,18 +155,34 @@ function parseTextSlots(text, origin) {
129
155
  let weight;
130
156
  let bucket;
131
157
  let notify;
158
+ let context;
159
+ let tpm;
132
160
  for (const token of rest) {
133
161
  if (token.startsWith("notify:")) {
134
162
  if (notify === void 0) notify = token.slice("notify:".length);
135
163
  continue;
136
164
  }
165
+ if (token.startsWith("c:")) {
166
+ if (context === void 0) {
167
+ const parsedContext = Number(token.slice(2));
168
+ if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
169
+ }
170
+ continue;
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
+ }
137
179
  if (token.startsWith("b:")) {
138
180
  if (bucket === void 0) bucket = token.slice(2);
139
181
  } else if (weight === void 0) {
140
182
  weight = token;
141
183
  }
142
184
  }
143
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
185
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
144
186
  }
145
187
  return parseSlots({ slots: entries }, origin);
146
188
  }
@@ -260,6 +302,43 @@ var LIMIT_PATTERNS = [
260
302
  ];
261
303
  var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
262
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
+ }
263
342
  var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
264
343
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
265
344
  var limitReason = (text) => {
@@ -326,6 +405,7 @@ function state(opts = {}) {
326
405
  reaping: false,
327
406
  lastSweep: 0,
328
407
  lastDirSweep: 0,
408
+ lastHeartbeatPublish: 0,
329
409
  connected: /* @__PURE__ */ new Set(),
330
410
  connectedAt: 0,
331
411
  connectedReady: false,
@@ -344,26 +424,75 @@ function state(opts = {}) {
344
424
  function ensureSlots(s, onWarn) {
345
425
  if (s.slots.length > 0) return s.slots;
346
426
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
347
- s.slots = slots;
427
+ s.slots = applyLearnedTpm(s, slots, onWarn);
348
428
  s.origin = origin;
349
429
  for (const problem of problems) onWarn(problem);
350
430
  return slots;
351
431
  }
352
- 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("|")}`;
353
433
  function reloadSlots(s, onWarn) {
354
434
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
355
- const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
356
- if (!changed) return { changed, slots, origin };
357
- 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;
358
439
  s.origin = origin;
359
440
  for (const problem of problems) onWarn(problem);
360
- return { changed, slots, origin };
441
+ return { changed, slots: effective, origin };
361
442
  }
362
443
  function slotSources(input = {}) {
363
444
  if (input.models !== void 0) return [];
364
445
  if (input.modelsFile) return [input.modelsFile];
365
446
  return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
366
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
+ }
367
496
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
368
497
  var selfFile = (id) => `claims.${id}.json`;
369
498
  var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
@@ -713,20 +842,51 @@ function snapshot(s) {
713
842
  }
714
843
  var loadOf = (counts, slot) => counts.get(slot) ?? 0;
715
844
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
716
- function pickSlot(s, snap) {
845
+ function pickSlot(s, snap, neededContext) {
717
846
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
718
- const configured = slots.filter((slot) => providerReady(s, slot.model));
719
- const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
720
- const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
721
- for (const slot of usable) {
722
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
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);
850
+ if (neededContext === void 0) {
851
+ for (const slot of usable) {
852
+ if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
853
+ }
854
+ return usable.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, usable[0]);
855
+ }
856
+ const adequate = usable.filter((slot) => effective(slot) >= neededContext);
857
+ const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
858
+ const candidates = adequate.length > 0 ? adequate : usable;
859
+ if (adequateKnown.length === 0) {
860
+ const known = usable.filter((slot) => Number.isFinite(effective(slot)));
861
+ if (known.length > 0) {
862
+ const largest = Math.max(...known.map((slot) => effective(slot)));
863
+ const largestSlots = known.filter((slot) => effective(slot) === largest);
864
+ for (const slot of largestSlots) {
865
+ if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
866
+ }
867
+ return largestSlots.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, largestSlots[0]);
868
+ }
869
+ for (const slot of candidates) {
870
+ if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
871
+ }
872
+ return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
723
873
  }
724
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
874
+ const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
875
+ if (underWeight.length > 0) {
876
+ const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
877
+ if (known.length === 0) return underWeight[0];
878
+ return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
879
+ }
880
+ return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
725
881
  }
726
- function poolPass(s, snap) {
882
+ function poolPass(s, snap, neededContext) {
727
883
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
728
884
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
729
- if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
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);
888
+ const pool = candidates.length > 0 ? candidates : usable;
889
+ if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
730
890
  return "overflow";
731
891
  }
732
892
  function agentFor(base, slot) {
@@ -786,7 +946,10 @@ function noteActivity(s, sessionID) {
786
946
  s.activity.set(sessionID, now);
787
947
  let touched = false;
788
948
  for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
789
- if (touched) publish(s);
949
+ if (touched && now - s.lastHeartbeatPublish >= HEARTBEAT_PUBLISH_MS) {
950
+ s.lastHeartbeatPublish = now;
951
+ publish(s);
952
+ }
790
953
  }
791
954
  function idleFor(s, claim, now) {
792
955
  const target = claim.g;
@@ -886,14 +1049,20 @@ export {
886
1049
  AUTH_FAILURE_COOLDOWN_MS,
887
1050
  AUTH_FAILURE_REASON,
888
1051
  BASES,
1052
+ BELOW_MIN_CONTEXT_COOLDOWN_MS,
889
1053
  CATALOG_TTL_MS,
890
1054
  CLAIM_TTL_MS,
1055
+ CONTEXT_OVERFLOW_COOLDOWN_MS,
891
1056
  DEAD_FILE_MS,
892
1057
  DEFAULT_PRESET,
893
1058
  FALLBACK_SLOTS,
894
1059
  HANG_COOLDOWN_MS,
1060
+ HEARTBEAT_PUBLISH_MS,
1061
+ LEARNED_TPM_FILE,
895
1062
  MAX_CLAIM_AGE_MS,
896
1063
  MAX_WEIGHT,
1064
+ MIN_SLOT_CONTEXT,
1065
+ MIN_SLOT_TPM,
897
1066
  MODEL_MISSING_COOLDOWN_MS,
898
1067
  MODEL_MISSING_REASON,
899
1068
  ORPHAN_TMP_MS,
@@ -916,6 +1085,7 @@ export {
916
1085
  acquire,
917
1086
  agentFor,
918
1087
  announcesReset,
1088
+ applyLearnedTpm,
919
1089
  backfillChild,
920
1090
  bindChild,
921
1091
  claimKey,
@@ -930,19 +1100,24 @@ export {
930
1100
  init,
931
1101
  isAuthFailureText,
932
1102
  isAvailable,
1103
+ isContextOverflowText,
933
1104
  isLimitText,
934
1105
  isModelMissingText,
935
1106
  isProbe,
936
1107
  isQuotaReason,
1108
+ isRequestTooLargeText,
937
1109
  isTransientGatewayText,
938
1110
  isUnavailableText,
939
1111
  limitReason,
940
1112
  manualVariant,
941
1113
  markProbe,
942
1114
  noteActivity,
1115
+ noteTpm,
943
1116
  notifyDefault,
1117
+ parseRateLimitHeaders,
944
1118
  parseSlotText,
945
1119
  parseSlots,
1120
+ parseStatedTokenLimit,
946
1121
  parseTextSlots,
947
1122
  pickSlot,
948
1123
  poolPass,
@@ -952,6 +1127,7 @@ export {
952
1127
  pruneSessions,
953
1128
  publish,
954
1129
  readFailures,
1130
+ readLearnedTpm,
955
1131
  readLimits,
956
1132
  readSiblings,
957
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
 
@@ -7,7 +7,7 @@ The plugin options, where the model pool gets its slots, and exactly what LaCode
7
7
  The slot list is the first of these that yields usable slots:
8
8
 
9
9
  1. plugin options — `models` in the [tuple](../README.md#configure)
10
- 2. `~/.config/lacode/pool.txt` — flat `model [weight] [b:<bucket>] [notify:on|off]` per line, preferred
10
+ 2. `~/.config/lacode/pool.txt` — flat `model [weight] [b:<bucket>] [c:<context>] [notify:on|off]` per line, preferred
11
11
  3. `~/.config/lacode/pool.json` (its parent directory is created for you)
12
12
  4. the bundled `presets/free-tier.json`
13
13
  5. a built-in fallback, so routing always works
@@ -33,6 +33,8 @@ A slot may also declare a shared quota bucket with `b:<name>` on the line (or a
33
33
  the sibling would spend the same exhausted window; without one, the model is its
34
34
  own bucket. `pool_status` shows each shared bucket's effective capacity.
35
35
 
36
+ `c:<context>` is the model's context window in tokens; the router uses it to prefer the smallest model that fits the task, and it is optional for hand-written lines.
37
+
36
38
  Each slot gets a `notify` flag that controls the desktop "rate-limit reset"
37
39
  notice raised when a slot that was cooling for a quota/rate-limit reason becomes
38
40
  usable again. The default is **provider-based**: on for `opencode` and
@@ -48,6 +50,18 @@ warning. The flag gates only the desktop reset notice, not pool routing or
48
50
  status, and pool files are hot-reloaded so changes take effect without a
49
51
  restart.
50
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
+
51
65
 
52
66
  ## Which models run which agent
53
67