@lawaty/lacode 0.7.1 → 0.9.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 +224 -2
- package/README.md +24 -6
- package/dist/agents.js +100 -14
- package/dist/commands.js +10 -5
- package/dist/index.js +740 -77
- package/dist/pool.js +153 -12
- package/docs/CONTEXT.md +21 -6
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +8 -8
- package/presets/free-tier.json +26 -9
package/dist/pool.js
CHANGED
|
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
|
34
34
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
35
35
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
36
36
|
var MAX_WEIGHT = 100;
|
|
37
|
+
var SLOT_CEILING_FACTOR = 2;
|
|
38
|
+
var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
|
|
37
39
|
var MAX_SLOTS = 4;
|
|
38
40
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
39
41
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
40
42
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
41
43
|
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
42
44
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
45
|
+
var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
43
46
|
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
|
|
44
47
|
var BASES = ["explore", "implement", "context"];
|
|
45
48
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
@@ -48,10 +51,10 @@ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
|
48
51
|
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
49
52
|
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
50
53
|
var FALLBACK_SLOTS = [
|
|
51
|
-
{ index: 1, model: "opencode/
|
|
52
|
-
{ index: 2, model: "opencode-go/
|
|
53
|
-
{ index: 3, model: "opencode/
|
|
54
|
-
{ index: 4, model: "opencode/
|
|
54
|
+
{ index: 1, model: "opencode/big-pickle", weight: 3 },
|
|
55
|
+
{ index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
|
|
56
|
+
{ index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
|
|
57
|
+
{ index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
|
|
55
58
|
];
|
|
56
59
|
function parseSlots(parsed, origin) {
|
|
57
60
|
const problems = [];
|
|
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
|
|
|
207
210
|
var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
|
|
208
211
|
var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
|
|
209
212
|
var statedCooldown = (text) => {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
|
|
214
|
+
const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
|
|
215
|
+
if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
|
|
216
|
+
const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
|
|
217
|
+
if (m) return bounded(Number(m[1]) * 60 * 1e3);
|
|
218
|
+
const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
|
|
219
|
+
if (s) return bounded(Number(s[1]) * 1e3);
|
|
216
220
|
return void 0;
|
|
217
221
|
};
|
|
218
222
|
var retryAfterMs = (headers) => {
|
|
@@ -246,10 +250,14 @@ function state(opts = {}) {
|
|
|
246
250
|
cooling: /* @__PURE__ */ new Map(),
|
|
247
251
|
strikes: /* @__PURE__ */ new Map(),
|
|
248
252
|
activity: /* @__PURE__ */ new Map(),
|
|
253
|
+
probes: /* @__PURE__ */ new Set(),
|
|
254
|
+
probing: false,
|
|
255
|
+
lastProbe: 0,
|
|
249
256
|
children: /* @__PURE__ */ new Map(),
|
|
250
257
|
reaped: 0,
|
|
251
258
|
reaping: false,
|
|
252
259
|
lastSweep: 0,
|
|
260
|
+
lastDirSweep: 0,
|
|
253
261
|
id,
|
|
254
262
|
dir,
|
|
255
263
|
modelsFile,
|
|
@@ -286,10 +294,88 @@ function init(s, onWarn) {
|
|
|
286
294
|
onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
|
|
287
295
|
}
|
|
288
296
|
}
|
|
297
|
+
var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
|
|
298
|
+
function sweepStateDir(s) {
|
|
299
|
+
const removed = [];
|
|
300
|
+
if (!s.dirReady) return removed;
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = readdirSync(s.dir);
|
|
304
|
+
} catch {
|
|
305
|
+
return removed;
|
|
306
|
+
}
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const drop = (name) => {
|
|
309
|
+
try {
|
|
310
|
+
unlinkSync(join2(s.dir, name));
|
|
311
|
+
removed.push(name);
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
let age;
|
|
317
|
+
try {
|
|
318
|
+
age = now - statSync(join2(s.dir, entry)).mtimeMs;
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (entry.endsWith(".tmp")) {
|
|
323
|
+
if (age > ORPHAN_TMP_MS) drop(entry);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const claim = /^claims\.(.+)\.json$/.exec(entry);
|
|
327
|
+
if (claim) {
|
|
328
|
+
if (claim[1] === s.id) continue;
|
|
329
|
+
if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
|
|
330
|
+
drop(entry);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (age > DEAD_FILE_MS) drop(entry);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (entry.startsWith("limit.") && entry.endsWith(".json")) {
|
|
337
|
+
try {
|
|
338
|
+
const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
|
|
339
|
+
if (typeof strike.until !== "number") {
|
|
340
|
+
drop(entry);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (strike.until > now) continue;
|
|
344
|
+
if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
|
|
345
|
+
drop(entry);
|
|
346
|
+
} catch {
|
|
347
|
+
drop(entry);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return removed;
|
|
352
|
+
}
|
|
289
353
|
function pruneOwn(s) {
|
|
290
354
|
const now = Date.now();
|
|
291
355
|
for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
|
|
292
356
|
}
|
|
357
|
+
function pruneActivity(s, now) {
|
|
358
|
+
if (s.activity.size === 0) return;
|
|
359
|
+
const live = /* @__PURE__ */ new Set();
|
|
360
|
+
for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
|
|
361
|
+
for (const [id, seen] of s.activity) {
|
|
362
|
+
if (live.has(id)) continue;
|
|
363
|
+
if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function pruneChildren(s, now) {
|
|
367
|
+
if (s.children.size === 0) return;
|
|
368
|
+
for (const [parent, ring] of s.children) {
|
|
369
|
+
const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
|
|
370
|
+
if (fresh.length === 0) s.children.delete(parent);
|
|
371
|
+
else if (fresh.length !== ring.length) s.children.set(parent, fresh);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function pruneSessions(s) {
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
pruneActivity(s, now);
|
|
377
|
+
pruneChildren(s, now);
|
|
378
|
+
}
|
|
293
379
|
function publish(s) {
|
|
294
380
|
if (!s.dirReady) return;
|
|
295
381
|
const now = Date.now();
|
|
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
|
|
|
435
521
|
}
|
|
436
522
|
return strike;
|
|
437
523
|
}
|
|
524
|
+
function clearCooldowns(s, models) {
|
|
525
|
+
const cleared = [];
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const wanted = models && models.length > 0 ? new Set(models) : void 0;
|
|
528
|
+
let entries;
|
|
529
|
+
try {
|
|
530
|
+
entries = readdirSync(s.dir);
|
|
531
|
+
} catch {
|
|
532
|
+
return cleared;
|
|
533
|
+
}
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
536
|
+
const path = join2(s.dir, entry);
|
|
537
|
+
let strike;
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
540
|
+
if (parsed && typeof parsed.model === "string") strike = parsed;
|
|
541
|
+
} catch {
|
|
542
|
+
strike = void 0;
|
|
543
|
+
}
|
|
544
|
+
if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
|
|
545
|
+
try {
|
|
546
|
+
unlinkSync(path);
|
|
547
|
+
} catch {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (strike === void 0) {
|
|
551
|
+
cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
s.cooling.delete(strike.model);
|
|
555
|
+
s.strikes.delete(strike.model);
|
|
556
|
+
cleared.push({
|
|
557
|
+
model: strike.model,
|
|
558
|
+
reason: typeof strike.reason === "string" ? strike.reason : "unknown",
|
|
559
|
+
strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
|
|
560
|
+
secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return cleared;
|
|
564
|
+
}
|
|
565
|
+
var markProbe = (s, sessionID) => s.probes.add(sessionID);
|
|
566
|
+
var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
|
|
567
|
+
var isProbe = (s, sessionID) => s.probes.has(sessionID);
|
|
438
568
|
function refreshCatalog(s, serverUrl) {
|
|
439
569
|
if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
|
|
440
570
|
s.catalogAt = Date.now();
|
|
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
|
|
|
457
587
|
}
|
|
458
588
|
function snapshot(s) {
|
|
459
589
|
pruneOwn(s);
|
|
590
|
+
pruneSessions(s);
|
|
460
591
|
const siblings = readSiblings(s);
|
|
461
592
|
const cooling = readLimits(s);
|
|
462
593
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -466,7 +597,7 @@ function snapshot(s) {
|
|
|
466
597
|
return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
|
|
467
598
|
}
|
|
468
599
|
var loadOf = (counts, model) => counts.get(model) ?? 0;
|
|
469
|
-
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot
|
|
600
|
+
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
|
|
470
601
|
function roundRobinSequence(slots) {
|
|
471
602
|
const seq = [];
|
|
472
603
|
for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
|
|
@@ -629,6 +760,9 @@ export {
|
|
|
629
760
|
PRESETS_DIR,
|
|
630
761
|
QUOTA_COOLDOWN_BASE_MS,
|
|
631
762
|
QUOTA_COOLDOWN_MAX_MS,
|
|
763
|
+
SLOT_CEILING_FACTOR,
|
|
764
|
+
STATED_COOLDOWN_MAX_MS,
|
|
765
|
+
STATE_SWEEP_INTERVAL_MS,
|
|
632
766
|
STUCK_IDLE_MS,
|
|
633
767
|
STUCK_MIN_AGE_MS,
|
|
634
768
|
UNAVAILABLE_COOLDOWN_MS,
|
|
@@ -640,6 +774,7 @@ export {
|
|
|
640
774
|
backfillChild,
|
|
641
775
|
bindChild,
|
|
642
776
|
claimKey,
|
|
777
|
+
clearCooldowns,
|
|
643
778
|
condemnReason,
|
|
644
779
|
coolProvider,
|
|
645
780
|
coolUnavailable,
|
|
@@ -650,10 +785,12 @@ export {
|
|
|
650
785
|
init,
|
|
651
786
|
isAvailable,
|
|
652
787
|
isLimitText,
|
|
788
|
+
isProbe,
|
|
653
789
|
isQuotaReason,
|
|
654
790
|
isTransientGatewayText,
|
|
655
791
|
isUnavailableText,
|
|
656
792
|
manualVariant,
|
|
793
|
+
markProbe,
|
|
657
794
|
noteActivity,
|
|
658
795
|
parseSlotText,
|
|
659
796
|
parseSlots,
|
|
@@ -661,6 +798,7 @@ export {
|
|
|
661
798
|
pickSlot,
|
|
662
799
|
providerOf,
|
|
663
800
|
pruneOwn,
|
|
801
|
+
pruneSessions,
|
|
664
802
|
publish,
|
|
665
803
|
readLimits,
|
|
666
804
|
readSiblings,
|
|
@@ -673,10 +811,13 @@ export {
|
|
|
673
811
|
retryAfterMs,
|
|
674
812
|
roundRobinSequence,
|
|
675
813
|
selfFile,
|
|
814
|
+
slotCeiling,
|
|
676
815
|
slotFor,
|
|
677
816
|
snapshot,
|
|
678
817
|
splitModel,
|
|
679
818
|
state,
|
|
680
819
|
statedCooldown,
|
|
681
|
-
|
|
820
|
+
sweepStateDir,
|
|
821
|
+
underCeiling,
|
|
822
|
+
unmarkProbe
|
|
682
823
|
};
|
package/docs/CONTEXT.md
CHANGED
|
@@ -29,10 +29,11 @@ the exploration it replaces is worse than no map:
|
|
|
29
29
|
for order mutations", not "there is a file called OrderService in
|
|
30
30
|
`src/orders/`". The cartographer is forbidden by name from writing directory
|
|
31
31
|
listings, copying source, or restating what the README already says.
|
|
32
|
-
- **One file per question.** Five files, each with a defined scope, so
|
|
33
|
-
reads one of them rather than all of them.
|
|
32
|
+
- **One file per question.** Five navigation files, each with a defined scope, so
|
|
33
|
+
an agent reads one of them rather than all of them. A sixth file,
|
|
34
|
+
`active.md`, holds the work in flight rather than a map of the repository.
|
|
34
35
|
- **Canonical entry points.** The highest-value thing in the map is a short
|
|
35
|
-
"Start here" list per subsystem: the
|
|
36
|
+
"Start here" list per subsystem: the canonical files an agent should open, each
|
|
36
37
|
with a one-phrase reason.
|
|
37
38
|
- **It links out.** Your project's own README, ADRs and docs are authoritative
|
|
38
39
|
and are not duplicated here.
|
|
@@ -47,7 +48,8 @@ harness that produces a number for your own workload.
|
|
|
47
48
|
|
|
48
49
|
## The files in the map
|
|
49
50
|
|
|
50
|
-
`.opencode/context/` holds the five
|
|
51
|
+
`.opencode/context/` holds the five navigation files below, plus one file that is
|
|
52
|
+
a work list rather than a map:
|
|
51
53
|
|
|
52
54
|
| File | What belongs in it |
|
|
53
55
|
| --- | --- |
|
|
@@ -56,6 +58,7 @@ harness that produces a number for your own workload.
|
|
|
56
58
|
| `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
|
|
57
59
|
| `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
|
|
58
60
|
| `decisions.md` | Architectural decisions that the repository's own decision log does not already record. Concise, variable length. |
|
|
61
|
+
| `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
|
|
59
62
|
|
|
60
63
|
The project's root `AGENTS.md` is the standing-instructions home: the client's
|
|
61
64
|
own statement of intent, what they want worked on and how they want it
|
|
@@ -135,6 +138,18 @@ your source.
|
|
|
135
138
|
If you have defined a command of the same name, yours wins and is left exactly
|
|
136
139
|
as you wrote it — the same merge-without-clobber rule the agents follow.
|
|
137
140
|
|
|
141
|
+
`active.md` is the one file in the directory the plugin maintains rather than
|
|
142
|
+
the cartographer. The cartographer records what happened — work in flight, and a
|
|
143
|
+
task marked finished with the date it finished — and the plugin decides what is
|
|
144
|
+
still worth keeping: the file is regrouped by week and a task that was finished
|
|
145
|
+
more than a week ago is dropped. The arithmetic lives in
|
|
146
|
+
`src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
|
|
147
|
+
model applies is a rule the model applies when it feels like it, and the failure
|
|
148
|
+
is silent — the backlog just grows. The plugin normalizes the file on every
|
|
149
|
+
edit of it and once when the session starts, so a hand edit is grouped and
|
|
150
|
+
pruned whether the cartographer ran or not, and the file is rewritten only when
|
|
151
|
+
its canonical form differs from what is on disk, so the write cannot loop.
|
|
152
|
+
|
|
138
153
|
## One writer
|
|
139
154
|
|
|
140
155
|
Only `context` writes the map. Every other agent is told never to edit
|
|
@@ -152,7 +167,7 @@ naming the one writer, and the permission rule — a permission denial tells an
|
|
|
152
167
|
agent nothing about what it should have done instead.
|
|
153
168
|
|
|
154
169
|
This exists because two writers produce a map that is neither current nor
|
|
155
|
-
trustworthy. Concurrent sessions rewriting the same
|
|
170
|
+
trustworthy. Concurrent sessions rewriting the same files produce
|
|
156
171
|
contradictions that no later reader can resolve, and the reader has no way to
|
|
157
172
|
tell which version is newer.
|
|
158
173
|
|
|
@@ -193,7 +208,7 @@ startup check has no way to see a worktree it was not told about.
|
|
|
193
208
|
|
|
194
209
|
How to detect it: open opencode from the repository root, not from a
|
|
195
210
|
subdirectory of it. If `ls .opencode/context` from your shell's current
|
|
196
|
-
directory does not list the
|
|
211
|
+
directory does not list the map files, your shell is not at the root and
|
|
197
212
|
neither is opencode's worktree. The cost of getting it wrong is not a crash —
|
|
198
213
|
it is a map maintained only by prompt, with no mechanism enforcing who writes it.
|
|
199
214
|
|
package/docs/OMNIROUTE.md
CHANGED
|
@@ -25,8 +25,10 @@ as an opencode provider for you. It is **off by default**: if you do not set
|
|
|
25
25
|
`auto` is the zero-config default, but on a free tier it often resolves to a
|
|
26
26
|
slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
|
|
27
27
|
ordered for latency and is worth applying on a new client.
|
|
28
|
-
3. Optionally, to route pooled work through it,
|
|
29
|
-
|
|
28
|
+
3. Optionally, to route pooled work through it, add an `omniroute/<id>` slot to
|
|
29
|
+
`~/.config/lacode/pool.txt` exactly as you would any other provider. The
|
|
30
|
+
integration gives it no special precedence, priority or weight — its place and
|
|
31
|
+
weight in the pool are whatever you configure. `pool.txt` is optional user
|
|
30
32
|
config; the bundled default preset does **not** include OmniRoute.
|
|
31
33
|
4. **Restart opencode.** Nothing here hot-reloads.
|
|
32
34
|
|
|
@@ -64,7 +66,7 @@ The full option set, for reference:
|
|
|
64
66
|
| `enabled` | `false` | Turns the integration on. |
|
|
65
67
|
| `baseURL` | `http://localhost:20128/v1` | Gateway base URL, normalised to exactly one `/v1`. |
|
|
66
68
|
| `apiKey` | — | Bearer key. **`OMNIROUTE_API_KEY` overrides this value.** The key is never logged. |
|
|
67
|
-
| `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it.
|
|
69
|
+
| `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. It leads the provider's own model list — an OmniRoute-side detail, unrelated to pool order. |
|
|
68
70
|
| `models` | — | A **hard allowlist**: only these ids are ever exposed, even if the gateway lists more. |
|
|
69
71
|
| `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
|
|
70
72
|
| `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
|
|
@@ -95,23 +97,23 @@ failed.
|
|
|
95
97
|
|
|
96
98
|
**Using OmniRoute in the pool.** The integration only *registers* the provider —
|
|
97
99
|
it never touches your pool, and it does not place a slot for you. To route pooled
|
|
98
|
-
work (exploration, implementation, cartography) through the gateway,
|
|
99
|
-
your pool like any other model:
|
|
100
|
+
work (exploration, implementation, cartography) through the gateway, add an
|
|
101
|
+
`omniroute/<id>` slot to your pool like any other model:
|
|
100
102
|
|
|
101
103
|
```
|
|
102
104
|
# ~/.config/lacode/pool.txt
|
|
103
|
-
omniroute/
|
|
104
|
-
opencode/
|
|
105
|
-
opencode-go/
|
|
106
|
-
opencode/big-pickle 1
|
|
105
|
+
omniroute/free-mix
|
|
106
|
+
opencode/big-pickle
|
|
107
|
+
opencode-go/minimax-m3.1-flash-free
|
|
107
108
|
```
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
the
|
|
114
|
-
|
|
110
|
+
OmniRoute is a provider like any other: the pool decides its place and weight,
|
|
111
|
+
and neither the pool nor this integration gives it any built-in precedence or
|
|
112
|
+
priority — where it sits and how many turns it takes are whatever you configure.
|
|
113
|
+
Any `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or
|
|
114
|
+
the whole catalog when neither is set. Because the plugin writes the pool
|
|
115
|
+
nothing, the only thing that changes when the gateway is down is that
|
|
116
|
+
`omniroute/<id>` fails; every other slot keeps working. See
|
|
115
117
|
[docs/USER-STORIES.md](USER-STORIES.md) US-28.
|
|
116
118
|
|
|
117
119
|
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -102,10 +102,10 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
102
102
|
**Story ID(s) for tests:** US-5
|
|
103
103
|
**Acceptance criteria:**
|
|
104
104
|
- [ ] Given a heavy slot and a light slot, When spawns are routed below both ceilings, Then the rotation walks the list in order with each slot taking as many consecutive turns as its weight says, rather than the heavy slot being filled and refilled first.
|
|
105
|
-
- [ ] Given a slot is at its weight
|
|
105
|
+
- [ ] Given a slot is at its ceiling (twice its weight) or cooling, When its turn comes up in the rotation, Then the turn is skipped and the rotation resumes at the following turn.
|
|
106
106
|
- [ ] Given every slot is at its ceiling, When another spawn is routed, Then it goes to the least-loaded slot as overflow.
|
|
107
107
|
- [ ] Given all slots carry equal weight, When spawns are routed, Then the picks round-robin in declaration order.
|
|
108
|
-
- [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its weight
|
|
108
|
+
- [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its ceiling (twice its weight) except by overflow.
|
|
109
109
|
|
|
110
110
|
### US-6: Live pool visibility
|
|
111
111
|
**As a** user, **I want** to see per-slot load and which model is cooling and why, **so that** I understand where my subagents are going.
|
package/package.json
CHANGED
package/presets/README.md
CHANGED
|
@@ -36,10 +36,10 @@ in place, no JSON and no objects:
|
|
|
36
36
|
|
|
37
37
|
```text
|
|
38
38
|
# provider/model-id weight
|
|
39
|
-
opencode
|
|
40
|
-
opencode/
|
|
41
|
-
opencode/
|
|
42
|
-
opencode/
|
|
39
|
+
opencode/big-pickle 3
|
|
40
|
+
opencode-go/minimax-m3.1-flash-free 2
|
|
41
|
+
opencode/deepseek-v4.1-flash-free 1
|
|
42
|
+
opencode/glm-5.3-flash-free 1
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
`#` starts a comment (whole-line or trailing), blank lines are ignored, and the
|
|
@@ -62,7 +62,7 @@ else means the flat form.
|
|
|
62
62
|
| Field | Required | Meaning |
|
|
63
63
|
|-------|----------|---------|
|
|
64
64
|
| `model` | yes | `provider/model-id`, split on the **first** slash only |
|
|
65
|
-
| `weight` | no |
|
|
65
|
+
| `weight` | no | turns taken per rotation (the slot's share of the rotation); its concurrency ceiling is twice that. Absent = 1 |
|
|
66
66
|
|
|
67
67
|
`$comment` is a JSON-legal array of strings and is ignored by the loader; the
|
|
68
68
|
bundled preset uses it to explain itself in place.
|
|
@@ -91,9 +91,9 @@ because the pool requires it.
|
|
|
91
91
|
|
|
92
92
|
## Weight, precisely
|
|
93
93
|
|
|
94
|
-
A weight is
|
|
95
|
-
|
|
96
|
-
round-robin**:
|
|
94
|
+
A weight is the number of turns a slot takes per rotation — its share of the
|
|
95
|
+
work — and its concurrency ceiling is **twice that weight**: a weight-3 slot may
|
|
96
|
+
hold 6 concurrent sessions. Routing is **order-first weighted round-robin**:
|
|
97
97
|
|
|
98
98
|
1. the slot list is expanded into a run in which each slot appears once per
|
|
99
99
|
unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
|
package/presets/free-tier.json
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
"AT MOST 4 SLOTS: one hidden variant per base per slot is declared, so a fifth entry would",
|
|
14
14
|
"route at an agent that does not exist. Extra entries are dropped with a warning.",
|
|
15
15
|
"",
|
|
16
|
-
"WEIGHT IS THE ONLY KNOWN KNOB
|
|
17
|
-
"
|
|
16
|
+
"WEIGHT IS THE ONLY KNOWN KNOB: one position per unit of weight in the rotation run,",
|
|
17
|
+
"and a concurrency ceiling of TWICE that weight -- a share of the work, not a priority.",
|
|
18
|
+
"Routing is ORDER-FIRST",
|
|
18
19
|
"WEIGHTED ROUND-ROBIN: the slot list is walked IN ORDER and each slot takes as many",
|
|
19
20
|
"consecutive turns as its weight says, so weights 3,2,1,1 produce the run 1,1,1,2,2,3,4,",
|
|
20
21
|
"which then repeats. The rule is:",
|
|
@@ -25,11 +26,27 @@
|
|
|
25
26
|
" 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
|
|
26
27
|
" so a burst past the summed ceilings degrades to even spreading and never blocks",
|
|
27
28
|
"",
|
|
28
|
-
"With this file:
|
|
29
|
-
"(opencode-go) takes 2, then
|
|
29
|
+
"With this file: big-pickle takes 3 consecutive turns, then minimax-m3.1-flash-free",
|
|
30
|
+
"(opencode-go) takes 2, then deepseek-v4.1-flash-free and glm-5.3-flash-free take 1",
|
|
31
|
+
"each -- sequence 1,1,1,2,2,3,4,...",
|
|
30
32
|
"Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
|
|
31
33
|
"primary/overflow cycle exactly, which is why the old tier values still work.",
|
|
32
34
|
"",
|
|
35
|
+
"WEIGHTS DESCEND, AND THAT IS NOT COSMETIC. The rotation is order-first: the run is",
|
|
36
|
+
"expanded in declaration order, so the heaviest slot must come first for a skip to",
|
|
37
|
+
"land on the heaviest slot that is LEFT. A heavier slot declared after a lighter one",
|
|
38
|
+
"is unreachable in the way the pool means.",
|
|
39
|
+
"",
|
|
40
|
+
"EVERY SLOT IS A MODEL THAT IS PROBABLY STILL THERE NEXT WEEK, AND NO TWO SLOTS",
|
|
41
|
+
"SHARE A FAMILY. A free model can be withdrawn by its provider without warning --",
|
|
42
|
+
"space-bunny-free was pulled from the opencode provider while it was still this",
|
|
43
|
+
"file's first slot, and every new session then died on 'Model not found'. So the",
|
|
44
|
+
"slots here are the long-lived free ones (big-pickle has been on the opencode",
|
|
45
|
+
"provider for years), spread over two providers and four model families, because a",
|
|
46
|
+
"pool where every slot is one family is one withdrawal away from being empty. If a",
|
|
47
|
+
"slot does go away, the plugin warns at startup and the rest keep working; the fix",
|
|
48
|
+
"is a one-line edit here, not a redesign.",
|
|
49
|
+
"",
|
|
33
50
|
"Round-robin rather than heaviest-first because re-prioritising the heaviest slot on",
|
|
34
51
|
"EVERY spawn drained the fastest provider's free-tier window early: one provider",
|
|
35
52
|
"absorbed all work up to its ceiling and kept being refilled the moment a claim",
|
|
@@ -66,9 +83,9 @@
|
|
|
66
83
|
"Changing this file requires restarting every opencode process: plugins are not hot-reloaded."
|
|
67
84
|
],
|
|
68
85
|
"slots": [
|
|
69
|
-
{ "model": "opencode/
|
|
70
|
-
{ "model": "opencode-go/
|
|
71
|
-
{ "model": "opencode/
|
|
72
|
-
{ "model": "opencode/
|
|
86
|
+
{ "model": "opencode/big-pickle", "weight": 3 },
|
|
87
|
+
{ "model": "opencode-go/minimax-m3.1-flash-free", "weight": 2 },
|
|
88
|
+
{ "model": "opencode/deepseek-v4.1-flash-free", "weight": 1 },
|
|
89
|
+
{ "model": "opencode/glm-5.3-flash-free", "weight": 1 }
|
|
73
90
|
]
|
|
74
|
-
}
|
|
91
|
+
}
|