@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.
@@ -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.0.0-beta.0",
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
+ }
@@ -6,20 +6,20 @@
6
6
  // writes on a fail-closed condition.
7
7
  //
8
8
  // Layout (exact, per spec):
9
- // 1. every free opencode-go model with ok:true and a numeric score, one slot
10
- // each, sorted by score descending, weight 4
11
- // 2. every slot from the current pool whose provider is NOT present in the
12
- // score file, preserved exactly (raw line text, weight/bucket/notify, order)
13
- // 3. exactly one free opencode model — the best ok:true numeric score —
14
- // weight 2, with b:opencode
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 = #opencode-go free + #preserved unscored + 1.
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 { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
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 free models first, best opencode last.`
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: { opencodeGo: string[], opencode: string, preserved: string[] } }}
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
- const providerEntries = scores.providers
101
- const scoredProviders = new Set(
102
- providerEntries.map((p) => String(p?.provider ?? "").trim()).filter(Boolean),
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
- const goEntry = providerEntries.find((p) => p?.provider === "opencode-go")
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 opEntry = providerEntries.find((p) => p?.provider === "opencode")
112
- const opRows = okFreeRows(opEntry).sort((a, b) => b.score - a.score)
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=daily
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 units = renderTimerUnits({
208
- nodePath: process.execPath,
209
- scriptPath: fileURLToPath(import.meta.url),
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: 20)
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
- if (argv.includes("--install-timer")) {
252
- installTimerUnits()
253
- return
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", "20")) || 20)
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
- `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
299
- `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
- `opencode-last [${built.chosen.opencode}]`,
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}`)