vipcode 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.en.md +60 -0
- package/README.md +401 -2
- package/dist/agent.js +502 -0
- package/dist/agents.js +174 -0
- package/dist/app.js +1025 -0
- package/dist/auth.js +108 -0
- package/dist/catalog.js +487 -0
- package/dist/commands.js +121 -0
- package/dist/config.js +230 -0
- package/dist/debug.js +32 -0
- package/dist/discovery.js +277 -0
- package/dist/frontmatter.js +45 -0
- package/dist/images.js +81 -0
- package/dist/index.js +623 -0
- package/dist/login.js +157 -0
- package/dist/lsp.js +480 -0
- package/dist/markdown.js +262 -0
- package/dist/match.js +8 -0
- package/dist/mcp.js +225 -0
- package/dist/meta.js +7 -0
- package/dist/paths.js +53 -0
- package/dist/permissions.js +144 -0
- package/dist/prompt.js +123 -0
- package/dist/ranking.js +230 -0
- package/dist/router.js +726 -0
- package/dist/sanitize.js +38 -0
- package/dist/session.js +219 -0
- package/dist/skills.js +374 -0
- package/dist/snapshot.js +199 -0
- package/dist/tools.js +1311 -0
- package/dist/trust.js +171 -0
- package/dist/tui/app.js +1019 -0
- package/dist/tui/dialogs.js +197 -0
- package/dist/tui/editor.js +120 -0
- package/dist/ui.js +184 -0
- package/dist/update.js +237 -0
- package/package.json +64 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { deepMerge } from "./config.js";
|
|
4
|
+
import { wildcardMatch } from "./match.js";
|
|
5
|
+
import { HOME_DIR, INSTALL_DIR } from "./paths.js";
|
|
6
|
+
const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
|
|
7
|
+
function strictest(a, b) {
|
|
8
|
+
if (STRICTNESS[b] > STRICTNESS[a]) {
|
|
9
|
+
return b;
|
|
10
|
+
}
|
|
11
|
+
return a;
|
|
12
|
+
}
|
|
13
|
+
// Reglas tipo { "*": "ask", "git status*": "allow" }: gana el patrón más concreto (el más largo)
|
|
14
|
+
export function matchRules(rules, subject, fallback) {
|
|
15
|
+
if (!rules) {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
if (typeof rules === "string") {
|
|
19
|
+
return rules;
|
|
20
|
+
}
|
|
21
|
+
let best = null;
|
|
22
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
23
|
+
if (wildcardMatch(pattern, subject) && (!best || pattern.length >= best.length)) {
|
|
24
|
+
best = { length: pattern.length, action };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (best) {
|
|
28
|
+
return best.action;
|
|
29
|
+
}
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
// Parte "a && b | c; d" en comandos sueltos para que "git status && rm -rf x" no pase como "git status"
|
|
33
|
+
// Redirecciones que no escriben ficheros (2>&1, >/dev/null): no cuentan como separadores ni como escritura
|
|
34
|
+
const HARMLESS_REDIRECTS = /\d*>&\d+|&>\s*\/dev\/null|\d*>>?\s*\/dev\/null/g;
|
|
35
|
+
export function splitCommand(command) {
|
|
36
|
+
// Un & suelto (segundo plano) también separa: «ls & rm -rf ~» son dos comandos
|
|
37
|
+
return command
|
|
38
|
+
.replace(HARMLESS_REDIRECTS, " ")
|
|
39
|
+
.split(/&&|\|\||;|\||\n|&/)
|
|
40
|
+
.map((part) => part.trim())
|
|
41
|
+
.filter((part) => part.length > 0);
|
|
42
|
+
}
|
|
43
|
+
export function evaluate(config, kind, subject) {
|
|
44
|
+
if (kind === "none") {
|
|
45
|
+
return "allow";
|
|
46
|
+
}
|
|
47
|
+
if (kind === "write") {
|
|
48
|
+
return config.edit || "ask";
|
|
49
|
+
}
|
|
50
|
+
if (kind === "web") {
|
|
51
|
+
return config.webfetch || "ask";
|
|
52
|
+
}
|
|
53
|
+
if (kind === "mcp") {
|
|
54
|
+
return matchRules(config.mcp, subject, "ask");
|
|
55
|
+
}
|
|
56
|
+
const parts = splitCommand(subject);
|
|
57
|
+
let result = "allow";
|
|
58
|
+
if (parts.length === 0) {
|
|
59
|
+
result = matchRules(config.bash, subject, "ask");
|
|
60
|
+
}
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
result = strictest(result, matchRules(config.bash, part, "ask"));
|
|
63
|
+
}
|
|
64
|
+
// Una sustitución $(...) o `...` puede esconder cualquier cosa
|
|
65
|
+
// Sustituciones, escribir en ficheros (> o --output) y saltos de línea raros siempre se preguntan,
|
|
66
|
+
// aunque el comando esté permitido (si no, «ls > ~/.zshrc» pasaría por «ls*»)
|
|
67
|
+
const writes = /[<>]\(|>/.test(subject.replace(HARMLESS_REDIRECTS, " ")) || /--output\b|\r/.test(subject);
|
|
68
|
+
if (/\$\(|`/.test(subject) || writes) {
|
|
69
|
+
result = strictest(result, "ask");
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
// Mezcla los permisos generales con los de un agente concreto
|
|
74
|
+
export function permissionsFor(base, override) {
|
|
75
|
+
if (!override) {
|
|
76
|
+
return base;
|
|
77
|
+
}
|
|
78
|
+
return deepMerge(base, override);
|
|
79
|
+
}
|
|
80
|
+
// Sugerencia de patrón para "siempre este comando": "npm test -- --watch" → "npm test*"
|
|
81
|
+
export function suggestPattern(command) {
|
|
82
|
+
const first = splitCommand(command)[0] || command.trim();
|
|
83
|
+
const words = first.split(/\s+/);
|
|
84
|
+
const twoWords = ["npm", "pnpm", "yarn", "bun", "git", "cargo", "go", "pip", "pip3", "python", "python3", "node", "npx", "docker", "make", "uv", "poetry", "deno", "composer", "php", "dotnet", "mvn", "gradle", "./gradlew", "bundle", "rails", "rake", "pytest", "php artisan"];
|
|
85
|
+
if (words.length >= 2 && twoWords.includes(words[0])) {
|
|
86
|
+
return `${words[0]} ${words[1]}*`;
|
|
87
|
+
}
|
|
88
|
+
return `${words[0]}*`;
|
|
89
|
+
}
|
|
90
|
+
// Permisos concedidos durante la sesión ("sí a todo", "siempre este comando")
|
|
91
|
+
export class SessionGrants {
|
|
92
|
+
kinds = new Set();
|
|
93
|
+
patterns = [];
|
|
94
|
+
allowKind(kind) {
|
|
95
|
+
this.kinds.add(kind);
|
|
96
|
+
}
|
|
97
|
+
allowPattern(kind, pattern) {
|
|
98
|
+
this.patterns.push({ kind, pattern });
|
|
99
|
+
}
|
|
100
|
+
allows(kind, subject) {
|
|
101
|
+
if (this.kinds.has(kind)) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const patterns = this.patterns.filter((entry) => entry.kind === kind).map((entry) => entry.pattern);
|
|
105
|
+
if (patterns.length === 0) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (kind === "bash") {
|
|
109
|
+
if (/\$\(|`/.test(subject)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const parts = splitCommand(subject);
|
|
113
|
+
return parts.length > 0 && parts.every((part) => patterns.some((pattern) => wildcardMatch(pattern, part)));
|
|
114
|
+
}
|
|
115
|
+
return patterns.some((pattern) => wildcardMatch(pattern, subject));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Decide si una acción se permite sola, se bloquea o hay que preguntar
|
|
119
|
+
// Un comando que nombra la carpeta de datos de vipcode o su instalación (para leer tus claves, cambiar la
|
|
120
|
+
// configuración o tocar su propio código) se pregunta siempre: no vale ninguna regla allow ni «sí a todo»
|
|
121
|
+
export function touchesVipcode(command) {
|
|
122
|
+
const home = os.homedir();
|
|
123
|
+
const names = [HOME_DIR, INSTALL_DIR, "lib/node_modules/vipcode"];
|
|
124
|
+
if (HOME_DIR.startsWith(home + path.sep)) {
|
|
125
|
+
const inHome = HOME_DIR.slice(home.length + 1);
|
|
126
|
+
names.push(`~/${inHome}`, `$HOME/${inHome}`, `\${HOME}/${inHome}`);
|
|
127
|
+
}
|
|
128
|
+
return names.some((name) => command.includes(name));
|
|
129
|
+
}
|
|
130
|
+
export function createAsker(getConfig, grants, prompt) {
|
|
131
|
+
return async (request) => {
|
|
132
|
+
const action = evaluate(getConfig(), request.kind, request.subject);
|
|
133
|
+
if (action === "deny") {
|
|
134
|
+
return { allow: false, feedback: "bloqueado por la configuración de permisos (deny)" };
|
|
135
|
+
}
|
|
136
|
+
if (request.kind === "bash" && touchesVipcode(request.subject)) {
|
|
137
|
+
return prompt({ ...request, title: `${request.title} (toca la carpeta de vipcode)` });
|
|
138
|
+
}
|
|
139
|
+
if (action === "allow" || grants.allows(request.kind, request.subject)) {
|
|
140
|
+
return { allow: true };
|
|
141
|
+
}
|
|
142
|
+
return prompt(request);
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { GLOBAL_INSTRUCTIONS_PATH } from "./paths.js";
|
|
5
|
+
function readLimited(file, max = 15000) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.readFileSync(file, "utf8").slice(0, max);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Instrucciones del proyecto, igual que hace opencode con AGENTS.md
|
|
14
|
+
function projectInstructions(root, extra) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
const global = readLimited(GLOBAL_INSTRUCTIONS_PATH);
|
|
17
|
+
if (global.trim() !== "") {
|
|
18
|
+
parts.push(`# Instrucciones generales del usuario (~/.vipcode/AGENTS.md)\n${global}`);
|
|
19
|
+
}
|
|
20
|
+
const names = ["AGENTS.md", "CLAUDE.md", ".vipcode.md"];
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
const file = path.join(root, name);
|
|
23
|
+
if (fs.existsSync(file)) {
|
|
24
|
+
parts.push(`# Instrucciones del proyecto (${name})\n${readLimited(file)}`);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Ficheros extra del config ("instructions": ["docs/estilo.md", ".cursor/rules/*.md"])
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
for (const pattern of extra) {
|
|
31
|
+
let files = [];
|
|
32
|
+
try {
|
|
33
|
+
files = fg.sync(pattern, { cwd: root, onlyFiles: true, dot: true, absolute: true }).slice(0, 10);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
files = [];
|
|
37
|
+
}
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (seen.has(file)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
seen.add(file);
|
|
43
|
+
parts.push(`# Instrucciones (${path.relative(root, file)})\n${readLimited(file, 8000)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (parts.length === 0) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
return `\n\n${parts.join("\n\n")}`;
|
|
50
|
+
}
|
|
51
|
+
// Lo que hay en la raíz del proyecto, para que el modelo se oriente sin gastar una llamada
|
|
52
|
+
function topLevel(root) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = fs
|
|
55
|
+
.readdirSync(root, { withFileTypes: true })
|
|
56
|
+
.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules" && entry.name !== ".DS_Store")
|
|
57
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
return `${entry.name}/`;
|
|
61
|
+
}
|
|
62
|
+
return entry.name;
|
|
63
|
+
});
|
|
64
|
+
if (entries.length === 0) {
|
|
65
|
+
return "(empty)";
|
|
66
|
+
}
|
|
67
|
+
let extra = "";
|
|
68
|
+
if (entries.length > 40) {
|
|
69
|
+
extra = ` … (+${entries.length - 40} more)`;
|
|
70
|
+
}
|
|
71
|
+
return entries.slice(0, 40).join(", ") + extra;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "(unknown)";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function systemPrompt(root, options) {
|
|
78
|
+
const { agent, toolNames } = options;
|
|
79
|
+
const has = (name) => toolNames.includes(name);
|
|
80
|
+
let modeText = "You are in BUILD mode: you may read, create and edit files and run shell commands (the user approves risky actions).";
|
|
81
|
+
if (agent.readOnly && !options.subagent) {
|
|
82
|
+
modeText = "";
|
|
83
|
+
}
|
|
84
|
+
const rules = [
|
|
85
|
+
"Explore before acting: use list_dir, glob, grep and read_file to understand the code. Never guess file contents.",
|
|
86
|
+
];
|
|
87
|
+
if (!agent.readOnly) {
|
|
88
|
+
rules.push("To change an existing file, read it first and then use edit_file with an exact, unique old_string copied from read_file output (without the line-number prefix). Use write_file only for new files or full rewrites.", "Make the smallest change that solves the task and follows the existing style of the project.", "After changing code, run the relevant build, linter or tests with bash when the project has them. If the language server reports errors after your edit, fix them.");
|
|
89
|
+
}
|
|
90
|
+
rules.push("Call several tools in one turn when they are independent.", "If a tool returns an error, read it and fix your approach instead of repeating the same call.");
|
|
91
|
+
if (has("todowrite")) {
|
|
92
|
+
rules.push("For tasks with 3 or more steps, keep a todo list with todowrite: mark one step in_progress when you start it and completed as soon as it is done. Skip it for simple tasks.");
|
|
93
|
+
}
|
|
94
|
+
if (has("task")) {
|
|
95
|
+
rules.push("Use the task tool to delegate broad searches or independent sub-tasks to a subagent; it keeps your own context small.");
|
|
96
|
+
}
|
|
97
|
+
if (has("skill")) {
|
|
98
|
+
rules.push("The skill tool lists skills: specialised instructions for certain kinds of tasks. When the request matches a skill's description, load it with skill before starting and follow its instructions.");
|
|
99
|
+
}
|
|
100
|
+
if (has("websearch") || has("webfetch")) {
|
|
101
|
+
rules.push("Use websearch to find documentation and webfetch to read a page when you need information you don't have.");
|
|
102
|
+
}
|
|
103
|
+
rules.push("Files the user attaches with @path come inside <fichero> or <carpeta> tags in their message; they are already read.", "When you finish, reply with a short summary of what you changed. Do not paste whole files back.", "Reply in the same language the user writes in (usually Spanish). Be concise; use Markdown only when it helps.");
|
|
104
|
+
let agentText = "";
|
|
105
|
+
if (agent.prompt.trim() !== "") {
|
|
106
|
+
agentText = `\n\n${agent.prompt.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
let isGit = "no";
|
|
109
|
+
if (fs.existsSync(path.join(root, ".git"))) {
|
|
110
|
+
isGit = "yes";
|
|
111
|
+
}
|
|
112
|
+
return `You are vipcode, an expert software engineering agent running in the user's terminal.${modeText ? `\n\n${modeText}` : ""}${agentText}
|
|
113
|
+
|
|
114
|
+
Working rules:
|
|
115
|
+
${rules.map((rule) => `- ${rule}`).join("\n")}
|
|
116
|
+
|
|
117
|
+
Environment:
|
|
118
|
+
- Working directory: ${root}
|
|
119
|
+
- Git repository: ${isGit}
|
|
120
|
+
- Platform: ${process.platform}
|
|
121
|
+
- Date: ${new Date().toISOString().slice(0, 10)}
|
|
122
|
+
- Top-level entries: ${topLevel(root)}${projectInstructions(root, options.instructions)}`;
|
|
123
|
+
}
|
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readAuth } from "./auth.js";
|
|
3
|
+
import { debugLog } from "./debug.js";
|
|
4
|
+
import { RANKING_PATH, ensureHomeDir } from "./paths.js";
|
|
5
|
+
const WEIGHTS = {
|
|
6
|
+
coding: { code: 0.4, agents: 0.3, tools: 0.15, general: 0.15 },
|
|
7
|
+
general: { general: 1 },
|
|
8
|
+
};
|
|
9
|
+
// Nombres que cada proveedor escribe a su manera y no se parecen al de LLM Stats
|
|
10
|
+
const ALIASES = {
|
|
11
|
+
"openai-fast": "gpt-oss-20b",
|
|
12
|
+
"nemotron-3-120b-a12b": "nemotron-3-super-120b-a12b",
|
|
13
|
+
};
|
|
14
|
+
// API oficial de LLM Stats (VIPCODE_LLMSTATS_URL permite usar otra, por ejemplo en las pruebas)
|
|
15
|
+
function apiBase() {
|
|
16
|
+
return process.env.VIPCODE_LLMSTATS_URL || "https://api.zeroeval.com/stats/v1";
|
|
17
|
+
}
|
|
18
|
+
// "nvidia/z-ai/glm-5.3:free" → "glm-5.3"
|
|
19
|
+
function baseId(id) {
|
|
20
|
+
let text = String(id).toLowerCase().replace(/^@cf\//, "");
|
|
21
|
+
text = text.slice(text.lastIndexOf("/") + 1);
|
|
22
|
+
return text.replace(/(:free|-free|:cloud|-cloud)$/, "");
|
|
23
|
+
}
|
|
24
|
+
// Clave para comparar: solo letras y números, sin palabras que no cambian el modelo
|
|
25
|
+
function keyOf(id) {
|
|
26
|
+
let text = baseId(id).replace(/[:._\s]+/g, "-");
|
|
27
|
+
text = text.replace(/-(instruct|it|chat|fp8|hf|versatile)(?=-|$)/g, "");
|
|
28
|
+
text = text.replace(/^(meta-|zai-|z-ai-)/, "");
|
|
29
|
+
return text.replace(/[^a-z0-9]/g, "");
|
|
30
|
+
}
|
|
31
|
+
// Palabras del nombre sin versiones ni tamaños ("gemini-3.8-flash" → gemini, flash)
|
|
32
|
+
function wordsOf(id) {
|
|
33
|
+
return baseId(id)
|
|
34
|
+
.replace(/[:._\s]+/g, "-")
|
|
35
|
+
.split("-")
|
|
36
|
+
.filter((word) => word && word !== "latest" && !/^\d+$/.test(word) && !/^v?\d+(\.\d+)?[bm]?$/.test(word))
|
|
37
|
+
.sort()
|
|
38
|
+
.join(" ");
|
|
39
|
+
}
|
|
40
|
+
// Lo descargado con la API (null si todavía no hay clave de LLM Stats o nunca se ha descargado)
|
|
41
|
+
export function loadRankingData() {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(fs.readFileSync(RANKING_PATH, "utf8"));
|
|
44
|
+
if (Array.isArray(data.models) && data.models.length > 0) {
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// sin descargas todavía
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export class Ranking {
|
|
54
|
+
data;
|
|
55
|
+
metric;
|
|
56
|
+
byKey = new Map();
|
|
57
|
+
keys = [];
|
|
58
|
+
sorted = {};
|
|
59
|
+
cache = new Map();
|
|
60
|
+
constructor(data, metric = "coding") {
|
|
61
|
+
this.data = data;
|
|
62
|
+
this.metric = metric;
|
|
63
|
+
for (const model of data.models) {
|
|
64
|
+
for (const key of [keyOf(model.id), keyOf(model.name)]) {
|
|
65
|
+
if (key && !this.byKey.has(key)) {
|
|
66
|
+
this.byKey.set(key, model);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.keys = [...this.byKey.keys()];
|
|
71
|
+
for (const field of ["general", "code", "agents", "tools"]) {
|
|
72
|
+
this.sorted[field] = data.models
|
|
73
|
+
.map((model) => model[field])
|
|
74
|
+
.filter((value) => typeof value === "number")
|
|
75
|
+
.sort((a, b) => a - b);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Busca el modelo de LLM Stats que corresponde a un id de proveedor
|
|
79
|
+
find(id) {
|
|
80
|
+
const alias = ALIASES[baseId(id)];
|
|
81
|
+
if (alias) {
|
|
82
|
+
return this.byKey.get(keyOf(alias)) || null;
|
|
83
|
+
}
|
|
84
|
+
const base = baseId(id);
|
|
85
|
+
const withoutDate = base.replace(/-(\d{4}|\d{6}|\d{8}|\d{2}-\d{4})$/, "");
|
|
86
|
+
const tries = [keyOf(base), keyOf(withoutDate), keyOf(base.replace(/-v\d+$/, ""))];
|
|
87
|
+
for (const key of tries) {
|
|
88
|
+
if (this.byKey.has(key)) {
|
|
89
|
+
return this.byKey.get(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// "…-latest": el más reciente con las mismas palabras
|
|
93
|
+
if (base.endsWith("-latest")) {
|
|
94
|
+
const words = wordsOf(base);
|
|
95
|
+
const candidates = this.data.models
|
|
96
|
+
.filter((model) => wordsOf(model.id) === words)
|
|
97
|
+
.sort((a, b) => String(b.released).localeCompare(String(a.released)));
|
|
98
|
+
if (candidates[0]) {
|
|
99
|
+
return candidates[0];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Un nombre más corto que el de LLM Stats ("beam" → "beam-1.0-501b-a23b"), o más largo ("llama-4-scout-17b" → "llama-4-scout")
|
|
103
|
+
const short = keyOf(withoutDate);
|
|
104
|
+
const longer = this.keys.filter((key) => key.startsWith(short)).sort((a, b) => a.length - b.length);
|
|
105
|
+
const distinct = new Set(longer.map((key) => this.byKey.get(key)));
|
|
106
|
+
if (longer.length > 0 && (distinct.size === 1 || short.length >= 8)) {
|
|
107
|
+
return this.byKey.get(longer[0]);
|
|
108
|
+
}
|
|
109
|
+
const shorter = this.keys.filter((key) => key.length >= 8 && short.startsWith(key)).sort((a, b) => b.length - a.length);
|
|
110
|
+
if (shorter[0]) {
|
|
111
|
+
return this.byKey.get(shorter[0]);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
percentile(field, value) {
|
|
116
|
+
const list = this.sorted[field];
|
|
117
|
+
if (!list || list.length < 2) {
|
|
118
|
+
return 0.5;
|
|
119
|
+
}
|
|
120
|
+
let below = 0;
|
|
121
|
+
while (below < list.length && list[below] < value) {
|
|
122
|
+
below++;
|
|
123
|
+
}
|
|
124
|
+
return below / (list.length - 1);
|
|
125
|
+
}
|
|
126
|
+
// Nota de 0 a 100 (null si LLM Stats no tiene datos del modelo)
|
|
127
|
+
score(id) {
|
|
128
|
+
if (this.cache.has(id)) {
|
|
129
|
+
return this.cache.get(id);
|
|
130
|
+
}
|
|
131
|
+
const model = this.find(id);
|
|
132
|
+
let result = null;
|
|
133
|
+
if (model) {
|
|
134
|
+
let sum = 0;
|
|
135
|
+
let weight = 0;
|
|
136
|
+
for (const [field, value] of Object.entries(WEIGHTS[this.metric])) {
|
|
137
|
+
const raw = model[field];
|
|
138
|
+
if (typeof raw === "number" && value) {
|
|
139
|
+
// Percentiles para que índices con escalas distintas se puedan sumar
|
|
140
|
+
sum += this.percentile(field, raw) * value;
|
|
141
|
+
weight += value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (weight > 0) {
|
|
145
|
+
result = { score: Math.round((sum / weight) * 100), model };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.cache.set(id, result);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Clave de la API de LLM Stats (vipcode auth login llm-stats, o la variable LLMSTATS_API_KEY)
|
|
153
|
+
export function rankingKey() {
|
|
154
|
+
return readAuth()["llm-stats"]?.key || process.env.LLMSTATS_API_KEY || null;
|
|
155
|
+
}
|
|
156
|
+
function pick(source, names) {
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
const value = source[name];
|
|
159
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// Un modelo tal como lo devuelve la API (los nombres de los campos pueden variar: se buscan varios)
|
|
166
|
+
function parseApiModel(item) {
|
|
167
|
+
const id = String(item?.id || item?.model_id || "");
|
|
168
|
+
if (!id) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const scores = {
|
|
172
|
+
...item,
|
|
173
|
+
...(item.top_scores || {}),
|
|
174
|
+
...(item.scores || {}),
|
|
175
|
+
...(item.indices || {}),
|
|
176
|
+
...(item.category_scores || {}),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
name: String(item.name || id),
|
|
181
|
+
general: pick(scores, ["index_general", "general", "overall", "llm_stats_score", "score"]),
|
|
182
|
+
code: pick(scores, ["index_code", "code", "coding"]),
|
|
183
|
+
agents: pick(scores, ["index_agents", "agents", "agent", "agentic"]),
|
|
184
|
+
tools: pick(scores, ["index_tool_calling", "tool_calling", "tools", "tool_use"]),
|
|
185
|
+
released: item.release_date ? String(item.release_date) : null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Descarga las puntuaciones actuales con la API oficial (plan gratis: 250 respuestas al día)
|
|
189
|
+
export async function refreshRanking(apiKey, signal) {
|
|
190
|
+
const models = [];
|
|
191
|
+
let cursor = "";
|
|
192
|
+
for (let page = 0; page < 20; page++) {
|
|
193
|
+
let url = `${apiBase()}/models?limit=100`;
|
|
194
|
+
if (cursor) {
|
|
195
|
+
url += `&cursor=${encodeURIComponent(cursor)}`;
|
|
196
|
+
}
|
|
197
|
+
const signals = [AbortSignal.timeout(20000)];
|
|
198
|
+
if (signal) {
|
|
199
|
+
signals.push(signal);
|
|
200
|
+
}
|
|
201
|
+
const response = await fetch(url, {
|
|
202
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" },
|
|
203
|
+
signal: AbortSignal.any(signals),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`HTTP ${response.status}`);
|
|
207
|
+
}
|
|
208
|
+
const json = await response.json();
|
|
209
|
+
const items = Array.isArray(json?.models) ? json.models : Array.isArray(json?.data) ? json.data : [];
|
|
210
|
+
for (const item of items) {
|
|
211
|
+
const model = parseApiModel(item);
|
|
212
|
+
if (model) {
|
|
213
|
+
models.push(model);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
cursor = json?.next_cursor || json?.nextCursor || "";
|
|
217
|
+
if (!cursor || items.length === 0) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const rated = models.filter((model) => [model.general, model.code, model.agents, model.tools].some((value) => value !== null));
|
|
222
|
+
if (rated.length < 30) {
|
|
223
|
+
throw new Error("la API no devolvió puntuaciones que vipcode sepa leer");
|
|
224
|
+
}
|
|
225
|
+
const data = { updated: new Date().toISOString().slice(0, 10), source: "https://llm-stats.com (API)", models: rated };
|
|
226
|
+
ensureHomeDir();
|
|
227
|
+
fs.writeFileSync(RANKING_PATH, JSON.stringify(data));
|
|
228
|
+
debugLog("ranking actualizado", rated.length);
|
|
229
|
+
return { count: rated.length };
|
|
230
|
+
}
|