@lawaty/lacode 2.0.0-beta.0 → 2.2.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 +35 -1
- package/dist/index.js +310 -24
- package/dist/pool.js +53 -8
- package/docs/CONFIGURATION.md +3 -1
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +187 -0
- package/scripts/refresh-pool.mjs +115 -87
- package/scripts/score-models.mjs +188 -59
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0-beta.0",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"README.md",
|
|
52
52
|
"docs",
|
|
53
53
|
"scripts/score-models.mjs",
|
|
54
|
+
"scripts/pool-selection.mjs",
|
|
54
55
|
"scripts/refresh-pool.mjs"
|
|
55
56
|
],
|
|
56
57
|
"scripts": {
|
package/presets/README.md
CHANGED
|
@@ -36,15 +36,15 @@ change here takes effect without a restart. Plugin CODE still needs one.
|
|
|
36
36
|
in place, no JSON and no objects:
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
# provider/model-id weight
|
|
40
|
-
opencode/big-pickle 3
|
|
41
|
-
opencode-go/beam-free 2
|
|
42
|
-
opencode/deepseek-v4.1-flash-free 1
|
|
43
|
-
opencode/glm-5.3-flash-free 1
|
|
39
|
+
# provider/model-id weight c:<context>
|
|
40
|
+
opencode/big-pickle 3 c:200000
|
|
41
|
+
opencode-go/beam-free 2 c:200000
|
|
42
|
+
opencode/deepseek-v4.1-flash-free 1 c:200000
|
|
43
|
+
opencode/glm-5.3-flash-free 1 c:200000
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
`#` starts a comment (whole-line or trailing), blank lines are ignored, and the
|
|
47
|
-
weight is optional (default 1). The rules are exactly the same as the JSON form
|
|
47
|
+
weight is optional (default 1). `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. The rules are exactly the same as the JSON form
|
|
48
48
|
below: any number of slots, a model may repeat on another line, split on the
|
|
49
49
|
first slash only. The loader accepts either shape in the same file, so a leading
|
|
50
50
|
`{` means JSON and anything else means the flat form.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Pure pool selection: which scored models become slots, and when a limited
|
|
2
|
+
// model is worth retrying. Imported by scripts/score-models.mjs; refresh-pool
|
|
3
|
+
// consumes the resulting `selection` and does not repeat this policy.
|
|
4
|
+
|
|
5
|
+
export const MIN_CONTEXT = 100_000
|
|
6
|
+
export const SMALL_CONTEXT_MAX = 200_000
|
|
7
|
+
export const MIN_SCORE = 60
|
|
8
|
+
export const POOL_SIZE = 10
|
|
9
|
+
export const MAX_ZEN = 1
|
|
10
|
+
export const LARGE_RESERVE = 3
|
|
11
|
+
export const MAX_PER_PROVIDER = 2
|
|
12
|
+
export const MAX_PER_PROVIDER_LARGE = 1
|
|
13
|
+
export const DEFAULT_QUOTA_RETRY_MS = 15 * 60 * 1000
|
|
14
|
+
export const CREDIT_RETRY_MS = 6 * 60 * 60 * 1000
|
|
15
|
+
export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000
|
|
16
|
+
export const STATED_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
export const isTransientFailure = (row) =>
|
|
19
|
+
row?.ok === false &&
|
|
20
|
+
/timeout|timed out|rate-limit|no answer|no response|unreachable|ECONN|ETIMEDOUT|fetch failed|socket hang up|credit|402/i.test(
|
|
21
|
+
String(row.reason ?? ""),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const isLimitText = (text) =>
|
|
25
|
+
/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)|\b429\b/i.test(
|
|
26
|
+
String(text ?? ""),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const isDailyLimit = (text) => /per[\s-]?day|daily|free-models-per-day|requests?\s+per\s+day/i.test(String(text ?? ""))
|
|
30
|
+
|
|
31
|
+
const isCreditText = /out of credits|insufficient credits|credit balance|requires more credits|\b402\b/i
|
|
32
|
+
|
|
33
|
+
const bounded = (ms) => (Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : undefined)
|
|
34
|
+
|
|
35
|
+
export const statedCooldownMs = (text) => {
|
|
36
|
+
const value = String(text ?? "")
|
|
37
|
+
const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(value)
|
|
38
|
+
if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1000)
|
|
39
|
+
const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(value)
|
|
40
|
+
if (m) return bounded(Number(m[1]) * 60 * 1000)
|
|
41
|
+
const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(value)
|
|
42
|
+
if (s) return bounded(Number(s[1]) * 1000)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const retryAfterHeaderMs = (value) => {
|
|
47
|
+
if (!value) return undefined
|
|
48
|
+
const text = String(value).trim()
|
|
49
|
+
if (/^\d+$/.test(text)) return bounded(Number(text) * 1000)
|
|
50
|
+
const at = Date.parse(text)
|
|
51
|
+
return Number.isFinite(at) ? bounded(at - Date.now()) : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function retryAfterMs(text, header, now = Date.now()) {
|
|
55
|
+
const headerMs = retryAfterHeaderMs(header)
|
|
56
|
+
if (headerMs) return now + headerMs
|
|
57
|
+
const stated = statedCooldownMs(text)
|
|
58
|
+
if (stated) return now + stated
|
|
59
|
+
const normalized = String(text ?? "")
|
|
60
|
+
if (isDailyLimit(text)) {
|
|
61
|
+
const d = new Date(now)
|
|
62
|
+
const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0, 0)
|
|
63
|
+
return next > now ? next : now + MAX_RETRY_DELAY_MS
|
|
64
|
+
}
|
|
65
|
+
if (isCreditText.test(normalized)) return now + CREDIT_RETRY_MS
|
|
66
|
+
if (isLimitText(text)) return now + DEFAULT_QUOTA_RETRY_MS
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rowsOf = (providers) =>
|
|
71
|
+
Array.isArray(providers)
|
|
72
|
+
? providers.flatMap((entry) => (Array.isArray(entry?.rows) ? entry.rows : []))
|
|
73
|
+
: []
|
|
74
|
+
|
|
75
|
+
const providerOf = (model) => String(model ?? "").split("/")[0]
|
|
76
|
+
|
|
77
|
+
export function selectPool({
|
|
78
|
+
providers,
|
|
79
|
+
previousProviders,
|
|
80
|
+
minContext = MIN_CONTEXT,
|
|
81
|
+
minScore = MIN_SCORE,
|
|
82
|
+
poolSize = POOL_SIZE,
|
|
83
|
+
maxZen = MAX_ZEN,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const rankable = (row) =>
|
|
86
|
+
row?.ok === true &&
|
|
87
|
+
typeof row.score === "number" &&
|
|
88
|
+
Number.isFinite(row.score) &&
|
|
89
|
+
row.score >= minScore &&
|
|
90
|
+
(row.context == null || row.context >= minContext)
|
|
91
|
+
const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
|
|
92
|
+
|
|
93
|
+
const rows = rowsOf(providers)
|
|
94
|
+
const previousRows = rowsOf(previousProviders)
|
|
95
|
+
const currentByModel = new Map(rows.map((row) => [row.model, row]))
|
|
96
|
+
const erroredProviders = new Set(
|
|
97
|
+
(Array.isArray(providers) ? providers : [])
|
|
98
|
+
.filter((entry) => entry?.error)
|
|
99
|
+
.map((entry) => String(entry.provider ?? "")),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// A model the previous run scored is kept when this run could not reach it
|
|
103
|
+
// for a transient reason. A model the provider says is gone is not kept.
|
|
104
|
+
const fallback = previousRows.filter((row) => {
|
|
105
|
+
if (!rankable(row)) return false
|
|
106
|
+
const current = currentByModel.get(row.model)
|
|
107
|
+
if (current) return isTransientFailure(current)
|
|
108
|
+
return erroredProviders.has(providerOf(row.model))
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const merge = (pick) => {
|
|
112
|
+
const seen = new Set()
|
|
113
|
+
const out = []
|
|
114
|
+
for (const row of [...rows, ...fallback]) {
|
|
115
|
+
if (!pick(row) || !rankable(row) || seen.has(row.model)) continue
|
|
116
|
+
seen.add(row.model)
|
|
117
|
+
out.push(row)
|
|
118
|
+
}
|
|
119
|
+
return out.sort(byScore)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const go = merge((row) => providerOf(row.model) === "opencode-go")
|
|
123
|
+
const other = merge((row) => providerOf(row.model) !== "opencode-go" && providerOf(row.model) !== "opencode")
|
|
124
|
+
const zen = merge((row) => providerOf(row.model) === "opencode")
|
|
125
|
+
|
|
126
|
+
const small = other
|
|
127
|
+
.filter((row) => row.context != null && row.context < SMALL_CONTEXT_MAX)
|
|
128
|
+
.sort(byScore)
|
|
129
|
+
const large = other
|
|
130
|
+
.filter((row) => row.context == null || row.context >= SMALL_CONTEXT_MAX)
|
|
131
|
+
.sort(byScore)
|
|
132
|
+
|
|
133
|
+
const capPerProvider = (rows, max) => {
|
|
134
|
+
const counts = new Map()
|
|
135
|
+
const out = []
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const provider = providerOf(row.model)
|
|
138
|
+
const count = counts.get(provider) ?? 0
|
|
139
|
+
if (count >= max) continue
|
|
140
|
+
counts.set(provider, count + 1)
|
|
141
|
+
out.push(row)
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cappedSmall = capPerProvider(small, MAX_PER_PROVIDER)
|
|
147
|
+
const cappedLarge = capPerProvider(large, MAX_PER_PROVIDER_LARGE)
|
|
148
|
+
|
|
149
|
+
const remaining = Math.max(0, poolSize - go.length)
|
|
150
|
+
const pickedLarge = cappedLarge.slice(0, Math.min(LARGE_RESERVE, remaining))
|
|
151
|
+
const pickedSmall = cappedSmall.slice(0, Math.max(0, remaining - pickedLarge.length))
|
|
152
|
+
|
|
153
|
+
if (pickedLarge.length + pickedSmall.length < remaining) {
|
|
154
|
+
const need = remaining - pickedLarge.length - pickedSmall.length
|
|
155
|
+
pickedLarge.push(...cappedLarge.slice(pickedLarge.length, pickedLarge.length + need))
|
|
156
|
+
}
|
|
157
|
+
if (pickedLarge.length + pickedSmall.length < remaining) {
|
|
158
|
+
const need = remaining - pickedLarge.length - pickedSmall.length
|
|
159
|
+
pickedSmall.push(...cappedSmall.slice(pickedSmall.length, pickedSmall.length + need))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const zenSlots = Math.max(0, Math.min(maxZen, poolSize - go.length - pickedSmall.length - pickedLarge.length))
|
|
163
|
+
const pickedZen = zen.slice(0, zenSlots)
|
|
164
|
+
|
|
165
|
+
const selection = [
|
|
166
|
+
...go.map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
|
|
167
|
+
...pickedSmall.map((row) => ({ model: row.model, weight: 2, context: row.context ?? null })),
|
|
168
|
+
...pickedLarge.map((row) => ({ model: row.model, weight: 1, context: row.context ?? null })),
|
|
169
|
+
...pickedZen.map((row) => ({ model: row.model, weight: 1, bucket: "opencode", context: row.context ?? null })),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
const warnings = []
|
|
173
|
+
const total = selection.length
|
|
174
|
+
if (total === 0) warnings.push("no scorable model at or above the minimum score is available")
|
|
175
|
+
else if (total < poolSize) warnings.push(`only ${total} scorable model(s) available, wanted ${poolSize}`)
|
|
176
|
+
|
|
177
|
+
const limited = rows.filter((row) => row?.ok === false && Number.isFinite(row.retryAfter))
|
|
178
|
+
const retry =
|
|
179
|
+
limited.length > 0
|
|
180
|
+
? {
|
|
181
|
+
after: new Date(Math.min(...limited.map((row) => row.retryAfter))).toISOString(),
|
|
182
|
+
models: limited.map((row) => row.model),
|
|
183
|
+
}
|
|
184
|
+
: undefined
|
|
185
|
+
|
|
186
|
+
return { selection, warnings, retry }
|
|
187
|
+
}
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -6,20 +6,20 @@
|
|
|
6
6
|
// writes on a fail-closed condition.
|
|
7
7
|
//
|
|
8
8
|
// Layout (exact, per spec):
|
|
9
|
-
// 1. every
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// 1. every opencode-go model with ok:true, a numeric score at or above the
|
|
10
|
+
// score floor and a context at or above the context floor, sorted by score
|
|
11
|
+
// descending, weight 4
|
|
12
|
+
// 2. the best (10 - n) models from every provider other than opencode,
|
|
13
|
+
// sorted by score descending, weight 2
|
|
14
|
+
// 3. at most one opencode model as a last resort, weight 2, with b:opencode
|
|
15
15
|
//
|
|
16
|
-
// Total =
|
|
16
|
+
// Total = n + min(10 - n, #others), where n = #1.
|
|
17
17
|
//
|
|
18
18
|
// This file ships as source and must run with plain `node` (ESM, no TS).
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
21
21
|
import { execFileSync } from "node:child_process"
|
|
22
|
-
import { homedir } from "node:os"
|
|
22
|
+
import { homedir, userInfo } from "node:os"
|
|
23
23
|
import { dirname, join, resolve } from "node:path"
|
|
24
24
|
import { fileURLToPath } from "node:url"
|
|
25
25
|
|
|
@@ -27,6 +27,10 @@ const HOME = homedir()
|
|
|
27
27
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
28
28
|
export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
|
|
29
29
|
export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
|
|
30
|
+
export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "lacode")
|
|
31
|
+
export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
|
|
32
|
+
export const STABLE_SCORER = (home = HOME) => join(STABLE_LIB_DIR(home), "score-models.mjs")
|
|
33
|
+
export const STABLE_SELECTION = (home = HOME) => join(STABLE_LIB_DIR(home), "pool-selection.mjs")
|
|
30
34
|
export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
|
|
31
35
|
export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
|
|
32
36
|
|
|
@@ -35,25 +39,6 @@ const isSlotLine = (line) => {
|
|
|
35
39
|
return stripped.length > 0 && stripped.includes("/")
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
const providerOf = (modelOrLine) => {
|
|
39
|
-
const stripped = String(modelOrLine).split("#")[0].trim()
|
|
40
|
-
const token = stripped.split(/\s+/)[0] || ""
|
|
41
|
-
const slash = token.indexOf("/")
|
|
42
|
-
return slash < 0 ? token : token.slice(0, slash)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const isFree = (modelId) => {
|
|
46
|
-
const id = String(modelId || "")
|
|
47
|
-
return id.endsWith(":free") || id.endsWith("-free")
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
|
|
51
|
-
|
|
52
|
-
const okFreeRows = (providerEntry) => {
|
|
53
|
-
const rows = Array.isArray(providerEntry?.rows) ? providerEntry.rows : []
|
|
54
|
-
return rows.filter((row) => row?.ok === true && numericScore(row) && isFree(row.model))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
42
|
function splitHeaderAndSlots(text) {
|
|
58
43
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
59
44
|
// Drop a single trailing empty element produced by a final newline so we can
|
|
@@ -70,7 +55,7 @@ function splitHeaderAndSlots(text) {
|
|
|
70
55
|
function refreshComment(now, scoresGeneratedAt) {
|
|
71
56
|
const when = now instanceof Date ? now.toISOString() : String(now)
|
|
72
57
|
const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
|
|
73
|
-
return `# Refreshed ${when} from model-scores.json${gen}: opencode-go
|
|
58
|
+
return `# Refreshed ${when} from model-scores.json${gen}: opencode-go first (weight 4), small-context providers (weight 2), large-context providers (weight 1), opencode last (max 1).`
|
|
74
59
|
}
|
|
75
60
|
|
|
76
61
|
function withRefreshComment(headerLines, comment) {
|
|
@@ -78,8 +63,9 @@ function withRefreshComment(headerLines, comment) {
|
|
|
78
63
|
return [...kept, comment]
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
function formatGeneratedLine(model, weight, bucket) {
|
|
66
|
+
function formatGeneratedLine(model, weight, bucket, context) {
|
|
82
67
|
const parts = [String(model), String(weight)]
|
|
68
|
+
if (context != null && Number.isFinite(Number(context))) parts.push(`c:${Math.round(Number(context))}`)
|
|
83
69
|
if (bucket) parts.push(`b:${bucket}`)
|
|
84
70
|
return parts.join(" ")
|
|
85
71
|
}
|
|
@@ -88,56 +74,36 @@ function formatGeneratedLine(model, weight, bucket) {
|
|
|
88
74
|
* Pure builder. Throws on every fail-closed condition; never touches the filesystem.
|
|
89
75
|
*
|
|
90
76
|
* @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
|
|
91
|
-
* @returns {{ text: string, slots: string[], warnings: string[], chosen: {
|
|
77
|
+
* @returns {{ text: string, slots: string[], warnings: string[], chosen: { selected: string[] } }}
|
|
92
78
|
*/
|
|
93
79
|
export function buildPool(input) {
|
|
94
|
-
const warnings = []
|
|
95
80
|
const scores = input?.scores
|
|
96
|
-
if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers)) {
|
|
97
|
-
throw new Error("score file is missing or invalid (expected { providers: [...] })")
|
|
81
|
+
if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers) || !Array.isArray(scores.selection)) {
|
|
82
|
+
throw new Error("score file is missing or invalid (expected { providers: [...], selection: [...] })")
|
|
98
83
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
84
|
+
const selection = scores.selection.filter(
|
|
85
|
+
(entry) =>
|
|
86
|
+
entry &&
|
|
87
|
+
typeof entry.model === "string" &&
|
|
88
|
+
entry.model.includes("/") &&
|
|
89
|
+
typeof entry.weight === "number" &&
|
|
90
|
+
Number.isFinite(entry.weight) &&
|
|
91
|
+
entry.weight > 0,
|
|
103
92
|
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const goRows = okFreeRows(goEntry).sort((a, b) => b.score - a.score)
|
|
107
|
-
if (goRows.length === 0) {
|
|
108
|
-
throw new Error("no free opencode-go model with ok:true and a numeric score is available; refusing to write pool.txt")
|
|
93
|
+
if (selection.length === 0) {
|
|
94
|
+
throw new Error("score file has no selected models; refusing to write pool.txt")
|
|
109
95
|
}
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
if (opRows.length === 0) {
|
|
114
|
-
throw new Error("no free opencode model with ok:true and a numeric score is available; refusing to write pool.txt")
|
|
115
|
-
}
|
|
116
|
-
const bestOp = opRows[0].model
|
|
117
|
-
|
|
118
|
-
const { header, slots: currentSlots } = splitHeaderAndSlots(input?.currentPoolText ?? "")
|
|
119
|
-
const preserved = currentSlots.filter((line) => {
|
|
120
|
-
if (!isSlotLine(line)) return false
|
|
121
|
-
return !scoredProviders.has(providerOf(line))
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
const block1 = goRows.map((row) => formatGeneratedLine(row.model, 4))
|
|
125
|
-
const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
|
|
126
|
-
const outSlots = [...block1, ...preserved, ...block3]
|
|
127
|
-
|
|
96
|
+
const warnings = Array.isArray(scores.warnings) ? scores.warnings : []
|
|
97
|
+
const { header } = splitHeaderAndSlots(input?.currentPoolText ?? "")
|
|
98
|
+
const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context))
|
|
128
99
|
const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
|
|
129
100
|
const outHeader = withRefreshComment(header, comment)
|
|
130
|
-
|
|
131
101
|
const text = [...outHeader, "", ...outSlots, ""].join("\n")
|
|
132
102
|
return {
|
|
133
103
|
text,
|
|
134
104
|
slots: outSlots,
|
|
135
105
|
warnings,
|
|
136
|
-
chosen: {
|
|
137
|
-
opencodeGo: goRows.map((r) => r.model),
|
|
138
|
-
opencode: bestOp,
|
|
139
|
-
preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
|
|
140
|
-
},
|
|
106
|
+
chosen: { selected: selection.map((entry) => entry.model) },
|
|
141
107
|
}
|
|
142
108
|
}
|
|
143
109
|
|
|
@@ -163,6 +129,12 @@ function atomicWriteSimple(path, text) {
|
|
|
163
129
|
renameSync(tmp, path)
|
|
164
130
|
}
|
|
165
131
|
|
|
132
|
+
function writeFileAtomic(path, text) {
|
|
133
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
134
|
+
writeFileSync(tmp, text, "utf8")
|
|
135
|
+
renameSync(tmp, path)
|
|
136
|
+
}
|
|
137
|
+
|
|
166
138
|
function runScorer(max) {
|
|
167
139
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
168
140
|
const scorer = resolve(here, "score-models.mjs")
|
|
@@ -171,6 +143,41 @@ function runScorer(max) {
|
|
|
171
143
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
172
144
|
}
|
|
173
145
|
|
|
146
|
+
export function scheduleRetry(retry, options = {}) {
|
|
147
|
+
const at = Date.parse(retry?.after ?? "")
|
|
148
|
+
if (!Number.isFinite(at)) return { scheduled: false, reason: "no reset time" }
|
|
149
|
+
const now = options.now ?? Date.now
|
|
150
|
+
const nowMs = now()
|
|
151
|
+
const seconds = Math.ceil((at - nowMs) / 1000)
|
|
152
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "reset is not in the future" }
|
|
153
|
+
if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "reset is more than a day away" }
|
|
154
|
+
const exec = options.exec ?? execFileSync
|
|
155
|
+
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
156
|
+
const nodePath = options.nodePath ?? process.execPath
|
|
157
|
+
const home = options.home ?? HOME
|
|
158
|
+
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
159
|
+
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
160
|
+
try {
|
|
161
|
+
exec(
|
|
162
|
+
"systemd-run",
|
|
163
|
+
[
|
|
164
|
+
"--user",
|
|
165
|
+
"--collect",
|
|
166
|
+
`--unit=${unit}`,
|
|
167
|
+
`--on-active=${seconds}`,
|
|
168
|
+
`--setenv=HOME=${home}`,
|
|
169
|
+
`--setenv=PATH=${path}`,
|
|
170
|
+
nodePath,
|
|
171
|
+
scriptPath,
|
|
172
|
+
],
|
|
173
|
+
{ stdio: "ignore" },
|
|
174
|
+
)
|
|
175
|
+
return { scheduled: true, seconds, after: retry.after }
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { scheduled: false, reason: String(error?.message ?? error) }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
174
181
|
export function renderTimerUnits(input = {}) {
|
|
175
182
|
const nodePath = input.nodePath || process.execPath
|
|
176
183
|
const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
|
|
@@ -192,8 +199,7 @@ Nice=10
|
|
|
192
199
|
Description=Daily LaCode model pool refresh
|
|
193
200
|
|
|
194
201
|
[Timer]
|
|
195
|
-
OnCalendar
|
|
196
|
-
RandomizedDelaySec=2h
|
|
202
|
+
OnCalendar=*-*-* 01:00:00
|
|
197
203
|
Persistent=true
|
|
198
204
|
Unit=${REFRESH_SERVICE_NAME}
|
|
199
205
|
|
|
@@ -203,18 +209,25 @@ WantedBy=timers.target
|
|
|
203
209
|
return { service, timer, servicePath, timerPath }
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
function installTimerUnits() {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
home: HOME,
|
|
211
|
-
})
|
|
212
|
-
mkdirSync(dirname(units.servicePath), { recursive: true })
|
|
213
|
-
writeFileSync(units.servicePath, units.service, "utf8")
|
|
214
|
-
writeFileSync(units.timerPath, units.timer, "utf8")
|
|
212
|
+
function installTimerUnits(stable = false) {
|
|
213
|
+
const live = fileURLToPath(import.meta.url)
|
|
214
|
+
const scriptPath = stable ? STABLE_SCRIPT(HOME) : live
|
|
215
|
+
const units = renderTimerUnits({ nodePath: process.execPath, scriptPath, home: HOME })
|
|
215
216
|
try {
|
|
217
|
+
if (stable) {
|
|
218
|
+
mkdirSync(STABLE_LIB_DIR(HOME), { recursive: true })
|
|
219
|
+
copyFileSync(live, scriptPath)
|
|
220
|
+
copyFileSync(resolve(dirname(live), "score-models.mjs"), STABLE_SCORER(HOME))
|
|
221
|
+
copyFileSync(resolve(dirname(live), "pool-selection.mjs"), STABLE_SELECTION(HOME))
|
|
222
|
+
}
|
|
223
|
+
mkdirSync(dirname(units.servicePath), { recursive: true })
|
|
224
|
+
writeFileAtomic(units.servicePath, units.service)
|
|
225
|
+
writeFileAtomic(units.timerPath, units.timer)
|
|
216
226
|
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
|
|
217
227
|
execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
|
|
228
|
+
try {
|
|
229
|
+
execFileSync("loginctl", ["enable-linger", userInfo().username], { stdio: "ignore" })
|
|
230
|
+
} catch {}
|
|
218
231
|
} catch (error) {
|
|
219
232
|
console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
|
|
220
233
|
console.error(`unit files written:`)
|
|
@@ -226,6 +239,7 @@ function installTimerUnits() {
|
|
|
226
239
|
process.exitCode = 1
|
|
227
240
|
return false
|
|
228
241
|
}
|
|
242
|
+
if (stable) console.log(`copied ${live}, score-models.mjs and pool-selection.mjs to ${STABLE_LIB_DIR(HOME)}`)
|
|
229
243
|
console.log(`installed ${units.servicePath}`)
|
|
230
244
|
console.log(`installed ${units.timerPath}`)
|
|
231
245
|
console.log(`enabled ${REFRESH_TIMER_NAME}`)
|
|
@@ -236,10 +250,13 @@ function usage() {
|
|
|
236
250
|
console.log(`Usage: node scripts/refresh-pool.mjs [options]
|
|
237
251
|
|
|
238
252
|
--from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
|
|
253
|
+
--no-retry do not schedule a transient retry after a rate-limited run
|
|
239
254
|
--install-timer install and enable a user systemd oneshot service + daily timer
|
|
255
|
+
(add --stable to copy the script to ~/.local/lib/lacode and
|
|
256
|
+
point ExecStart there, so plugin-cache updates do not break it)
|
|
240
257
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
241
258
|
--dry-run same as --print
|
|
242
|
-
--max N models per provider passed to the scorer (default:
|
|
259
|
+
--max N models per provider passed to the scorer (default: 50)
|
|
243
260
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
244
261
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
245
262
|
-h, --help this message`)
|
|
@@ -248,18 +265,19 @@ function usage() {
|
|
|
248
265
|
async function main() {
|
|
249
266
|
const argv = process.argv.slice(2)
|
|
250
267
|
if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
if (argv.includes("--install-timer")) {
|
|
269
|
+
installTimerUnits(argv.includes("--stable"))
|
|
270
|
+
return
|
|
271
|
+
}
|
|
255
272
|
const fromScores = argv.includes("--from-scores")
|
|
256
273
|
const printOnly = argv.includes("--print") || argv.includes("--dry-run")
|
|
274
|
+
const noRetry = argv.includes("--no-retry")
|
|
257
275
|
const value = (flag, fallback) => {
|
|
258
276
|
const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
|
|
259
277
|
if (!hit) return fallback
|
|
260
278
|
return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
|
|
261
279
|
}
|
|
262
|
-
const max = Math.max(1, Number(value("--max", "
|
|
280
|
+
const max = Math.max(1, Number(value("--max", "50")) || 50)
|
|
263
281
|
const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
|
|
264
282
|
const poolPath = value("--pool", DEFAULT_POOL_PATH)
|
|
265
283
|
|
|
@@ -294,12 +312,22 @@ async function main() {
|
|
|
294
312
|
|
|
295
313
|
const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
|
|
296
314
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
315
|
+
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
297
316
|
const summary = [
|
|
298
|
-
`
|
|
299
|
-
`
|
|
300
|
-
`
|
|
317
|
+
`selected [${built.chosen.selected.join(", ")}]`,
|
|
318
|
+
...(dropped.length ? [`dropped ${dropped.length} model(s) below the context/score floor`] : []),
|
|
319
|
+
...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
|
|
301
320
|
].join(" | ")
|
|
302
321
|
|
|
322
|
+
if (!printOnly && !noRetry) {
|
|
323
|
+
const scheduled = scheduleRetry(scores.retry)
|
|
324
|
+
if (scheduled.scheduled) {
|
|
325
|
+
console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s) at ${scheduled.after}`)
|
|
326
|
+
} else if (scores.retry) {
|
|
327
|
+
console.log(`retry not scheduled: ${scheduled.reason}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
303
331
|
if (printOnly) {
|
|
304
332
|
process.stdout.write(built.text)
|
|
305
333
|
console.error(`--print: proposed ${oldSlotCount} -> ${newSlotCount} slots; no changes written. ${summary}`)
|