@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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Daily-refreshable pool builder.
2
+ // Refreshable pool builder; the installed systemd timer runs it every 6 hours.
3
3
  //
4
4
  // Reads the scorer's output (~/.config/lacode/model-scores.json) and proposes a
5
5
  // rewritten ~/.config/lacode/pool.txt. It never partially writes and never
@@ -9,14 +9,16 @@
9
9
  // 1. every opencode-go model with ok:true, a numeric score at or above the
10
10
  // score floor and a context at or above the context floor, sorted by score
11
11
  // descending, weight 4
12
- // 2. the best (10 - n) models from every other provider, sorted by score
13
- // descending, weight 2; opencode models get b:opencode
12
+ // 2. the best (10 - n) models from every provider other than opencode,
13
+ // sorted by score descending, weight 2
14
+ // 3. exactly one opencode model as the reserved last slot, weight 2, with
15
+ // b:opencode, when a rankable opencode model exists
14
16
  //
15
- // Total = n + min(10 - n, #others), where n = #1.
17
+ // Total = n + (others up to 10 - n - zen) + zen, where n = #1.
16
18
  //
17
19
  // This file ships as source and must run with plain `node` (ESM, no TS).
18
20
 
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
20
22
  import { execFileSync } from "node:child_process"
21
23
  import { homedir, userInfo } from "node:os"
22
24
  import { dirname, join, resolve } from "node:path"
@@ -28,27 +30,60 @@ export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
28
30
  export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
29
31
  export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "lacode")
30
32
  export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
33
+ export const STABLE_SCORER = (home = HOME) => join(STABLE_LIB_DIR(home), "score-models.mjs")
34
+ export const STABLE_SELECTION = (home = HOME) => join(STABLE_LIB_DIR(home), "pool-selection.mjs")
31
35
  export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
32
36
  export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
33
37
 
34
- const isSlotLine = (line) => {
35
- const stripped = String(line).split("#")[0].trim()
36
- return stripped.length > 0 && stripped.includes("/")
37
- }
38
+ const REFRESH_LOCK_DIR = join(HOME, ".config", "lacode", "refresh.lock")
39
+ const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
38
40
 
