@lawaty/lacode 2.1.0-beta.0 → 2.3.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2515 -2110
- package/dist/lacode.js +2 -0
- package/dist/pool.js +194 -18
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +15 -1
- package/docs/USER-STORIES.md +56 -8
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +281 -0
- package/scripts/refresh-pool.mjs +149 -69
- package/scripts/score-models.mjs +227 -68
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
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
|
|
13
|
-
// descending, weight 2
|
|
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 +
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
77
|
+
function releaseRefreshLock() {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(REFRESH_LOCK_DIR, { recursive: true, force: true })
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
47
82
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
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),
|
|
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: {
|
|
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
|
-
|
|
114
|
-
|
|
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=
|
|
246
|
+
Description=LaCode model pool refresh every 6 hours
|
|
195
247
|
|
|
196
248
|
[Timer]
|
|
197
|
-
OnCalendar=*-*-*
|
|
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 ${
|
|
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
|
-
--
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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", "
|
|
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
|
-
`
|
|
309
|
-
`
|
|
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}`)
|