@lawaty/lacode 1.0.3 → 1.2.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 +102 -1
- package/README.md +47 -15
- package/dist/agents.js +376 -247
- package/dist/commands.js +38 -18
- package/dist/index.js +2179 -489
- package/dist/lacode-voice.js +2351 -0
- package/dist/lacode.js +23 -2
- package/dist/pool.js +73 -38
- package/dist/tui.js +1282 -0
- package/docs/CONFIGURATION.md +17 -7
- package/docs/CONTEXT.md +80 -10
- package/docs/INSTALL.md +2 -2
- package/docs/USER-STORIES.md +11 -11
- package/package.json +12 -4
- package/presets/README.md +17 -19
- package/presets/free-tier.json +11 -13
- package/scripts/score-models.mjs +353 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// LaCode free-model scorer — MEASURE, DO NOT FABRICATE.
|
|
3
|
+
//
|
|
4
|
+
// The pool is for mechanical work: reading files, grepping, mechanical edits,
|
|
5
|
+
// cartography. What matters for that is not "the smartest model" but a model that
|
|
6
|
+
// is (a) free, (b) awake, (c) fast, and (d) has enough context and output room.
|
|
7
|
+
// This script measures those four things and ranks the candidates.
|
|
8
|
+
//
|
|
9
|
+
// npm run score all configured free providers
|
|
10
|
+
// npm run score -- --providers opencode,openrouter
|
|
11
|
+
// npm run score -- --max 6 probe at most 6 models per provider
|
|
12
|
+
// npm run score -- --no-write print only, touch nothing
|
|
13
|
+
//
|
|
14
|
+
// WHAT IT MEASURES
|
|
15
|
+
// context limit.context from `opencode models --verbose`
|
|
16
|
+
// output limit.output (the token ceiling for one reply)
|
|
17
|
+
// speed wall-clock ms for one tiny completion, one request per model
|
|
18
|
+
// reliability did the model actually answer? A 429/401/404 is recorded as the
|
|
19
|
+
// named failure it is, never as a slow success.
|
|
20
|
+
//
|
|
21
|
+
// WHAT IT DOES NOT DO
|
|
22
|
+
// * It never invents a latency or a score. A model it could not reach is shown
|
|
23
|
+
// as unreachable with the reason and is NOT ranked.
|
|
24
|
+
// * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
|
|
25
|
+
// pool stays yours to decide.
|
|
26
|
+
// * It never probes a paid model. Only cost 0/0 models from the provider's
|
|
27
|
+
// catalog are candidates.
|
|
28
|
+
//
|
|
29
|
+
// OUTPUT
|
|
30
|
+
// a per-provider ranked table, an overall ranking, a suggested pool.txt block,
|
|
31
|
+
// and (unless --no-write) the raw rows in ~/.config/lacode/model-scores.json so
|
|
32
|
+
// a later run can compare.
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from "node:child_process"
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
36
|
+
import { homedir } from "node:os"
|
|
37
|
+
import { dirname, join } from "node:path"
|
|
38
|
+
|
|
39
|
+
const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
40
|
+
const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
|
|
41
|
+
const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
|
|
42
|
+
|
|
43
|
+
// ── scoring weights ─────────────────────────────────────────────────────────
|
|
44
|
+
// Mechanical work prizes speed and availability over raw capability. Context is
|
|
45
|
+
// scored on a log curve so 256k vs 1M is a difference, not a cliff.
|
|
46
|
+
const W = { speed: 0.35, context: 0.3, output: 0.15, reliability: 0.2 }
|
|
47
|
+
const MAX_CONTEXT = 1_000_000
|
|
48
|
+
const FAST_MS = 300 // at or below this, speed scores 1.0
|
|
49
|
+
const SLOW_MS = 8_000 // at or above this, speed scores 0
|
|
50
|
+
const PROMPT = "Reply with the single word: ok"
|
|
51
|
+
const MAX_TOKENS = 8
|
|
52
|
+
|
|
53
|
+
// ── args ────────────────────────────────────────────────────────────────────
|
|
54
|
+
const argv = process.argv.slice(2)
|
|
55
|
+
const has = (flag) => argv.includes(flag)
|
|
56
|
+
const value = (flag, fallback) => {
|
|
57
|
+
const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
|
|
58
|
+
if (!hit) return fallback
|
|
59
|
+
return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (has("--help") || has("-h")) {
|
|
63
|
+
console.log(`Usage: npm run score -- [options]
|
|
64
|
+
|
|
65
|
+
--providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
|
|
66
|
+
--max N probe at most N free models per provider (default: 12)
|
|
67
|
+
--timeout MS per-request deadline in ms (default: 20000)
|
|
68
|
+
--delay MS pause between requests in ms (default: 300)
|
|
69
|
+
--no-write do not write ~/.config/lacode/model-scores.json
|
|
70
|
+
-h, --help this message
|
|
71
|
+
|
|
72
|
+
Only cost 0/0 models are candidates. Credentials come from
|
|
73
|
+
~/.local/share/opencode/auth.json; a provider with no entry falls back to the
|
|
74
|
+
"opencode" key (which is how the console-openrouter connection authenticates).`)
|
|
75
|
+
process.exit(0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
|
|
79
|
+
const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
|
|
80
|
+
const timeoutMs = Math.max(1_000, Number(value("--timeout", "20000")) || 20_000)
|
|
81
|
+
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
82
|
+
const writeResults = !has("--no-write")
|
|
83
|
+
|
|
84
|
+
// ── discovery ───────────────────────────────────────────────────────────────
|
|
85
|
+
/** Parse `opencode models --verbose <provider>`: alternating id line + JSON block. */
|
|
86
|
+
function parseVerbose(text) {
|
|
87
|
+
const rows = []
|
|
88
|
+
let id = null
|
|
89
|
+
let buf = []
|
|
90
|
+
const flush = () => {
|
|
91
|
+
if (!id || buf.length === 0) return false
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(buf.join("\n"))
|
|
94
|
+
rows.push({ id, model: parsed })
|
|
95
|
+
id = null
|
|
96
|
+
buf = []
|
|
97
|
+
return true
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const line of text.split(/\r?\n/)) {
|
|
103
|
+
const isId = /^\S/.test(line) && !/^[{}]/.test(line)
|
|
104
|
+
if (isId) {
|
|
105
|
+
if (id) flush()
|
|
106
|
+
if (line.startsWith("provider/") || line.includes("/")) {
|
|
107
|
+
id = line.trim()
|
|
108
|
+
buf = []
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (id) {
|
|
113
|
+
buf.push(line)
|
|
114
|
+
const trimmed = line.trim()
|
|
115
|
+
if (trimmed === "}" || trimmed === "},") flush()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
flush()
|
|
119
|
+
return rows
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function freeModels(provider) {
|
|
123
|
+
let text
|
|
124
|
+
try {
|
|
125
|
+
text = execFileSync("opencode", ["models", "--verbose", provider], {
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
128
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
129
|
+
})
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [] }
|
|
132
|
+
}
|
|
133
|
+
const rows = parseVerbose(text)
|
|
134
|
+
const models = rows
|
|
135
|
+
.filter(({ model }) => model?.api?.url && model?.api?.id)
|
|
136
|
+
.filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
|
|
137
|
+
.filter(({ model }) => !model.status || model.status === "active")
|
|
138
|
+
.map(({ id, model }) => ({
|
|
139
|
+
model: id,
|
|
140
|
+
url: model.api.url,
|
|
141
|
+
apiId: model.api.id,
|
|
142
|
+
context: model.limit?.context ?? null,
|
|
143
|
+
output: model.limit?.output ?? null,
|
|
144
|
+
name: model.name ?? id,
|
|
145
|
+
}))
|
|
146
|
+
return { provider, error: null, models }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── credentials ─────────────────────────────────────────────────────────────
|
|
150
|
+
function loadAuth() {
|
|
151
|
+
if (!existsSync(AUTH)) return {}
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(readFileSync(AUTH, "utf8"))
|
|
154
|
+
} catch {
|
|
155
|
+
return {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const auth = loadAuth()
|
|
159
|
+
const keyFor = (provider) => auth[provider]?.key ?? auth.opencode?.key ?? null
|
|
160
|
+
// The Zen gateways reject a request that does not name a session; the value does
|
|
161
|
+
// not need to be a real opencode session, it only has to be present.
|
|
162
|
+
const runSession = `score-${Date.now().toString(36)}`
|
|
163
|
+
const sessionHeader = (provider) =>
|
|
164
|
+
provider === "opencode" || provider === "opencode-go" ? { "x-opencode-session": runSession } : {}
|
|
165
|
+
|
|
166
|
+
// ── probing ─────────────────────────────────────────────────────────────────
|
|
167
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One real request. Returns {ok, ms, reason}. A failure is named, never rounded
|
|
171
|
+
* into a success, and `ms` is only reported for a request that actually ran.
|
|
172
|
+
*/
|
|
173
|
+
async function probe(candidate, provider) {
|
|
174
|
+
const key = keyFor(provider)
|
|
175
|
+
if (!key) return { ok: false, ms: null, reason: "no credential in auth.json" }
|
|
176
|
+
|
|
177
|
+
const url = `${String(candidate.url).replace(/\/+$/, "")}/chat/completions`
|
|
178
|
+
const started = performance.now()
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetch(url, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: {
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
Authorization: `Bearer ${key}`,
|
|
185
|
+
...sessionHeader(provider),
|
|
186
|
+
...(provider === "openrouter" ? { "HTTP-Referer": "https://opencode.ai", "X-Title": "LaCode score" } : {}),
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
model: candidate.apiId,
|
|
190
|
+
messages: [{ role: "user", content: PROMPT }],
|
|
191
|
+
max_tokens: MAX_TOKENS,
|
|
192
|
+
temperature: 0,
|
|
193
|
+
stream: false,
|
|
194
|
+
}),
|
|
195
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
196
|
+
})
|
|
197
|
+
const ms = Math.round(performance.now() - started)
|
|
198
|
+
const body = await response.text()
|
|
199
|
+
let parsed = null
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(body)
|
|
202
|
+
} catch {}
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const detail = parsed?.error?.message ?? body.slice(0, 160)
|
|
206
|
+
const kind =
|
|
207
|
+
response.status === 429
|
|
208
|
+
? "rate-limited"
|
|
209
|
+
: response.status === 401 || response.status === 403
|
|
210
|
+
? "credential"
|
|
211
|
+
: response.status === 404
|
|
212
|
+
? "not served"
|
|
213
|
+
: `http ${response.status}`
|
|
214
|
+
return { ok: false, ms, reason: `${kind}: ${detail}` }
|
|
215
|
+
}
|
|
216
|
+
// A 200 can still carry an error object (OpenRouter does this).
|
|
217
|
+
if (parsed?.error) return { ok: false, ms, reason: `${parsed.error.message ?? "error"} `.trim() }
|
|
218
|
+
if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
|
|
219
|
+
return { ok: false, ms, reason: "no choices in response" }
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, ms, reason: "answered" }
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const ms = Math.round(performance.now() - started)
|
|
224
|
+
const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
|
|
225
|
+
return { ok: false, ms, reason: name }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── scoring ─────────────────────────────────────────────────────────────────
|
|
230
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n))
|
|
231
|
+
const logScore = (n, max) => (n && n > 0 ? clamp01(Math.log2(1 + n) / Math.log2(1 + max)) : 0)
|
|
232
|
+
const speedScore = (ms) => (ms == null ? 0 : clamp01((SLOW_MS - ms) / (SLOW_MS - FAST_MS)))
|
|
233
|
+
|
|
234
|
+
function score(row) {
|
|
235
|
+
if (!row.ok) return null
|
|
236
|
+
const parts = {
|
|
237
|
+
speed: speedScore(row.ms),
|
|
238
|
+
context: logScore(row.context, MAX_CONTEXT),
|
|
239
|
+
output: logScore(row.output, 262_144),
|
|
240
|
+
reliability: 1,
|
|
241
|
+
}
|
|
242
|
+
const value = 100 * (Object.entries(W).reduce((sum, [k, w]) => sum + w * parts[k], 0) / Object.values(W).reduce((a, b) => a + b, 0))
|
|
243
|
+
return { value: Math.round(value * 10) / 10, parts }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const pad = (s, n) => String(s).padEnd(n)
|
|
247
|
+
const padL = (s, n) => String(s).padStart(n)
|
|
248
|
+
|
|
249
|
+
async function main() {
|
|
250
|
+
const scanned = providers.map(freeModels)
|
|
251
|
+
const results = []
|
|
252
|
+
|
|
253
|
+
for (const entry of scanned) {
|
|
254
|
+
if (entry.error) {
|
|
255
|
+
console.log(`\n${entry.provider}: ${entry.error}`)
|
|
256
|
+
results.push({ provider: entry.provider, error: entry.error, rows: [] })
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const candidates = entry.models.slice(0, maxPerProvider)
|
|
260
|
+
console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}`)
|
|
261
|
+
if (candidates.length === 0) {
|
|
262
|
+
results.push({ provider: entry.provider, error: null, rows: [] })
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const rows = []
|
|
266
|
+
for (const candidate of candidates) {
|
|
267
|
+
const outcome = await probe(candidate, entry.provider)
|
|
268
|
+
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
269
|
+
row.score = score(row)
|
|
270
|
+
rows.push(row)
|
|
271
|
+
const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
|
|
272
|
+
console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
|
|
273
|
+
await sleep(delayMs)
|
|
274
|
+
}
|
|
275
|
+
results.push({ provider: entry.provider, error: null, rows })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Per-provider ranked tables.
|
|
279
|
+
for (const entry of results) {
|
|
280
|
+
if (entry.error || entry.rows.length === 0) continue
|
|
281
|
+
console.log(`\n${entry.provider} — ranked`)
|
|
282
|
+
console.log(` ${pad("#", 3)}${pad("model", 52)}${padL("ctx", 9)}${padL("out", 9)}${padL("ms", 7)}${padL("score", 7)}`)
|
|
283
|
+
const ranked = [...entry.rows].filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)
|
|
284
|
+
ranked.forEach((r, i) => {
|
|
285
|
+
console.log(` ${pad(i + 1, 3)}${pad(r.model.replace(`${entry.provider}/`, ""), 52)}${padL(r.context ?? "?", 9)}${padL(r.output ?? "?", 9)}${padL(r.ms ?? "?", 7)}${padL(r.score.value, 7)}`)
|
|
286
|
+
})
|
|
287
|
+
const failed = entry.rows.filter((r) => !r.ok)
|
|
288
|
+
if (failed.length) {
|
|
289
|
+
console.log(` unreachable (not ranked):`)
|
|
290
|
+
for (const r of failed) console.log(` ${pad(r.model.replace(`${entry.provider}/`, ""), 52)} ${r.reason.slice(0, 90)}`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Overall ranking across providers.
|
|
295
|
+
const all = results.flatMap((e) => e.rows).filter((r) => r.score)
|
|
296
|
+
all.sort((a, b) => b.score.value - a.score.value)
|
|
297
|
+
if (all.length) {
|
|
298
|
+
console.log(`\noverall — best free models right now`)
|
|
299
|
+
all.slice(0, 12).forEach((r, i) => {
|
|
300
|
+
console.log(` ${pad(i + 1, 3)}${pad(r.model, 58)}${padL(`${r.ms}ms`, 8)}${padL(r.score.value, 7)}`)
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A SUGGESTED pool: the best model from each provider, weights 4/3/2/1 by rank.
|
|
305
|
+
// Printed, never written — pool.txt stays the user's decision.
|
|
306
|
+
const bestPerProvider = []
|
|
307
|
+
for (const entry of results) {
|
|
308
|
+
const top = entry.rows.filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)[0]
|
|
309
|
+
if (top) bestPerProvider.push(top)
|
|
310
|
+
}
|
|
311
|
+
bestPerProvider.sort((a, b) => b.score.value - a.score.value)
|
|
312
|
+
if (bestPerProvider.length) {
|
|
313
|
+
console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
|
|
314
|
+
console.log(`# scored ${new Date().toISOString()} — one best free model per provider`)
|
|
315
|
+
bestPerProvider.forEach((r, i) => {
|
|
316
|
+
const weight = Math.max(1, 4 - i)
|
|
317
|
+
console.log(`${pad(r.model, 60)}${weight}`)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (writeResults) {
|
|
322
|
+
try {
|
|
323
|
+
mkdirSync(dirname(OUT), { recursive: true })
|
|
324
|
+
const payload = {
|
|
325
|
+
generatedAt: new Date().toISOString(),
|
|
326
|
+
weights: W,
|
|
327
|
+
providers: results.map((e) => ({
|
|
328
|
+
provider: e.provider,
|
|
329
|
+
error: e.error,
|
|
330
|
+
rows: e.rows.map((r) => ({
|
|
331
|
+
model: r.model,
|
|
332
|
+
context: r.context,
|
|
333
|
+
output: r.output,
|
|
334
|
+
ok: r.ok,
|
|
335
|
+
ms: r.ms,
|
|
336
|
+
reason: r.reason,
|
|
337
|
+
score: r.score?.value ?? null,
|
|
338
|
+
})),
|
|
339
|
+
})),
|
|
340
|
+
}
|
|
341
|
+
writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
|
|
342
|
+
console.log(`\nwrote ${OUT}`)
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(`\ncould not write ${OUT}: ${String(error?.message ?? error)}`)
|
|
345
|
+
process.exitCode = 1
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
main().catch((error) => {
|
|
351
|
+
console.error(String(error?.stack ?? error))
|
|
352
|
+
process.exitCode = 1
|
|
353
|
+
})
|