@lawaty/lacode 2.0.0-beta.0 → 2.1.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 +22 -1
- package/dist/index.js +250 -15
- package/package.json +1 -1
- package/scripts/refresh-pool.mjs +74 -63
- package/scripts/score-models.mjs +111 -37
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,26 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.1.0-beta.0] — every provider counts, and the timer installs itself
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Pool: `npm run score` scans every configured provider in one `opencode models --verbose` call. `--min-context` (default 200000) and `--min-score` (default 60) exclude weak models, and both floors are recorded in `model-scores.json`.
|
|
17
|
+
- Timer migration: plugin startup installs or refreshes the pool-refresh systemd user timer once per package version, from a stable copy at `~/.local/lib/lacode/refresh-pool.mjs`. Opt out with `LACODE_NO_TIMER_MIGRATION=1`.
|
|
18
|
+
- `scripts/refresh-pool.mjs --install-timer --stable` installs that stable timer by hand.
|
|
19
|
+
- Timeline: a root `task` part expands into the child session's title and outcome, and `NO_PROJECT_CHANGES` is the explicit success token for a summary that found no project changes.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Pool: `scripts/refresh-pool.mjs` rebuilds `~/.config/lacode/pool.txt` from the best scored models across all providers — every opencode-go model at weight 4, then the best `10 - n` others at weight 2. Unscored/paid lines are no longer preserved.
|
|
24
|
+
- Pool: the refresh timer runs daily at 01:00 with no randomized delay; the 15-minute in-process cooldown probe is unchanged.
|
|
25
|
+
- Timer units are written atomically, and the installer enables user linger best-effort.
|
|
26
|
+
- Timeline: side-maintenance sessions and their tool parts are excluded from the summarizer prompt and the rendered timeline; an empty summarizer reply is now a failure instead of an empty success.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Timeline: a `NO_PROJECT_CHANGES` slice still advances the durable cursor, and `/reprocess-timeline` no longer aborts on it.
|
|
31
|
+
|
|
12
32
|
## [2.0.0-beta.0] — The timeline remembers, and the map has one job
|
|
13
33
|
|
|
14
34
|
### Added
|
|
@@ -1132,7 +1152,8 @@ experimental; `latest` remains 0.4.2.
|
|
|
1132
1152
|
|
|
1133
1153
|
- First published release: pooled subagents over a shared set of models.
|
|
1134
1154
|
|
|
1135
|
-
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.
|
|
1155
|
+
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.1.0-beta.0...HEAD
|
|
1156
|
+
[2.1.0-beta.0]: https://github.com/lawaty/lacode/compare/v2.0.0-beta.0...v2.1.0-beta.0
|
|
1136
1157
|
[2.0.0-beta.0]: https://github.com/lawaty/lacode/compare/v1.3.1...v2.0.0-beta.0
|
|
1137
1158
|
[1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
|
|
1138
1159
|
[1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
|
package/dist/index.js
CHANGED
|
@@ -6489,7 +6489,8 @@ async function selfUpdateHooks(input, options) {
|
|
|
6489
6489
|
}
|
|
6490
6490
|
|
|
6491
6491
|
// src/plugins/timeline.ts
|
|
6492
|
-
import { writeFileSync as writeFileSync10, renameSync as renameSync7, rmSync as rmSync3 } from "node:fs";
|
|
6492
|
+
import { existsSync as existsSync5, writeFileSync as writeFileSync10, renameSync as renameSync7, rmSync as rmSync3 } from "node:fs";
|
|
6493
|
+
import { join as join12 } from "node:path";
|
|
6493
6494
|
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
6494
6495
|
|
|
6495
6496
|
// src/lib/timeline.ts
|
|
@@ -6761,6 +6762,49 @@ function summarizeToolResult(state2) {
|
|
|
6761
6762
|
}
|
|
6762
6763
|
return "";
|
|
6763
6764
|
}
|
|
6765
|
+
function isSideTool(tool3, input) {
|
|
6766
|
+
if (tool3 === "timeline_reprocess" || tool3 === "pool_status" || tool3 === "pool_recheck") return true;
|
|
6767
|
+
if (tool3 === "task" && input !== null && input !== void 0 && typeof input === "object") {
|
|
6768
|
+
return input.subagent_type === "context";
|
|
6769
|
+
}
|
|
6770
|
+
return false;
|
|
6771
|
+
}
|
|
6772
|
+
function mentionsSideMaintenance(text) {
|
|
6773
|
+
return /\b(?:timeline_reprocess|pool_status|pool_recheck|reprocess-timeline|cartographer)\b/i.test(text);
|
|
6774
|
+
}
|
|
6775
|
+
function isSideMaintenanceTask(input, rootDirectory, projectOwnsTooling) {
|
|
6776
|
+
if (projectOwnsTooling) return false;
|
|
6777
|
+
let description = "";
|
|
6778
|
+
let prompt = "";
|
|
6779
|
+
if (input !== null && input !== void 0 && typeof input === "object") {
|
|
6780
|
+
const rec = input;
|
|
6781
|
+
if (typeof rec.description === "string") description = rec.description;
|
|
6782
|
+
if (typeof rec.prompt === "string") prompt = rec.prompt;
|
|
6783
|
+
}
|
|
6784
|
+
const text = [description, prompt].filter((s) => typeof s === "string" && s.length > 0).join("\n");
|
|
6785
|
+
if (text === "") return false;
|
|
6786
|
+
if (/\.config\/(?:opencode|lacode)|\.local\/share\/opencode/i.test(text)) return true;
|
|
6787
|
+
if (mentionsSideMaintenance(text)) return true;
|
|
6788
|
+
const root = rootDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
6789
|
+
for (const match of text.matchAll(/\/(?:home|Users|root|opt|srv|mnt|workspace|workspaces)\/[^\s"'`),;]+/gi)) {
|
|
6790
|
+
const cleaned = match[0].replace(/[.,:;!?]+$/, "");
|
|
6791
|
+
if (!(cleaned.includes("/Projects/") || cleaned.includes("/projects/"))) continue;
|
|
6792
|
+
if (cleaned === root || cleaned.startsWith(root + "/")) continue;
|
|
6793
|
+
return true;
|
|
6794
|
+
}
|
|
6795
|
+
return false;
|
|
6796
|
+
}
|
|
6797
|
+
function taskChildSessionId(part) {
|
|
6798
|
+
const sessionId = part.state?.metadata?.sessionId;
|
|
6799
|
+
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId;
|
|
6800
|
+
const output = part.state?.output;
|
|
6801
|
+
if (typeof output !== "string") return void 0;
|
|
6802
|
+
const taskMatch = output.match(/task id="([A-Za-z0-9_.-]+)"/);
|
|
6803
|
+
if (taskMatch) return taskMatch[1];
|
|
6804
|
+
const sesMatch = output.match(/ses_[A-Za-z0-9]+/);
|
|
6805
|
+
if (sesMatch) return sesMatch[0];
|
|
6806
|
+
return void 0;
|
|
6807
|
+
}
|
|
6764
6808
|
function renderMessage(m) {
|
|
6765
6809
|
const role = m.info.role;
|
|
6766
6810
|
const lines = [];
|
|
@@ -6774,18 +6818,29 @@ function renderMessage(m) {
|
|
|
6774
6818
|
}
|
|
6775
6819
|
} else if (role === "assistant") {
|
|
6776
6820
|
for (const p of m.parts) {
|
|
6821
|
+
if (p.side === true) continue;
|
|
6777
6822
|
if (p.type === "text") {
|
|
6778
6823
|
const text = collapse(p.text ?? "");
|
|
6779
6824
|
lines.push(`[assistant] ${trunc(text, 1500)}`);
|
|
6780
6825
|
} else if (p.type === "tool") {
|
|
6826
|
+
if (p.side === true) continue;
|
|
6781
6827
|
const tool3 = p.tool ?? "";
|
|
6782
|
-
if (tool3
|
|
6828
|
+
if (isSideTool(tool3, p.state?.input)) continue;
|
|
6783
6829
|
const status = p.state?.status;
|
|
6784
6830
|
const summary = inputSummary(p.state?.input);
|
|
6785
|
-
|
|
6831
|
+
let result = summarizeToolResult(p.state);
|
|
6832
|
+
let suppressInput = false;
|
|
6833
|
+
if (tool3 === "task" && typeof p.childTitle === "string" && collapse(p.childTitle) !== "") {
|
|
6834
|
+
const title = trunc(collapse(p.childTitle), 160);
|
|
6835
|
+
result = title;
|
|
6836
|
+
if (typeof p.childOutcome === "string" && collapse(p.childOutcome) !== "") {
|
|
6837
|
+
result = `${title}: ${trunc(collapse(p.childOutcome), 600)}`;
|
|
6838
|
+
}
|
|
6839
|
+
if (summary && title.startsWith(summary)) suppressInput = true;
|
|
6840
|
+
}
|
|
6786
6841
|
let line = `[tool] ${tool3}`;
|
|
6787
6842
|
if (status) line += ` (${status})`;
|
|
6788
|
-
const inputPart = summary ? trunc(summary, 220) : "";
|
|
6843
|
+
const inputPart = summary && !suppressInput ? trunc(summary, 220) : "";
|
|
6789
6844
|
let rest = "";
|
|
6790
6845
|
if (inputPart) rest += ` ${inputPart}`;
|
|
6791
6846
|
if (result) rest += ` -> ${result}`;
|
|
@@ -6910,12 +6965,17 @@ var DEFAULT_MAX_ENTRIES = 8;
|
|
|
6910
6965
|
var MAX_ENTRIES = 10;
|
|
6911
6966
|
var PROMPT_TIMEOUT_MS = 9e4;
|
|
6912
6967
|
var SYSTEM = `You are the session timeline summarizer. You receive a slice of a coding session transcript.
|
|
6913
|
-
|
|
6968
|
+
Maintain a timeline of the project's own work: features, fixes, refactors, behavior changes, user-visible capabilities, architectural decisions, data/model changes, and documentation that defines project behavior.
|
|
6969
|
+
Write 1-8 timeline entries describing that project work.
|
|
6914
6970
|
|
|
6915
6971
|
Rules:
|
|
6916
6972
|
- One entry per line, each starting with "- ".
|
|
6917
6973
|
- Each entry is 6-16 words, past tense, plain text.
|
|
6918
|
-
- Cover each distinct task in the slice. Do not drop a
|
|
6974
|
+
- Cover each distinct project task in the slice. Do not drop a feature, fix, refactor, behavior change, user-visible capability, architectural decision, data/model change, or documentation that defines project behavior; merge only exact duplicates.
|
|
6975
|
+
- Record project test changes at the capability level, not as housekeeping; do not report counts.
|
|
6976
|
+
- Ignore self-maintenance and infrastructure: the context map/cartographer, timeline rebuilding/summarization, model-pool status/rechecks, plugin install/update, agent/command configuration, formatting/lint/dependency churn, and build/dist artifacts.
|
|
6977
|
+
- Name modules, components, services, or capabilities when they are the subject. Do not list file paths, line numbers, counts, commit hashes, or build artifacts.
|
|
6978
|
+
- Record decisions or findings that changed the plan, not routine searches or reads.
|
|
6919
6979
|
- Describe the TASK or OUTCOME, not the implementation. "Pool: added bucket-based routing" is right; "added b:<name> to pool.ts and agent-pool.ts" is too detailed.
|
|
6920
6980
|
- Never describe the mechanism: no flags, aliases, overrides, endpoints, probes, config keys, declarations, tags, or file changes. State what changed for the system: which capability now works, which bug is fixed, which behavior changed.
|
|
6921
6981
|
- Never use these words in an entry: tag, alias, override, flag, probe, endpoint, config key, catalog, file, line, count.
|
|
@@ -6923,13 +6983,12 @@ Rules:
|
|
|
6923
6983
|
- Use a short area prefix when it clarifies: "Pool:", "Providers:", "Config:", "Docs:", "Tests:", "Fix:", "Feature:".
|
|
6924
6984
|
- Name the capability, behavior, feature, bug, or decision. Do not name files, paths, functions, commands, environment variables, API keys, raw model IDs, test file names, or build artifacts. Provider and product names are allowed when they identify the task.
|
|
6925
6985
|
- Do not include counts of slots, providers, tests, lines, or files.
|
|
6926
|
-
- Omit internal housekeeping: builds, dist rebuilds, test-file additions, formatting, and dependency churn. If the slice only contains housekeeping, output nothing.
|
|
6927
6986
|
- Describe what was DONE or DECIDED. Never write "User asked...", "Assistant said...", "Discussed...", "Reviewed...", "Explained...", or "Considered...".
|
|
6928
6987
|
- Merge related changes into one stage; do not repeat the same task in different words.
|
|
6929
6988
|
- No markdown: no bold, italics, backticks, or code blocks.
|
|
6930
6989
|
- If a technical detail is the essence of the task, name it at the capability level: "Added Cloudflare Workers AI as a provider", not its account-id variables.
|
|
6931
|
-
- If the slice contains no
|
|
6932
|
-
- Output only the entries. No headings, no preamble, no closing sentence.
|
|
6990
|
+
- If the slice contains no project work, output exactly NO_PROJECT_CHANGES and nothing else. Otherwise output only the bullets.
|
|
6991
|
+
- Output only the entries or NO_PROJECT_CHANGES. No headings, no preamble, no closing sentence.
|
|
6933
6992
|
|
|
6934
6993
|
Example output:
|
|
6935
6994
|
- Pool: added bucket-based routing for model groups.
|
|
@@ -6957,7 +7016,7 @@ This:
|
|
|
6957
7016
|
- Pool: moved shared-window models to the end.
|
|
6958
7017
|
- Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
|
|
6959
7018
|
|
|
6960
|
-
Your entire response must consist only of lines beginning with "- ".`;
|
|
7019
|
+
Your entire response must consist only of lines beginning with "- ", or exactly NO_PROJECT_CHANGES.`;
|
|
6961
7020
|
function errorText(value) {
|
|
6962
7021
|
if (value instanceof Error) return value.message;
|
|
6963
7022
|
if (typeof value === "string") return value;
|
|
@@ -6972,6 +7031,85 @@ function errorText(value) {
|
|
|
6972
7031
|
}
|
|
6973
7032
|
return String(value);
|
|
6974
7033
|
}
|
|
7034
|
+
var MAX_TASK_CHILDREN_EXPANDED = 40;
|
|
7035
|
+
async function expandTaskSessions(client, messages, directory, owned, onError, cursor) {
|
|
7036
|
+
const projectOwnsTooling = existsSync5(join12(directory, "src/plugins/timeline.ts"));
|
|
7037
|
+
let attempts = 0;
|
|
7038
|
+
const expandedMessages = [];
|
|
7039
|
+
let startIndex = 0;
|
|
7040
|
+
if (cursor) {
|
|
7041
|
+
const cursorIndex = messages.findIndex((m) => m.info.id === cursor);
|
|
7042
|
+
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
|
|
7043
|
+
}
|
|
7044
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
7045
|
+
const message = messages[messageIndex];
|
|
7046
|
+
if (messageIndex < startIndex) {
|
|
7047
|
+
expandedMessages.push(message);
|
|
7048
|
+
continue;
|
|
7049
|
+
}
|
|
7050
|
+
const parts = message.parts ?? [];
|
|
7051
|
+
let changed = false;
|
|
7052
|
+
const nextParts = [];
|
|
7053
|
+
for (const part of parts) {
|
|
7054
|
+
if (message.info.role === "assistant" && part.type === "text" && !projectOwnsTooling && mentionsSideMaintenance(part.text ?? "")) {
|
|
7055
|
+
nextParts.push({ ...part, side: true });
|
|
7056
|
+
changed = true;
|
|
7057
|
+
continue;
|
|
7058
|
+
}
|
|
7059
|
+
if (part.type !== "tool" || part.tool !== "task" || attempts >= MAX_TASK_CHILDREN_EXPANDED) {
|
|
7060
|
+
nextParts.push(part);
|
|
7061
|
+
continue;
|
|
7062
|
+
}
|
|
7063
|
+
if (isSideMaintenanceTask(part.state?.input, directory, projectOwnsTooling)) {
|
|
7064
|
+
nextParts.push({ ...part, side: true });
|
|
7065
|
+
continue;
|
|
7066
|
+
}
|
|
7067
|
+
if (isSideTool("task", part.state?.input)) {
|
|
7068
|
+
nextParts.push({ ...part, side: true });
|
|
7069
|
+
continue;
|
|
7070
|
+
}
|
|
7071
|
+
if (typeof part.childTitle === "string" && part.childTitle.trim() !== "") {
|
|
7072
|
+
nextParts.push(part);
|
|
7073
|
+
continue;
|
|
7074
|
+
}
|
|
7075
|
+
const childId = taskChildSessionId(part);
|
|
7076
|
+
if (!childId || owned.has(childId)) {
|
|
7077
|
+
nextParts.push(part);
|
|
7078
|
+
continue;
|
|
7079
|
+
}
|
|
7080
|
+
attempts++;
|
|
7081
|
+
try {
|
|
7082
|
+
const fetched = await client.session.get({ path: { id: childId }, query: { directory } });
|
|
7083
|
+
const info = fetched?.data;
|
|
7084
|
+
const rawTitle = typeof info?.title === "string" ? info.title.trim() : "";
|
|
7085
|
+
if (!rawTitle || isInternalTitle(info?.title)) {
|
|
7086
|
+
nextParts.push(part);
|
|
7087
|
+
continue;
|
|
7088
|
+
}
|
|
7089
|
+
const childRes = await client.session.messages({ path: { id: childId }, query: { directory } });
|
|
7090
|
+
const childMessages = childRes?.data ?? [];
|
|
7091
|
+
let childOutcome;
|
|
7092
|
+
for (let i = childMessages.length - 1; i >= 0; i--) {
|
|
7093
|
+
const childMessage = childMessages[i];
|
|
7094
|
+
if (childMessage?.info?.role !== "assistant") continue;
|
|
7095
|
+
const textParts = (childMessage.parts ?? []).filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text ?? "");
|
|
7096
|
+
const joined = textParts.join("\n").replace(/\s+/g, " ").trim();
|
|
7097
|
+
if (!joined) continue;
|
|
7098
|
+
childOutcome = joined.length > 800 ? joined.slice(0, 800) : joined;
|
|
7099
|
+
break;
|
|
7100
|
+
}
|
|
7101
|
+
const cloned = childOutcome ? { ...part, childTitle: rawTitle, childOutcome } : { ...part, childTitle: rawTitle };
|
|
7102
|
+
nextParts.push(cloned);
|
|
7103
|
+
changed = true;
|
|
7104
|
+
} catch (error) {
|
|
7105
|
+
if (onError) onError("expand-task-session failed", { childId, error });
|
|
7106
|
+
nextParts.push(part);
|
|
7107
|
+
}
|
|
7108
|
+
}
|
|
7109
|
+
expandedMessages.push(changed ? { ...message, parts: nextParts } : message);
|
|
7110
|
+
}
|
|
7111
|
+
return expandedMessages;
|
|
7112
|
+
}
|
|
6975
7113
|
function timelineHooks(input, options) {
|
|
6976
7114
|
const { client, directory } = input;
|
|
6977
7115
|
const opts = options ?? {};
|
|
@@ -7179,13 +7317,17 @@ function timelineHooks(input, options) {
|
|
|
7179
7317
|
const textParts = parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n").trim();
|
|
7180
7318
|
const reasoningParts = parts.filter((part) => part.type === "reasoning").map((part) => part.text ?? "").join("\n").trim();
|
|
7181
7319
|
const text = textParts || reasoningParts;
|
|
7320
|
+
const trimmed = text.trim();
|
|
7321
|
+
if (trimmed === "NO_PROJECT_CHANGES") {
|
|
7322
|
+
return { ok: true, bullets: [] };
|
|
7323
|
+
}
|
|
7324
|
+
if (trimmed === "") {
|
|
7325
|
+
return { ok: false, bullets: [], error: "empty summarizer output" };
|
|
7326
|
+
}
|
|
7182
7327
|
const bullets = parseBullets(text, maxEntries);
|
|
7183
7328
|
if (bullets.length > 0) {
|
|
7184
7329
|
return { ok: true, bullets };
|
|
7185
7330
|
}
|
|
7186
|
-
if (text.trim() === "") {
|
|
7187
|
-
return { ok: true, bullets: [] };
|
|
7188
|
-
}
|
|
7189
7331
|
const salvaged = salvageBullets(text, maxEntries);
|
|
7190
7332
|
if (salvaged.length > 0) {
|
|
7191
7333
|
log("warn", "timeline: salvaged non-bulleted output", { sessionID: id, text: text.slice(0, 300) });
|
|
@@ -7228,7 +7370,8 @@ function timelineHooks(input, options) {
|
|
|
7228
7370
|
}
|
|
7229
7371
|
const response = await client.session.messages({ path: { id }, query: { directory } });
|
|
7230
7372
|
const messages = response?.data ?? [];
|
|
7231
|
-
const
|
|
7373
|
+
const expanded = await expandTaskSessions(client, messages, directory, owned, debug, state2.cursor?.messageID);
|
|
7374
|
+
const excerpt = buildExcerpt(expanded, state2.cursor);
|
|
7232
7375
|
if (!excerpt.text) {
|
|
7233
7376
|
state2.pending = false;
|
|
7234
7377
|
state2.failures = 0;
|
|
@@ -7286,7 +7429,7 @@ function timelineHooks(input, options) {
|
|
|
7286
7429
|
const info = infoRes?.data;
|
|
7287
7430
|
if (!info) return "Session not found.";
|
|
7288
7431
|
const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
|
|
7289
|
-
|
|
7432
|
+
let messages = msgRes?.data ?? [];
|
|
7290
7433
|
writeFileSync10(temp, "");
|
|
7291
7434
|
for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
|
|
7292
7435
|
if (abort?.aborted) {
|
|
@@ -7296,6 +7439,7 @@ function timelineHooks(input, options) {
|
|
|
7296
7439
|
}
|
|
7297
7440
|
return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
|
|
7298
7441
|
}
|
|
7442
|
+
messages = await expandTaskSessions(client, messages, directory, owned, debug, cursor?.messageID);
|
|
7299
7443
|
const excerpt = buildExcerpt(messages, cursor);
|
|
7300
7444
|
if (!excerpt.text) break;
|
|
7301
7445
|
const prior = readEntriesAt(temp, 4);
|
|
@@ -7449,6 +7593,96 @@ function timelineHooks(input, options) {
|
|
|
7449
7593
|
};
|
|
7450
7594
|
}
|
|
7451
7595
|
|
|
7596
|
+
// src/lib/timer-migration.ts
|
|
7597
|
+
import { execFile } from "node:child_process";
|
|
7598
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync9, readFileSync as readFileSync10, renameSync as renameSync8, writeFileSync as writeFileSync11 } from "node:fs";
|
|
7599
|
+
import { homedir as homedir8 } from "node:os";
|
|
7600
|
+
import { dirname as dirname6, join as join13 } from "node:path";
|
|
7601
|
+
var TIMER_MIGRATION_ENV = "LACODE_NO_TIMER_MIGRATION";
|
|
7602
|
+
var TIMER_MIGRATION_STATE_VERSION = 1;
|
|
7603
|
+
var defaultRun2 = (file, args, options) => new Promise((resolve5, reject) => {
|
|
7604
|
+
execFile(file, args, { stdio: options?.stdio ?? "ignore" }, (error) => {
|
|
7605
|
+
if (error) reject(error);
|
|
7606
|
+
else resolve5();
|
|
7607
|
+
});
|
|
7608
|
+
});
|
|
7609
|
+
function timerMigrationStatePath(home = homedir8()) {
|
|
7610
|
+
return join13(home, ".local", "share", "opencode", "lacode", "migrations.json");
|
|
7611
|
+
}
|
|
7612
|
+
function timerMigrationUnitPath(home = homedir8()) {
|
|
7613
|
+
return join13(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
|
|
7614
|
+
}
|
|
7615
|
+
function readState2(path4) {
|
|
7616
|
+
try {
|
|
7617
|
+
return JSON.parse(readFileSync10(path4, "utf8"));
|
|
7618
|
+
} catch {
|
|
7619
|
+
return {};
|
|
7620
|
+
}
|
|
7621
|
+
}
|
|
7622
|
+
function writeState2(path4, state2) {
|
|
7623
|
+
mkdirSync9(dirname6(path4), { recursive: true });
|
|
7624
|
+
const tmp = `${path4}.${process.pid}.tmp`;
|
|
7625
|
+
writeFileSync11(tmp, JSON.stringify(state2) + "\n");
|
|
7626
|
+
renameSync8(tmp, path4);
|
|
7627
|
+
}
|
|
7628
|
+
async function runTimerMigration(options = {}) {
|
|
7629
|
+
const env = options.env ?? process.env;
|
|
7630
|
+
if (env[TIMER_MIGRATION_ENV] === "1" || env.NODE_TEST_CONTEXT !== void 0) return { action: "disabled" };
|
|
7631
|
+
const platform = options.platform ?? process.platform;
|
|
7632
|
+
if (platform !== "linux") return { action: "skipped", reason: "not-linux" };
|
|
7633
|
+
const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
|
|
7634
|
+
const version = options.version ?? readInstalledVersion(packageRoot);
|
|
7635
|
+
if (!version) return { action: "skipped", reason: "no-version" };
|
|
7636
|
+
const home = options.home ?? homedir8();
|
|
7637
|
+
const statePath = options.statePath ?? timerMigrationStatePath(home);
|
|
7638
|
+
const state2 = readState2(statePath);
|
|
7639
|
+
if (state2.timersVersion === version && existsSync6(timerMigrationUnitPath(home))) {
|
|
7640
|
+
return { action: "up-to-date", version };
|
|
7641
|
+
}
|
|
7642
|
+
const run = options.run ?? defaultRun2;
|
|
7643
|
+
try {
|
|
7644
|
+
await run("systemctl", ["--user", "show-environment"], { stdio: "ignore" });
|
|
7645
|
+
} catch {
|
|
7646
|
+
return { action: "skipped", reason: "no-systemd" };
|
|
7647
|
+
}
|
|
7648
|
+
const script = join13(packageRoot, "scripts", "refresh-pool.mjs");
|
|
7649
|
+
if (!existsSync6(script)) return { action: "failed", version, error: `timer script not found: ${script}` };
|
|
7650
|
+
try {
|
|
7651
|
+
await run(process.execPath, [script, "--install-timer", "--stable"], { stdio: "ignore" });
|
|
7652
|
+
} catch (error) {
|
|
7653
|
+
return { action: "failed", version, error: String(error?.message ?? error) };
|
|
7654
|
+
}
|
|
7655
|
+
try {
|
|
7656
|
+
writeState2(statePath, {
|
|
7657
|
+
version: TIMER_MIGRATION_STATE_VERSION,
|
|
7658
|
+
timersVersion: version,
|
|
7659
|
+
installedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
7660
|
+
});
|
|
7661
|
+
} catch (error) {
|
|
7662
|
+
options.log?.("warn", `timer migration state not written: ${String(error?.message ?? error)}`);
|
|
7663
|
+
}
|
|
7664
|
+
return { action: "installed", version };
|
|
7665
|
+
}
|
|
7666
|
+
|
|
7667
|
+
// src/plugins/timer-migration.ts
|
|
7668
|
+
function timerMigrationHooks(input, options) {
|
|
7669
|
+
if (options?.enabled === false) return {};
|
|
7670
|
+
const log = (level, message, extra) => {
|
|
7671
|
+
void input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
7672
|
+
});
|
|
7673
|
+
};
|
|
7674
|
+
void runTimerMigration({
|
|
7675
|
+
packageRoot: PACKAGE_ROOT,
|
|
7676
|
+
version: readInstalledVersion(PACKAGE_ROOT),
|
|
7677
|
+
log
|
|
7678
|
+
}).then((result) => {
|
|
7679
|
+
if (result.action === "installed") log("info", `timer migration installed for ${result.version}`);
|
|
7680
|
+
else if (result.action === "failed") log("warn", `timer migration failed: ${result.error}`);
|
|
7681
|
+
else log("debug", `timer migration: ${result.action}`);
|
|
7682
|
+
}).catch((error) => log("error", `timer migration threw: ${String(error)}`));
|
|
7683
|
+
return {};
|
|
7684
|
+
}
|
|
7685
|
+
|
|
7452
7686
|
// src/index.ts
|
|
7453
7687
|
var inertHooks = {
|
|
7454
7688
|
config: async () => {
|
|
@@ -7489,6 +7723,7 @@ var LaCode = (async (input, options) => {
|
|
|
7489
7723
|
const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
|
|
7490
7724
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
7491
7725
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
7726
|
+
void timerMigrationHooks(input, options);
|
|
7492
7727
|
const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
|
|
7493
7728
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
7494
7729
|
const compat = await settled("provider-compat", () => providerCompatHooks(input)) ?? inertHooks;
|
package/package.json
CHANGED
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -6,20 +6,19 @@
|
|
|
6
6
|
// writes on a fail-closed condition.
|
|
7
7
|
//
|
|
8
8
|
// Layout (exact, per spec):
|
|
9
|
-
// 1. every
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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 other provider, sorted by score
|
|
13
|
+
// descending, weight 2; opencode models get b:opencode
|
|
15
14
|
//
|
|
16
|
-
// Total =
|
|
15
|
+
// Total = n + min(10 - n, #others), where n = #1.
|
|
17
16
|
//
|
|
18
17
|
// This file ships as source and must run with plain `node` (ESM, no TS).
|
|
19
18
|
|
|
20
|
-
import {
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
21
20
|
import { execFileSync } from "node:child_process"
|
|
22
|
-
import { homedir } from "node:os"
|
|
21
|
+
import { homedir, userInfo } from "node:os"
|
|
23
22
|
import { dirname, join, resolve } from "node:path"
|
|
24
23
|
import { fileURLToPath } from "node:url"
|
|
25
24
|
|
|
@@ -27,6 +26,8 @@ const HOME = homedir()
|
|
|
27
26
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
28
27
|
export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
|
|
29
28
|
export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
|
|
29
|
+
export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "lacode")
|
|
30
|
+
export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
|
|
30
31
|
export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
|
|
31
32
|
export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
|
|
32
33
|
|
|
@@ -42,17 +43,12 @@ const providerOf = (modelOrLine) => {
|
|
|
42
43
|
return slash < 0 ? token : token.slice(0, slash)
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
const isFree = (modelId) => {
|
|
46
|
-
const id = String(modelId || "")
|
|
47
|
-
return id.endsWith(":free") || id.endsWith("-free")
|
|
48
|
-
}
|
|
49
|
-
|
|
50
46
|
const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
56
52
|
|
|
57
53
|
function splitHeaderAndSlots(text) {
|
|
58
54
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
@@ -70,7 +66,7 @@ function splitHeaderAndSlots(text) {
|
|
|
70
66
|
function refreshComment(now, scoresGeneratedAt) {
|
|
71
67
|
const when = now instanceof Date ? now.toISOString() : String(now)
|
|
72
68
|
const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
|
|
73
|
-
return `# Refreshed ${when} from model-scores.json${gen}: opencode-go
|
|
69
|
+
return `# Refreshed ${when} from model-scores.json${gen}: opencode-go first (weight 4), then the best others (weight 2).`
|
|
74
70
|
}
|
|
75
71
|
|
|
76
72
|
function withRefreshComment(headerLines, comment) {
|
|
@@ -88,7 +84,7 @@ function formatGeneratedLine(model, weight, bucket) {
|
|
|
88
84
|
* Pure builder. Throws on every fail-closed condition; never touches the filesystem.
|
|
89
85
|
*
|
|
90
86
|
* @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
|
|
91
|
-
* @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[],
|
|
87
|
+
* @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], others: string[] } }}
|
|
92
88
|
*/
|
|
93
89
|
export function buildPool(input) {
|
|
94
90
|
const warnings = []
|
|
@@ -97,33 +93,34 @@ export function buildPool(input) {
|
|
|
97
93
|
throw new Error("score file is missing or invalid (expected { providers: [...] })")
|
|
98
94
|
}
|
|
99
95
|
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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")
|
|
109
112
|
}
|
|
110
|
-
|
|
111
|
-
|
|
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")
|
|
113
|
+
if (goRows.length + otherRows.length < POOL_SIZE) {
|
|
114
|
+
warnings.push(`only ${goRows.length + otherRows.length} scorable model(s) available, wanted ${POOL_SIZE}`)
|
|
115
115
|
}
|
|
116
|
-
const bestOp = opRows[0].model
|
|
117
116
|
|
|
118
|
-
const { header
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
|
|
126
|
-
const outSlots = [...block1, ...preserved, ...block3]
|
|
117
|
+
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
|
+
]
|
|
127
124
|
|
|
128
125
|
const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
|
|
129
126
|
const outHeader = withRefreshComment(header, comment)
|
|
@@ -135,8 +132,7 @@ export function buildPool(input) {
|
|
|
135
132
|
warnings,
|
|
136
133
|
chosen: {
|
|
137
134
|
opencodeGo: goRows.map((r) => r.model),
|
|
138
|
-
|
|
139
|
-
preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
|
|
135
|
+
others: otherRows.map((r) => r.model),
|
|
140
136
|
},
|
|
141
137
|
}
|
|
142
138
|
}
|
|
@@ -163,6 +159,12 @@ function atomicWriteSimple(path, text) {
|
|
|
163
159
|
renameSync(tmp, path)
|
|
164
160
|
}
|
|
165
161
|
|
|
162
|
+
function writeFileAtomic(path, text) {
|
|
163
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
164
|
+
writeFileSync(tmp, text, "utf8")
|
|
165
|
+
renameSync(tmp, path)
|
|
166
|
+
}
|
|
167
|
+
|
|
166
168
|
function runScorer(max) {
|
|
167
169
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
168
170
|
const scorer = resolve(here, "score-models.mjs")
|
|
@@ -192,8 +194,7 @@ Nice=10
|
|
|
192
194
|
Description=Daily LaCode model pool refresh
|
|
193
195
|
|
|
194
196
|
[Timer]
|
|
195
|
-
OnCalendar
|
|
196
|
-
RandomizedDelaySec=2h
|
|
197
|
+
OnCalendar=*-*-* 01:00:00
|
|
197
198
|
Persistent=true
|
|
198
199
|
Unit=${REFRESH_SERVICE_NAME}
|
|
199
200
|
|
|
@@ -203,18 +204,23 @@ WantedBy=timers.target
|
|
|
203
204
|
return { service, timer, servicePath, timerPath }
|
|
204
205
|
}
|
|
205
206
|
|
|
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")
|
|
207
|
+
function installTimerUnits(stable = false) {
|
|
208
|
+
const live = fileURLToPath(import.meta.url)
|
|
209
|
+
const scriptPath = stable ? STABLE_SCRIPT(HOME) : live
|
|
210
|
+
const units = renderTimerUnits({ nodePath: process.execPath, scriptPath, home: HOME })
|
|
215
211
|
try {
|
|
212
|
+
if (stable) {
|
|
213
|
+
mkdirSync(STABLE_LIB_DIR(HOME), { recursive: true })
|
|
214
|
+
copyFileSync(live, scriptPath)
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(units.servicePath), { recursive: true })
|
|
217
|
+
writeFileAtomic(units.servicePath, units.service)
|
|
218
|
+
writeFileAtomic(units.timerPath, units.timer)
|
|
216
219
|
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
|
|
217
220
|
execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
|
|
221
|
+
try {
|
|
222
|
+
execFileSync("loginctl", ["enable-linger", userInfo().username], { stdio: "ignore" })
|
|
223
|
+
} catch {}
|
|
218
224
|
} catch (error) {
|
|
219
225
|
console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
|
|
220
226
|
console.error(`unit files written:`)
|
|
@@ -226,6 +232,7 @@ function installTimerUnits() {
|
|
|
226
232
|
process.exitCode = 1
|
|
227
233
|
return false
|
|
228
234
|
}
|
|
235
|
+
if (stable) console.log(`copied ${live} to ${scriptPath}`)
|
|
229
236
|
console.log(`installed ${units.servicePath}`)
|
|
230
237
|
console.log(`installed ${units.timerPath}`)
|
|
231
238
|
console.log(`enabled ${REFRESH_TIMER_NAME}`)
|
|
@@ -237,6 +244,8 @@ function usage() {
|
|
|
237
244
|
|
|
238
245
|
--from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
|
|
239
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)
|
|
240
249
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
241
250
|
--dry-run same as --print
|
|
242
251
|
--max N models per provider passed to the scorer (default: 20)
|
|
@@ -248,10 +257,10 @@ function usage() {
|
|
|
248
257
|
async function main() {
|
|
249
258
|
const argv = process.argv.slice(2)
|
|
250
259
|
if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
if (argv.includes("--install-timer")) {
|
|
261
|
+
installTimerUnits(argv.includes("--stable"))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
255
264
|
const fromScores = argv.includes("--from-scores")
|
|
256
265
|
const printOnly = argv.includes("--print") || argv.includes("--dry-run")
|
|
257
266
|
const value = (flag, fallback) => {
|
|
@@ -294,10 +303,12 @@ async function main() {
|
|
|
294
303
|
|
|
295
304
|
const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
|
|
296
305
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
306
|
+
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
297
307
|
const summary = [
|
|
298
308
|
`opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
|
|
299
|
-
`
|
|
300
|
-
`
|
|
309
|
+
`others [${built.chosen.others.join(", ")}]`,
|
|
310
|
+
...(dropped.length ? [`dropped [${dropped.map((x) => x.model).join(", ")}]`] : []),
|
|
311
|
+
...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
|
|
301
312
|
].join(" | ")
|
|
302
313
|
|
|
303
314
|
if (printOnly) {
|
package/scripts/score-models.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// npm run score -- --no-write print only, touch nothing
|
|
13
13
|
//
|
|
14
14
|
// WHAT IT MEASURES
|
|
15
|
-
// context limit.context from `opencode models --verbose
|
|
15
|
+
// context limit.context from `opencode models --verbose`; a known limit
|
|
16
|
+
// below --min-context is excluded from ranking, not scored low
|
|
16
17
|
// output limit.output (the token ceiling for one reply)
|
|
17
18
|
// speed wall-clock ms for one tiny completion, one request per model
|
|
18
19
|
// reliability did the model actually answer? A 429/401/404 is recorded as the
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
// WHAT IT DOES NOT DO
|
|
22
23
|
// * It never invents a latency or a score. A model it could not reach is shown
|
|
23
24
|
// as unreachable with the reason and is NOT ranked.
|
|
25
|
+
// * It never ranks a model whose known context is below --min-context, or whose
|
|
26
|
+
// measured score is below --min-score: those are listed as excluded instead.
|
|
24
27
|
// * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
|
|
25
28
|
// pool stays yours to decide.
|
|
26
29
|
// * It never probes a paid model. Only cost 0/0 models from the provider's
|
|
@@ -38,7 +41,8 @@ import { dirname, join } from "node:path"
|
|
|
38
41
|
|
|
39
42
|
const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
40
43
|
const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
|
|
41
|
-
|
|
44
|
+
// No default provider list: `opencode models --verbose` with no argument returns
|
|
45
|
+
// every provider opencode knows about, and that is what we scan by default.
|
|
42
46
|
const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
43
47
|
|
|
44
48
|
// ── scoring weights ─────────────────────────────────────────────────────────
|
|
@@ -46,6 +50,8 @@ const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
|
46
50
|
// scored on a log curve so 256k vs 1M is a difference, not a cliff.
|
|
47
51
|
const W = { speed: 0.35, context: 0.3, output: 0.15, reliability: 0.2 }
|
|
48
52
|
const MAX_CONTEXT = 1_000_000
|
|
53
|
+
const MIN_CONTEXT = 200_000 // below this, a model is excluded instead of ranked
|
|
54
|
+
const MIN_SCORE = 60 // below this, a probed model is excluded from ranking
|
|
49
55
|
const FAST_MS = 300 // at or below this, speed scores 1.0
|
|
50
56
|
const SLOW_MS = 8_000 // at or above this, speed scores 0
|
|
51
57
|
const PROMPT = "Reply with the single word: ok"
|
|
@@ -63,8 +69,10 @@ const value = (flag, fallback) => {
|
|
|
63
69
|
if (has("--help") || has("-h")) {
|
|
64
70
|
console.log(`Usage: npm run score -- [options]
|
|
65
71
|
|
|
66
|
-
--providers a,b
|
|
72
|
+
--providers a,b restrict the scan to these providers (default: all configured)
|
|
67
73
|
--max N probe at most N free models per provider (default: 12)
|
|
74
|
+
--min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
|
|
75
|
+
--min-score N do not rank models scoring below N (default: ${MIN_SCORE})
|
|
68
76
|
--timeout MS per-request deadline in ms (default: 20000)
|
|
69
77
|
--delay MS pause between requests in ms (default: 300)
|
|
70
78
|
--no-write do not write ~/.config/lacode/model-scores.json
|
|
@@ -79,8 +87,11 @@ opencode/opencode-go models are probed through a temporary local opencode serve
|
|
|
79
87
|
process.exit(0)
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
const
|
|
90
|
+
const providerArg = value("--providers", null)
|
|
91
|
+
const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
|
|
83
92
|
const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
|
|
93
|
+
const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
|
|
94
|
+
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
84
95
|
const timeoutMs = Math.max(1_000, Number(value("--timeout", "20000")) || 20_000)
|
|
85
96
|
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
86
97
|
const writeResults = !has("--no-write")
|
|
@@ -136,18 +147,7 @@ function isChatModel(model) {
|
|
|
136
147
|
return true
|
|
137
148
|
}
|
|
138
149
|
|
|
139
|
-
function
|
|
140
|
-
let text
|
|
141
|
-
try {
|
|
142
|
-
text = execFileSync("opencode", ["models", "--verbose", provider], {
|
|
143
|
-
encoding: "utf8",
|
|
144
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
145
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
146
|
-
})
|
|
147
|
-
} catch (error) {
|
|
148
|
-
return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [] }
|
|
149
|
-
}
|
|
150
|
-
const rows = parseVerbose(text)
|
|
150
|
+
function buildProviderEntry(provider, rows) {
|
|
151
151
|
const models = rows
|
|
152
152
|
.filter(({ model }) => model?.api?.url && model?.api?.id)
|
|
153
153
|
.filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
|
|
@@ -161,7 +161,57 @@ function freeModels(provider) {
|
|
|
161
161
|
output: model.limit?.output ?? null,
|
|
162
162
|
name: model.name ?? id,
|
|
163
163
|
}))
|
|
164
|
-
|
|
164
|
+
const excluded = models
|
|
165
|
+
.filter((m) => m.context != null && m.context < minContext)
|
|
166
|
+
.map((m) => ({
|
|
167
|
+
model: m.model,
|
|
168
|
+
context: m.context,
|
|
169
|
+
output: m.output,
|
|
170
|
+
reason: `context ${m.context} < minimum ${minContext}`,
|
|
171
|
+
}))
|
|
172
|
+
return {
|
|
173
|
+
provider,
|
|
174
|
+
error: null,
|
|
175
|
+
models: models.filter((m) => m.context == null || m.context >= minContext),
|
|
176
|
+
excluded,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function freeModels(provider) {
|
|
181
|
+
let text
|
|
182
|
+
try {
|
|
183
|
+
text = execFileSync("opencode", ["models", "--verbose", provider], {
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
186
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
187
|
+
})
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }
|
|
190
|
+
}
|
|
191
|
+
return buildProviderEntry(provider, parseVerbose(text))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function freeModelsAll() {
|
|
195
|
+
let text
|
|
196
|
+
try {
|
|
197
|
+
text = execFileSync("opencode", ["models", "--verbose"], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
200
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
201
|
+
})
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return [{ provider: "all", error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }]
|
|
204
|
+
}
|
|
205
|
+
const groups = new Map()
|
|
206
|
+
for (const row of parseVerbose(text)) {
|
|
207
|
+
const provider = String(row.model?.providerID ?? row.id.split("/")[0] ?? "")
|
|
208
|
+
if (!provider) continue
|
|
209
|
+
if (!groups.has(provider)) groups.set(provider, [])
|
|
210
|
+
groups.get(provider).push(row)
|
|
211
|
+
}
|
|
212
|
+
return [...groups.entries()]
|
|
213
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
214
|
+
.map(([provider, rows]) => buildProviderEntry(provider, rows))
|
|
165
215
|
}
|
|
166
216
|
|
|
167
217
|
// ── credentials ─────────────────────────────────────────────────────────────
|
|
@@ -436,19 +486,24 @@ const pad = (s, n) => String(s).padEnd(n)
|
|
|
436
486
|
const padL = (s, n) => String(s).padStart(n)
|
|
437
487
|
|
|
438
488
|
async function main() {
|
|
439
|
-
const scanned = providers.map(freeModels)
|
|
489
|
+
const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
|
|
440
490
|
const results = []
|
|
441
491
|
|
|
442
492
|
for (const entry of scanned) {
|
|
443
493
|
if (entry.error) {
|
|
444
494
|
console.log(`\n${entry.provider}: ${entry.error}`)
|
|
445
|
-
results.push({ provider: entry.provider, error: entry.error, rows: [] })
|
|
495
|
+
results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
|
|
446
496
|
continue
|
|
447
497
|
}
|
|
448
498
|
const candidates = entry.models.slice(0, maxPerProvider)
|
|
449
|
-
|
|
499
|
+
const excludedNote = entry.excluded.length ? `, ${entry.excluded.length} excluded (context < ${minContext})` : ""
|
|
500
|
+
console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}${excludedNote}`)
|
|
501
|
+
if (entry.excluded.length) {
|
|
502
|
+
console.log(` excluded — context below ${minContext}:`)
|
|
503
|
+
for (const x of entry.excluded) console.log(` ${pad(x.model.replace(`${entry.provider}/`, ""), 52)} ${padL(x.context, 9)}`)
|
|
504
|
+
}
|
|
450
505
|
if (candidates.length === 0) {
|
|
451
|
-
results.push({ provider: entry.provider, error: null, rows: [] })
|
|
506
|
+
results.push({ provider: entry.provider, error: null, rows: [], excluded: entry.excluded })
|
|
452
507
|
continue
|
|
453
508
|
}
|
|
454
509
|
const rows = []
|
|
@@ -456,12 +511,21 @@ async function main() {
|
|
|
456
511
|
const outcome = await probe(candidate, entry.provider)
|
|
457
512
|
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
458
513
|
row.score = score(row)
|
|
514
|
+
if (row.score && row.score.value < minScore) {
|
|
515
|
+
entry.excluded.push({
|
|
516
|
+
model: row.model,
|
|
517
|
+
context: row.context,
|
|
518
|
+
output: row.output,
|
|
519
|
+
score: row.score.value,
|
|
520
|
+
reason: `score ${row.score.value} < minimum ${minScore}`,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
459
523
|
rows.push(row)
|
|
460
524
|
const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
|
|
461
525
|
console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
|
|
462
526
|
await sleep(delayMs)
|
|
463
527
|
}
|
|
464
|
-
results.push({ provider: entry.provider, error: null, rows })
|
|
528
|
+
results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
|
|
465
529
|
}
|
|
466
530
|
stopSessionServer()
|
|
467
531
|
|
|
@@ -470,7 +534,7 @@ async function main() {
|
|
|
470
534
|
if (entry.error || entry.rows.length === 0) continue
|
|
471
535
|
console.log(`\n${entry.provider} — ranked`)
|
|
472
536
|
console.log(` ${pad("#", 3)}${pad("model", 52)}${padL("ctx", 9)}${padL("out", 9)}${padL("ms", 7)}${padL("score", 7)}`)
|
|
473
|
-
const ranked = [...entry.rows].filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)
|
|
537
|
+
const ranked = [...entry.rows].filter((r) => r.score && r.score.value >= minScore).sort((a, b) => b.score.value - a.score.value)
|
|
474
538
|
ranked.forEach((r, i) => {
|
|
475
539
|
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)}`)
|
|
476
540
|
})
|
|
@@ -481,8 +545,15 @@ async function main() {
|
|
|
481
545
|
}
|
|
482
546
|
}
|
|
483
547
|
|
|
548
|
+
// Models dropped for a too-small context or a too-low score are reported, never ranked.
|
|
549
|
+
const excluded = results.flatMap((e) => e.excluded ?? [])
|
|
550
|
+
if (excluded.length) {
|
|
551
|
+
console.log(`\nexcluded — below the context or score floor (not ranked)`)
|
|
552
|
+
for (const x of excluded) console.log(` ${pad(x.model, 58)}${padL(x.context ?? "?", 9)} ${x.reason}`)
|
|
553
|
+
}
|
|
554
|
+
|
|
484
555
|
// Overall ranking across providers.
|
|
485
|
-
const all = results.flatMap((e) => e.rows).filter((r) => r.score)
|
|
556
|
+
const all = results.flatMap((e) => e.rows).filter((r) => r.score && r.score.value >= minScore)
|
|
486
557
|
all.sort((a, b) => b.score.value - a.score.value)
|
|
487
558
|
if (all.length) {
|
|
488
559
|
console.log(`\noverall — best free models right now`)
|
|
@@ -491,21 +562,21 @@ async function main() {
|
|
|
491
562
|
})
|
|
492
563
|
}
|
|
493
564
|
|
|
494
|
-
// A SUGGESTED pool:
|
|
565
|
+
// A SUGGESTED pool: every opencode-go model (weight 4), then the best
|
|
566
|
+
// (10 - n) models from the other providers (weight 2), by measured score.
|
|
495
567
|
// Printed, never written — pool.txt stays the user's decision.
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
568
|
+
const poolSize = 10
|
|
569
|
+
const goRows = all.filter((r) => r.model.startsWith("opencode-go/"))
|
|
570
|
+
const otherRows = all.filter((r) => !r.model.startsWith("opencode-go/"))
|
|
571
|
+
const pickedOthers = otherRows.slice(0, Math.max(0, poolSize - goRows.length))
|
|
572
|
+
const suggested = [
|
|
573
|
+
...goRows.map((r) => ({ row: r, weight: 4 })),
|
|
574
|
+
...pickedOthers.map((r) => ({ row: r, weight: 2 })),
|
|
575
|
+
]
|
|
576
|
+
if (suggested.length) {
|
|
503
577
|
console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
|
|
504
|
-
console.log(`# scored ${new Date().toISOString()} —
|
|
505
|
-
|
|
506
|
-
const weight = Math.max(1, 4 - i)
|
|
507
|
-
console.log(`${pad(r.model, 60)}${weight}`)
|
|
508
|
-
})
|
|
578
|
+
console.log(`# scored ${new Date().toISOString()} — opencode-go first (weight 4), then the best others (weight 2)`)
|
|
579
|
+
for (const { row, weight } of suggested) console.log(`${pad(row.model, 60)}${weight}`)
|
|
509
580
|
}
|
|
510
581
|
|
|
511
582
|
if (writeResults) {
|
|
@@ -514,9 +585,12 @@ async function main() {
|
|
|
514
585
|
const payload = {
|
|
515
586
|
generatedAt: new Date().toISOString(),
|
|
516
587
|
weights: W,
|
|
588
|
+
minContext,
|
|
589
|
+
minScore,
|
|
517
590
|
providers: results.map((e) => ({
|
|
518
591
|
provider: e.provider,
|
|
519
592
|
error: e.error,
|
|
593
|
+
excluded: e.excluded ?? [],
|
|
520
594
|
rows: e.rows.map((r) => ({
|
|
521
595
|
model: r.model,
|
|
522
596
|
context: r.context,
|