39
- const providerOf = (modelOrLine) => {
40
- const stripped = String(modelOrLine).split("#")[0].trim()
41
- const token = stripped.split(/\s+/)[0] || ""
42
- const slash = token.indexOf("/")
43
- return slash < 0 ? token : token.slice(0, slash)
41
+ /**
42
+ * One refresh at a time. The 6-hourly service and a transient retry timer can
43
+ * otherwise overlap and race on model-scores.json. A killed run leaves the lock
44
+ * behind, so a live PID keeps it only for an hour; a dead PID or a stale lock is
45
+ * reclaimed by the next run.
46
+ */
47
+ function acquireRefreshLock() {
48
+ const take = () => {
49
+ mkdirSync(REFRESH_LOCK_DIR)
50
+ writeFileSync(join(REFRESH_LOCK_DIR, "pid"), String(process.pid))
51
+ return true
52
+ }
53
+ try {
54
+ return take()
55
+ } catch {}
56
+ try {
57
+ const stat = statSync(REFRESH_LOCK_DIR)
58
+ let pid = 0
59
+ try {
60
+ pid = Number(readFileSync(join(REFRESH_LOCK_DIR, "pid"), "utf8"))
61
+ } catch {}
62
+ let alive = false
63
+ if (Number.isFinite(pid) && pid > 0) {
64
+ try {
65
+ process.kill(pid, 0)
66
+ alive = true
67
+ } catch {}
68
+ }
69
+ if (alive && Date.now() - stat.mtimeMs < REFRESH_LOCK_STALE_MS) return false
70
+ rmSync(REFRESH_LOCK_DIR, { recursive: true, force: true })
71
+ return take()
72
+ } catch {
73
+ return false
74
+ }
44
75
  }
45
76
 
46
- const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
77
+ function releaseRefreshLock() {
78
+ try {
79
+ rmSync(REFRESH_LOCK_DIR, { recursive: true, force: true })
80
+ } catch {}
81
+ }
47
82
 
48
- // Fallbacks for score files written before the floors were recorded there.
49
- const MIN_CONTEXT = 200_000
50
- const MIN_SCORE = 60
51
- const POOL_SIZE = 10
83
+ const isSlotLine = (line) => {
84
+ const stripped = String(line).split("#")[0].trim()
85
+ return stripped.length > 0 && stripped.includes("/")
86
+ }
52
87
 
53
88
  function splitHeaderAndSlots(text) {
54
89
  const lines = String(text ?? "").split(/\r?\n/)
@@ -66,7 +101,7 @@ function splitHeaderAndSlots(text) {
66
101
  function refreshComment(now, scoresGeneratedAt) {
67
102
  const when = now instanceof Date ? now.toISOString() : String(now)
68
103
  const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
69
- return `# Refreshed ${when} from model-scores.json${gen}: opencode-go first (weight 4), then the best others (weight 2).`
104
+ 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).`
70
105
  }
71
106
 
72
107
  function withRefreshComment(headerLines, comment) {
@@ -74,8 +109,9 @@ function withRefreshComment(headerLines, comment) {
74
109
  return [...kept, comment]
75
110
  }
76
111
 
77
- function formatGeneratedLine(model, weight, bucket) {
112
+ function formatGeneratedLine(model, weight, bucket, context) {
78
113
  const parts = [String(model), String(weight)]
114
+ if (context != null && Number.isFinite(Number(context))) parts.push(`c:${Math.round(Number(context))}`)
79
115
  if (bucket) parts.push(`b:${bucket}`)
80
116
  return parts.join(" ")
81
117
  }
@@ -84,56 +120,36 @@ function formatGeneratedLine(model, weight, bucket) {
84
120
  * Pure builder. Throws on every fail-closed condition; never touches the filesystem.
85
121
  *
86
122
  * @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
87
- * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], others: string[] } }}
123
+ * @returns {{ text: string, slots: string[], warnings: string[], chosen: { selected: string[] } }}
88
124
  */
89
125
  export function buildPool(input) {
90
- const warnings = []
91
126
  const scores = input?.scores
92
- if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers)) {
93
- throw new Error("score file is missing or invalid (expected { providers: [...] })")
94
- }
95
-
96
- const minScore = Number(scores.minScore) || MIN_SCORE
97
- const minContext = Number(scores.minContext) || MIN_CONTEXT
98
- const rankable = (row) =>
99
- row?.ok === true &&
100
- numericScore(row) &&
101
- row.score >= minScore &&
102
- (row.context == null || row.context >= minContext)
103
- const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
104
-
105
- const rows = scores.providers.flatMap((entry) => (Array.isArray(entry?.rows) ? entry.rows : []))
106
- const goRows = rows.filter((row) => providerOf(row.model) === "opencode-go" && rankable(row)).sort(byScore)
107
- const others = rows.filter((row) => providerOf(row.model) !== "opencode-go" && rankable(row)).sort(byScore)
108
- const otherRows = others.slice(0, Math.max(0, POOL_SIZE - goRows.length))
109
-
110
- if (goRows.length + otherRows.length === 0) {
111
- throw new Error("no scorable model at or above the minimum score is available; refusing to write pool.txt")
127
+ if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers) || !Array.isArray(scores.selection)) {
128
+ throw new Error("score file is missing or invalid (expected { providers: [...], selection: [...] })")
112
129
  }
113
- if (goRows.length + otherRows.length < POOL_SIZE) {
114
- warnings.push(`only ${goRows.length + otherRows.length} scorable model(s) available, wanted ${POOL_SIZE}`)
130
+ const selection = scores.selection.filter(
131
+ (entry) =>
132
+ entry &&
133
+ typeof entry.model === "string" &&
134
+ entry.model.includes("/") &&
135
+ typeof entry.weight === "number" &&
136
+ Number.isFinite(entry.weight) &&
137
+ entry.weight > 0,
138
+ )
139
+ if (selection.length === 0) {
140
+ throw new Error("score file has no selected models; refusing to write pool.txt")
115
141
  }
116
-
142
+ const warnings = Array.isArray(scores.warnings) ? scores.warnings : []
117
143
  const { header } = splitHeaderAndSlots(input?.currentPoolText ?? "")
118
- const outSlots = [
119
- ...goRows.map((row) => formatGeneratedLine(row.model, 4)),
120
- ...otherRows.map((row) =>
121
- formatGeneratedLine(row.model, 2, providerOf(row.model) === "opencode" ? "opencode" : undefined),
122
- ),
123
- ]
124
-
144
+ const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context))
125
145
  const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
126
146
  const outHeader = withRefreshComment(header, comment)
127
-
128
147
  const text = [...outHeader, "", ...outSlots, ""].join("\n")
129
148
  return {
130
149
  text,
131
150
  slots: outSlots,
132
151
  warnings,
133
- chosen: {
134
- opencodeGo: goRows.map((r) => r.model),
135
- others: otherRows.map((r) => r.model),
136
- },
152
+ chosen: { selected: selection.map((entry) => entry.model) },
137
153
  }
138
154
  }
139
155
 
@@ -173,6 +189,41 @@ function runScorer(max) {
173
189
  execFileSync(process.execPath, args, { stdio: "inherit" })
174
190
  }
175
191
 
192
+ export function scheduleRetry(retry, options = {}) {
193
+ const at = Date.parse(retry?.after ?? "")
194
+ if (!Number.isFinite(at)) return { scheduled: false, reason: "no reset time" }
195
+ const now = options.now ?? Date.now
196
+ const nowMs = now()
197
+ const seconds = Math.ceil((at - nowMs) / 1000)
198
+ if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "reset is not in the future" }
199
+ if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "reset is more than a day away" }
200
+ const exec = options.exec ?? execFileSync
201
+ const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
202
+ const nodePath = options.nodePath ?? process.execPath
203
+ const home = options.home ?? HOME
204
+ const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
205
+ const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
206
+ try {
207
+ exec(
208
+ "systemd-run",
209
+ [
210
+ "--user",
211
+ "--collect",
212
+ `--unit=${unit}`,
213
+ `--on-active=${seconds}`,
214
+ `--setenv=HOME=${home}`,
215
+ `--setenv=PATH=${path}`,
216
+ nodePath,
217
+ scriptPath,
218
+ ],
219
+ { stdio: "ignore" },
220
+ )
221
+ return { scheduled: true, seconds, after: retry.after }
222
+ } catch (error) {
223
+ return { scheduled: false, reason: String(error?.message ?? error) }
224
+ }
225
+ }
226
+
176
227
  export function renderTimerUnits(input = {}) {
177
228
  const nodePath = input.nodePath || process.execPath
178
229
  const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
@@ -186,15 +237,21 @@ Wants=network-online.target
186
237
 
187
238
  [Service]
188
239
  Type=oneshot
240
+ TimeoutStartSec=infinity
189
241
  ExecStart=${nodePath} ${scriptPath}
190
242
  Environment=PATH=${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin
191
243
  Nice=10
192
244
  `
193
245
  const timer = `[Unit]
194
- Description=Daily LaCode model pool refresh
246
+ Description=LaCode model pool refresh every 6 hours
195
247
 
196
248
  [Timer]
197
- OnCalendar=*-*-* 01:00:00
249
+ OnCalendar=*-*-* 00/6:00:00
250
+ # A reboot kills a run mid-scan. Persistent=true does NOT retry that, because
251
+ # systemd records the trigger when the service starts, not when it succeeds, so
252
+ # the 6-hour slot is already spent even though the run never finished. A startup
253
+ # trigger gives the next boot a fresh run even if the previous one was killed.
254
+ OnStartupSec=5min
198
255
  Persistent=true
199
256
  Unit=${REFRESH_SERVICE_NAME}
200
257
 
@@ -212,6 +269,8 @@ function installTimerUnits(stable = false) {
212
269
  if (stable) {
213
270
  mkdirSync(STABLE_LIB_DIR(HOME), { recursive: true })
214
271
  copyFileSync(live, scriptPath)
272
+ copyFileSync(resolve(dirname(live), "score-models.mjs"), STABLE_SCORER(HOME))
273
+ copyFileSync(resolve(dirname(live), "pool-selection.mjs"), STABLE_SELECTION(HOME))
215
274
  }
216
275
  mkdirSync(dirname(units.servicePath), { recursive: true })
217
276
  writeFileAtomic(units.servicePath, units.service)
@@ -232,10 +291,11 @@ function installTimerUnits(stable = false) {
232
291
  process.exitCode = 1
233
292
  return false
234
293
  }
235
- if (stable) console.log(`copied ${live} to ${scriptPath}`)
294
+ if (stable) console.log(`copied ${live}, score-models.mjs and pool-selection.mjs to ${STABLE_LIB_DIR(HOME)}`)
236
295
  console.log(`installed ${units.servicePath}`)
237
296
  console.log(`installed ${units.timerPath}`)
238
297
  console.log(`enabled ${REFRESH_TIMER_NAME}`)
298
+ console.log(`refresh now: systemctl --user start ${REFRESH_SERVICE_NAME}`)
239
299
  return true
240
300
  }
241
301
 
@@ -243,12 +303,15 @@ function usage() {
243
303
  console.log(`Usage: node scripts/refresh-pool.mjs [options]
244
304
 
245
305
  --from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
246
- --install-timer install and enable a user systemd oneshot service + daily timer
247
- (add --stable to copy the script to ~/.local/lib/lacode and
248
- point ExecStart there, so plugin-cache updates do not break it)
306
+ --no-retry do not schedule a transient retry after a rate-limited run
307
+ --install-timer install and enable a user systemd oneshot service + 6-hourly
308
+ timer (add --stable to copy the script to ~/.local/lib/lacode
309
+ and point ExecStart there, so plugin-cache updates do not break
310
+ it). Refresh on demand with:
311
+ systemctl --user start lacode-refresh-pool.service
249
312
  --print print the proposed pool.txt and make no changes (alias: --dry-run)
250
313
  --dry-run same as --print
251
- --max N models per provider passed to the scorer (default: 20)
314
+ --max N models per provider passed to the scorer (default: 50)
252
315
  --scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
253
316
  --pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
254
317
  -h, --help this message`)
@@ -263,15 +326,24 @@ async function main() {
263
326
  }
264
327
  const fromScores = argv.includes("--from-scores")
265
328
  const printOnly = argv.includes("--print") || argv.includes("--dry-run")
329
+ const noRetry = argv.includes("--no-retry")
266
330
  const value = (flag, fallback) => {
267
331
  const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
268
332
  if (!hit) return fallback
269
333
  return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
270
334
  }
271
- const max = Math.max(1, Number(value("--max", "20")) || 20)
335
+ const max = Math.max(1, Number(value("--max", "50")) || 50)
272
336
  const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
273
337
  const poolPath = value("--pool", DEFAULT_POOL_PATH)
274
338
 
339
+ if (!printOnly) {
340
+ if (!acquireRefreshLock()) {
341
+ console.log("another LaCode pool refresh is already running; skipping this run")
342
+ return
343
+ }
344
+ process.once("exit", releaseRefreshLock)
345
+ }
346
+
275
347
  if (!fromScores) {
276
348
  try {
277
349
  runScorer(max)
@@ -305,12 +377,20 @@ async function main() {
305
377
  const newSlotCount = built.slots.filter(isSlotLine).length
306
378
  const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
307
379
  const summary = [
308
- `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
309
- `others [${built.chosen.others.join(", ")}]`,
310
- ...(dropped.length ? [`dropped [${dropped.map((x) => x.model).join(", ")}]`] : []),
380
+ `selected [${built.chosen.selected.join(", ")}]`,
381
+ ...(dropped.length ? [`dropped ${dropped.length} model(s) below the context/score floor`] : []),
311
382
  ...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
312
383
  ].join(" | ")
313
384
 
385
+ if (!printOnly && !noRetry) {
386
+ const scheduled = scheduleRetry(scores.retry)
387
+ if (scheduled.scheduled) {
388
+ console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s) at ${scheduled.after}`)
389
+ } else if (scores.retry) {
390
+ console.log(`retry not scheduled: ${scheduled.reason}`)
391
+ }
392
+ }
393
+
314
394
  if (printOnly) {
315
395
  process.stdout.write(built.text)
316
396
  console.error(`--print: proposed ${oldSlotCount} -> ${newSlotCount} slots; no changes written. ${summary}`)