@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/CHANGELOG.md +42 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2515 -2110
- package/dist/lacode.js +2 -0
- package/dist/pool.js +194 -18
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +15 -1
- package/docs/USER-STORIES.md +56 -8
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +281 -0
- package/scripts/refresh-pool.mjs +149 -69
- package/scripts/score-models.mjs +227 -68
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
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
s.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
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|