@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 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.0.0-beta.0...HEAD
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 === "timeline_reprocess") continue;
6828
+ if (isSideTool(tool3, p.state?.input)) continue;
6783
6829
  const status = p.state?.status;
6784
6830
  const summary = inputSummary(p.state?.input);
6785
- const result = summarizeToolResult(p.state);
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
- Write 1-8 timeline entries describing the work as tasks, features, fixes, or stages.
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 provider, feature, fix, config, docs, or test change; merge only exact duplicates.
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 substantive work or decision, output nothing.
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 excerpt = buildExcerpt(messages, state2.cursor);
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
- const messages = msgRes?.data ?? [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "2.0.0-beta.0",
3
+ "version": "2.1.0-beta.0",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,20 +6,19 @@
6
6
  // writes on a fail-closed condition.
7
7
  //
8
8
  // Layout (exact, per spec):
9
- // 1. every free opencode-go model with ok:true and a numeric score, one slot
10
- // each, sorted by score descending, weight 4
11
- // 2. every slot from the current pool whose provider is NOT present in the
12
- // score file, preserved exactly (raw line text, weight/bucket/notify, order)
13
- // 3. exactly one free opencode model — the best ok:true numeric score —
14
- // weight 2, with b:opencode
9
+ // 1. every opencode-go model with ok:true, a numeric score at or above the
10
+ // score floor and a context at or above the context floor, sorted by score
11
+ // descending, weight 4
12
+ // 2. the best (10 - n) models from every other provider, sorted by score
13
+ // descending, weight 2; opencode models get b:opencode
15
14
  //
16
- // Total = #opencode-go free + #preserved unscored + 1.
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 { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
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
- const okFreeRows = (providerEntry) => {
53
- const rows = Array.isArray(providerEntry?.rows) ? providerEntry.rows : []
54
- return rows.filter((row) => row?.ok === true && numericScore(row) && isFree(row.model))
55
- }
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 free models first, best opencode last.`
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[], opencode: string, preserved: 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 providerEntries = scores.providers
101
- const scoredProviders = new Set(
102
- providerEntries.map((p) => String(p?.provider ?? "").trim()).filter(Boolean),
103
- )
104
-
105
- const goEntry = providerEntries.find((p) => p?.provider === "opencode-go")
106
- const goRows = okFreeRows(goEntry).sort((a, b) => b.score - a.score)
107
- if (goRows.length === 0) {
108
- throw new Error("no free opencode-go model with ok:true and a numeric score is available; refusing to write pool.txt")
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
- const opEntry = providerEntries.find((p) => p?.provider === "opencode")
112
- const opRows = okFreeRows(opEntry).sort((a, b) => b.score - a.score)
113
- if (opRows.length === 0) {
114
- throw new Error("no free opencode model with ok:true and a numeric score is available; refusing to write pool.txt")
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, slots: currentSlots } = splitHeaderAndSlots(input?.currentPoolText ?? "")
119
- const preserved = currentSlots.filter((line) => {
120
- if (!isSlotLine(line)) return false
121
- return !scoredProviders.has(providerOf(line))
122
- })
123
-
124
- const block1 = goRows.map((row) => formatGeneratedLine(row.model, 4))
125
- const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
126
- const outSlots = [...block1, ...preserved, ...block3]
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
- opencode: bestOp,
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=daily
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 units = renderTimerUnits({
208
- nodePath: process.execPath,
209
- scriptPath: fileURLToPath(import.meta.url),
210
- home: HOME,
211
- })
212
- mkdirSync(dirname(units.servicePath), { recursive: true })
213
- writeFileSync(units.servicePath, units.service, "utf8")
214
- writeFileSync(units.timerPath, units.timer, "utf8")
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
- if (argv.includes("--install-timer")) {
252
- installTimerUnits()
253
- return
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
- `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
- `opencode-last [${built.chosen.opencode}]`,
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) {
@@ -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
- const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
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 providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
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 providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
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 freeModels(provider) {
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
- return { provider, error: null, models }
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
- console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}`)
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: the best model from each provider, weights 4/3/2/1 by rank.
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 bestPerProvider = []
497
- for (const entry of results) {
498
- const top = entry.rows.filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)[0]
499
- if (top) bestPerProvider.push(top)
500
- }
501
- bestPerProvider.sort((a, b) => b.score.value - a.score.value)
502
- if (bestPerProvider.length) {
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()} — one best free model per provider`)
505
- bestPerProvider.forEach((r, i) => {
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,