vskill 0.5.85 → 0.5.87
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/README.md +11 -3
- package/dist/agents/agents-registry.d.ts +5 -0
- package/dist/agents/agents-registry.js.map +1 -1
- package/dist/eval/env.d.ts +18 -0
- package/dist/eval/env.js +74 -0
- package/dist/eval/env.js.map +1 -0
- package/dist/eval/llm.js +22 -6
- package/dist/eval/llm.js.map +1 -1
- package/dist/eval/path-utils.d.ts +24 -0
- package/dist/eval/path-utils.js +72 -0
- package/dist/eval/path-utils.js.map +1 -0
- package/dist/eval/skill-scanner.d.ts +30 -0
- package/dist/eval/skill-scanner.js +192 -12
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/api-routes.d.ts +80 -0
- package/dist/eval-server/api-routes.js +442 -20
- package/dist/eval-server/api-routes.js.map +1 -1
- package/dist/eval-server/settings-store.d.ts +39 -0
- package/dist/eval-server/settings-store.js +195 -0
- package/dist/eval-server/settings-store.js.map +1 -0
- package/dist/eval-server/studio-json.d.ts +9 -0
- package/dist/eval-server/studio-json.js +82 -0
- package/dist/eval-server/studio-json.js.map +1 -0
- package/dist/eval-ui/assets/{CommandPalette-BAcN6Jji.js → CommandPalette-BmFgVfCB.js} +1 -1
- package/dist/eval-ui/assets/UpdateDropdown-DyH9TWvR.js +1 -0
- package/dist/eval-ui/assets/index-C4XKAX1s.css +1 -0
- package/dist/eval-ui/assets/index-DQDnt_gJ.js +89 -0
- package/dist/eval-ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/eval-ui/assets/index-D6Rb5362.js +0 -76
- package/dist/eval-ui/assets/index-EnxOcJMr.css +0 -1
|
@@ -13,7 +13,8 @@ import { parseSource } from "../resolvers/source-resolver.js";
|
|
|
13
13
|
import { runBenchmarkSSE, runSingleCaseSSE } from "./benchmark-runner.js";
|
|
14
14
|
import { getSkillSemaphore } from "./concurrency.js";
|
|
15
15
|
import { resolveSkillDir } from "./skill-resolver.js";
|
|
16
|
-
import {
|
|
16
|
+
import { classifyOrigin, scanSkillsTriScope } from "../eval/skill-scanner.js";
|
|
17
|
+
import { resolveGlobalSkillsDir } from "../eval/path-utils.js";
|
|
17
18
|
import { loadAndValidateEvals, EvalValidationError } from "../eval/schema.js";
|
|
18
19
|
import { readBenchmark } from "../eval/benchmark.js";
|
|
19
20
|
import { writeHistoryEntry, listHistory, readHistoryEntry, computeRegressions, deleteHistoryEntry, getCaseHistory, computeStats } from "../eval/benchmark-history.js";
|
|
@@ -28,6 +29,9 @@ import { testActivation } from "../eval/activation-tester.js";
|
|
|
28
29
|
import { detectMcpDependencies, detectSkillDependencies } from "../eval/mcp-detector.js";
|
|
29
30
|
import { writeActivationRun, listActivationRuns, getActivationRun } from "../eval/activation-history.js";
|
|
30
31
|
import { AGENTS_REGISTRY, detectInstalledAgents } from "../agents/agents-registry.js";
|
|
32
|
+
import { resolveOllamaBaseUrl } from "../eval/env.js";
|
|
33
|
+
import * as settingsStore from "./settings-store.js";
|
|
34
|
+
import { loadStudioSelection, saveStudioSelection } from "./studio-json.js";
|
|
31
35
|
/**
|
|
32
36
|
* Build the response for GET /api/agents/installed.
|
|
33
37
|
* Returns all known agents with installed flag based on detected agents.
|
|
@@ -51,6 +55,196 @@ export function buildInstalledAgentsResponse(detectedAgents) {
|
|
|
51
55
|
}
|
|
52
56
|
return { agents, suggested };
|
|
53
57
|
}
|
|
58
|
+
let agentPresenceCache = null;
|
|
59
|
+
const AGENT_PRESENCE_CACHE_TTL = 30_000;
|
|
60
|
+
/** Test hook — clear the 30 s cache so the next buildAgentsResponse() re-scans. */
|
|
61
|
+
export function resetAgentPresenceCache() {
|
|
62
|
+
agentPresenceCache = null;
|
|
63
|
+
}
|
|
64
|
+
/** Count skills in a directory following the `<dir>/<skill>/SKILL.md` layout.
|
|
65
|
+
* Non-recursive — skills are conventionally flat children of the skills dir. */
|
|
66
|
+
function countSkillsIn(dir) {
|
|
67
|
+
if (!existsSync(dir))
|
|
68
|
+
return 0;
|
|
69
|
+
try {
|
|
70
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
71
|
+
let count = 0;
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const fullPath = join(dir, entry.name);
|
|
74
|
+
// Accept plain dirs AND symlinked-dirs.
|
|
75
|
+
let isDirLike = entry.isDirectory();
|
|
76
|
+
if (!isDirLike && entry.isSymbolicLink?.()) {
|
|
77
|
+
try {
|
|
78
|
+
isDirLike = statSync(fullPath).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
catch { /* broken link */ }
|
|
81
|
+
}
|
|
82
|
+
if (!isDirLike)
|
|
83
|
+
continue;
|
|
84
|
+
if (existsSync(join(fullPath, "SKILL.md")))
|
|
85
|
+
count++;
|
|
86
|
+
}
|
|
87
|
+
return count;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build the /api/agents response. Filters to agents with presence and
|
|
95
|
+
* includes per-agent counts, resolved paths, and shared-folder grouping.
|
|
96
|
+
*
|
|
97
|
+
* Results are cached for 30s keyed by `(root, home, binaries)` so repeated
|
|
98
|
+
* polls don't re-walk the filesystem.
|
|
99
|
+
*/
|
|
100
|
+
export async function buildAgentsResponse(opts) {
|
|
101
|
+
const root = opts.root;
|
|
102
|
+
const home = opts.home;
|
|
103
|
+
const detectedBinaries = opts.detectedBinaries ?? new Set();
|
|
104
|
+
const cacheKey = {
|
|
105
|
+
rootKey: root,
|
|
106
|
+
homeKey: home ?? "",
|
|
107
|
+
binariesKey: [...detectedBinaries].sort().join(","),
|
|
108
|
+
};
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
if (agentPresenceCache &&
|
|
111
|
+
now - agentPresenceCache.ts < AGENT_PRESENCE_CACHE_TTL &&
|
|
112
|
+
agentPresenceCache.rootKey === cacheKey.rootKey &&
|
|
113
|
+
agentPresenceCache.homeKey === cacheKey.homeKey &&
|
|
114
|
+
agentPresenceCache.binariesKey === cacheKey.binariesKey) {
|
|
115
|
+
return agentPresenceCache.data;
|
|
116
|
+
}
|
|
117
|
+
// Map each agent → resolved local + global dir. For tests, `home` overrides
|
|
118
|
+
// the homedir-derived global path. In production, resolveGlobalSkillsDir()
|
|
119
|
+
// handles cross-platform resolution (darwin / linux / win32).
|
|
120
|
+
const entries = [];
|
|
121
|
+
const globalDirByAgentId = new Map();
|
|
122
|
+
for (const agent of AGENTS_REGISTRY) {
|
|
123
|
+
const resolvedLocalDir = join(root, agent.localSkillsDir);
|
|
124
|
+
const resolvedGlobalDir = home
|
|
125
|
+
? join(home, firstNonTildeSegment(agent.globalSkillsDir))
|
|
126
|
+
: resolveGlobalSkillsDir(agent);
|
|
127
|
+
globalDirByAgentId.set(agent.id, resolvedGlobalDir);
|
|
128
|
+
const localExists = existsSync(resolvedLocalDir);
|
|
129
|
+
const globalExists = existsSync(resolvedGlobalDir);
|
|
130
|
+
const binaryDetected = detectedBinaries.has(agent.id);
|
|
131
|
+
const hasPresence = localExists || globalExists || binaryDetected;
|
|
132
|
+
if (!hasPresence)
|
|
133
|
+
continue;
|
|
134
|
+
const localSkillCount = countSkillsIn(resolvedLocalDir);
|
|
135
|
+
const globalSkillCount = countSkillsIn(resolvedGlobalDir);
|
|
136
|
+
const firstLocalSegment = agent.localSkillsDir.split("/")[0] || "";
|
|
137
|
+
const hasProjectFolder = firstLocalSegment
|
|
138
|
+
? existsSync(join(root, firstLocalSegment))
|
|
139
|
+
: false;
|
|
140
|
+
const isDefault = agent.id === "claude-code" && hasProjectFolder;
|
|
141
|
+
// Best-effort lastSync from lockfile — null when no lockfile or no entry.
|
|
142
|
+
const lastSync = resolveAgentLastSync(root, agent.id);
|
|
143
|
+
const health = computeAgentHealth(lastSync, localSkillCount + globalSkillCount);
|
|
144
|
+
entries.push({
|
|
145
|
+
id: agent.id,
|
|
146
|
+
displayName: agent.displayName,
|
|
147
|
+
featureSupport: agent.featureSupport,
|
|
148
|
+
isUniversal: agent.isUniversal,
|
|
149
|
+
parentCompany: agent.parentCompany,
|
|
150
|
+
detected: hasPresence,
|
|
151
|
+
isDefault,
|
|
152
|
+
localSkillCount,
|
|
153
|
+
globalSkillCount,
|
|
154
|
+
resolvedLocalDir,
|
|
155
|
+
resolvedGlobalDir,
|
|
156
|
+
lastSync,
|
|
157
|
+
health,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Sort: healthy + detected first, then by id.
|
|
161
|
+
entries.sort((a, b) => {
|
|
162
|
+
const aMissing = a.health === "missing";
|
|
163
|
+
const bMissing = b.health === "missing";
|
|
164
|
+
if (aMissing !== bMissing)
|
|
165
|
+
return aMissing ? 1 : -1;
|
|
166
|
+
return a.id.localeCompare(b.id);
|
|
167
|
+
});
|
|
168
|
+
// Shared-folder grouping — normalize paths via resolve() and group agents
|
|
169
|
+
// whose resolvedGlobalDir maps to the same absolute path.
|
|
170
|
+
const sharedGroups = new Map();
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const key = resolve(entry.resolvedGlobalDir);
|
|
173
|
+
const list = sharedGroups.get(key) ?? [];
|
|
174
|
+
list.push(entry.id);
|
|
175
|
+
sharedGroups.set(key, list);
|
|
176
|
+
}
|
|
177
|
+
const sharedFolders = [];
|
|
178
|
+
for (const [path, consumers] of sharedGroups.entries()) {
|
|
179
|
+
if (consumers.length >= 2) {
|
|
180
|
+
sharedFolders.push({ path, consumers: consumers.sort() });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Suggested: claude-code if detected; else alphabetically-first detected;
|
|
184
|
+
// else claude-code as fallback (consistent with buildInstalledAgentsResponse).
|
|
185
|
+
let suggested = "claude-code";
|
|
186
|
+
if (!entries.some((e) => e.id === "claude-code")) {
|
|
187
|
+
suggested = entries[0]?.id ?? "claude-code";
|
|
188
|
+
}
|
|
189
|
+
const data = { agents: entries, suggested, sharedFolders };
|
|
190
|
+
agentPresenceCache = { data, ts: now, ...cacheKey };
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
function firstNonTildeSegment(p) {
|
|
194
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
195
|
+
return p.slice(2);
|
|
196
|
+
if (p.startsWith("~"))
|
|
197
|
+
return p.slice(1);
|
|
198
|
+
return p;
|
|
199
|
+
}
|
|
200
|
+
/** Read the lockfile and return the newest `updatedAt` across entries owned by
|
|
201
|
+
* `agentId`. Returns null if the lockfile is missing or has no matching entry. */
|
|
202
|
+
function resolveAgentLastSync(root, _agentId) {
|
|
203
|
+
try {
|
|
204
|
+
const lock = readLockfile(root);
|
|
205
|
+
if (!lock?.skills)
|
|
206
|
+
return null;
|
|
207
|
+
let newest = null;
|
|
208
|
+
for (const entry of Object.values(lock.skills)) {
|
|
209
|
+
const updatedAt = entry.updatedAt;
|
|
210
|
+
if (typeof updatedAt === "string" && (!newest || updatedAt > newest)) {
|
|
211
|
+
newest = updatedAt;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return newest;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function computeAgentHealth(lastSync, totalSkills) {
|
|
221
|
+
if (totalSkills === 0 && !lastSync)
|
|
222
|
+
return "missing";
|
|
223
|
+
if (!lastSync)
|
|
224
|
+
return "ok";
|
|
225
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
226
|
+
const age = Date.now() - new Date(lastSync).getTime();
|
|
227
|
+
return age > SEVEN_DAYS_MS ? "stale" : "ok";
|
|
228
|
+
}
|
|
229
|
+
export function filterSkillsByScopeAndAgent(skills, filter) {
|
|
230
|
+
let out = skills;
|
|
231
|
+
if (filter.scope !== undefined) {
|
|
232
|
+
const allowed = ["own", "installed", "global"];
|
|
233
|
+
if (!allowed.includes(filter.scope))
|
|
234
|
+
return [];
|
|
235
|
+
out = out.filter((s) => (s.scope ?? "own") === filter.scope);
|
|
236
|
+
}
|
|
237
|
+
if (filter.agent !== undefined) {
|
|
238
|
+
const agent = filter.agent;
|
|
239
|
+
out = out.filter((s) => {
|
|
240
|
+
const scope = s.scope ?? "own";
|
|
241
|
+
if (scope === "own")
|
|
242
|
+
return true;
|
|
243
|
+
return s.sourceAgent === agent;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
54
248
|
// ---------------------------------------------------------------------------
|
|
55
249
|
// Shared helpers
|
|
56
250
|
// ---------------------------------------------------------------------------
|
|
@@ -319,6 +513,10 @@ const PROVIDER_MODELS = {
|
|
|
319
513
|
const PROBE_CACHE_TTL = 30_000; // re-probe every 30s
|
|
320
514
|
let ollamaCache = null;
|
|
321
515
|
let lmStudioCache = null;
|
|
516
|
+
export const OPENROUTER_CACHE = new Map();
|
|
517
|
+
export function resetOpenRouterCache() {
|
|
518
|
+
OPENROUTER_CACHE.clear();
|
|
519
|
+
}
|
|
322
520
|
/** Test hook: clear all probe caches so the next detectAvailableProviders() re-probes. */
|
|
323
521
|
export function resetDetectionCache() {
|
|
324
522
|
ollamaCache = null;
|
|
@@ -332,7 +530,7 @@ async function probeOllama() {
|
|
|
332
530
|
let models = PROVIDER_MODELS["ollama"];
|
|
333
531
|
let available = false;
|
|
334
532
|
try {
|
|
335
|
-
const baseUrl = process.env
|
|
533
|
+
const baseUrl = resolveOllamaBaseUrl(process.env);
|
|
336
534
|
const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(500) });
|
|
337
535
|
if (resp.ok) {
|
|
338
536
|
available = true;
|
|
@@ -374,27 +572,83 @@ async function probeLmStudio() {
|
|
|
374
572
|
lmStudioCache = { available, models, ts: now };
|
|
375
573
|
return lmStudioCache;
|
|
376
574
|
}
|
|
575
|
+
const DETECTION_WRAPPER_FOLDERS = [
|
|
576
|
+
".claude",
|
|
577
|
+
".cursor",
|
|
578
|
+
".codex",
|
|
579
|
+
".gemini",
|
|
580
|
+
".github",
|
|
581
|
+
".zed",
|
|
582
|
+
".specweave",
|
|
583
|
+
];
|
|
584
|
+
const DETECTION_BINARIES = ["claude", "cursor", "codex", "gemini"];
|
|
585
|
+
let detectionCache = null;
|
|
586
|
+
const DETECTION_CACHE_TTL = 30_000;
|
|
587
|
+
export function resetProjectDetectionCache() {
|
|
588
|
+
detectionCache = null;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Scan the project root for known agent wrapper folders and the system
|
|
592
|
+
* PATH for known agent binaries. Cheap synchronous scan (`existsSync` +
|
|
593
|
+
* `which`) cached for 30 s so repeated `/api/config` polls don't burn CPU.
|
|
594
|
+
*/
|
|
595
|
+
export function detectProjectAgents(root) {
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
if (detectionCache && now - detectionCache.ts < DETECTION_CACHE_TTL) {
|
|
598
|
+
return detectionCache.data;
|
|
599
|
+
}
|
|
600
|
+
const wrapperFolders = {};
|
|
601
|
+
for (const folder of DETECTION_WRAPPER_FOLDERS) {
|
|
602
|
+
try {
|
|
603
|
+
wrapperFolders[folder] = existsSync(join(root, folder));
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
wrapperFolders[folder] = false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const binaries = {};
|
|
610
|
+
for (const bin of DETECTION_BINARIES) {
|
|
611
|
+
binaries[bin] = isBinaryOnPath(bin);
|
|
612
|
+
}
|
|
613
|
+
const data = { wrapperFolders, binaries };
|
|
614
|
+
detectionCache = { data, ts: now };
|
|
615
|
+
return data;
|
|
616
|
+
}
|
|
617
|
+
function isBinaryOnPath(name) {
|
|
618
|
+
try {
|
|
619
|
+
const cmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
|
|
620
|
+
execSync(cmd, { stdio: "ignore", timeout: 1000 });
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
377
627
|
export async function detectAvailableProviders() {
|
|
378
628
|
const providers = [];
|
|
379
|
-
// Claude CLI —
|
|
629
|
+
// Claude CLI — delegates to the `claude` binary; the CLI owns session auth.
|
|
630
|
+
// See src/eval/llm.ts createClaudeCliClient compliance doc-block.
|
|
380
631
|
providers.push({
|
|
381
632
|
id: "claude-cli",
|
|
382
|
-
label: "Claude
|
|
633
|
+
label: "Use current Claude Code session",
|
|
383
634
|
available: true,
|
|
384
635
|
models: PROVIDER_MODELS["claude-cli"],
|
|
385
636
|
});
|
|
386
|
-
// Anthropic API — available if ANTHROPIC_API_KEY is set
|
|
637
|
+
// Anthropic API — available if ANTHROPIC_API_KEY is set OR a key is in the
|
|
638
|
+
// settings-store (browser tier or Darwin keychain).
|
|
387
639
|
providers.push({
|
|
388
640
|
id: "anthropic",
|
|
389
|
-
label: "Anthropic API
|
|
390
|
-
available: !!process.env.ANTHROPIC_API_KEY
|
|
641
|
+
label: "Anthropic API",
|
|
642
|
+
available: !!process.env.ANTHROPIC_API_KEY ||
|
|
643
|
+
settingsStore.hasKeySync("anthropic"),
|
|
391
644
|
models: PROVIDER_MODELS["anthropic"],
|
|
392
645
|
});
|
|
393
|
-
// OpenRouter — available if OPENROUTER_API_KEY is set
|
|
646
|
+
// OpenRouter — available if OPENROUTER_API_KEY is set OR a key is stored.
|
|
394
647
|
providers.push({
|
|
395
648
|
id: "openrouter",
|
|
396
|
-
label: "OpenRouter
|
|
397
|
-
available: !!process.env.OPENROUTER_API_KEY
|
|
649
|
+
label: "OpenRouter",
|
|
650
|
+
available: !!process.env.OPENROUTER_API_KEY ||
|
|
651
|
+
settingsStore.hasKeySync("openrouter"),
|
|
398
652
|
models: PROVIDER_MODELS["openrouter"],
|
|
399
653
|
});
|
|
400
654
|
// Local providers (Ollama + LM Studio) — cached probes fired in parallel so
|
|
@@ -429,6 +683,20 @@ export function registerRoutes(router, root, projectName) {
|
|
|
429
683
|
sendJson(res, { error: err.message }, 500, _req);
|
|
430
684
|
}
|
|
431
685
|
});
|
|
686
|
+
// 0686 — /api/agents: agents with filesystem presence + per-agent counts +
|
|
687
|
+
// shared-folder grouping. 30s detection cache (mirrors Ollama/LM Studio
|
|
688
|
+
// probe pattern from 0677).
|
|
689
|
+
router.get("/api/agents", async (req, res) => {
|
|
690
|
+
try {
|
|
691
|
+
const detected = await detectInstalledAgents();
|
|
692
|
+
const detectedBinaries = new Set(detected.map((a) => a.id));
|
|
693
|
+
const data = await buildAgentsResponse({ root, detectedBinaries });
|
|
694
|
+
sendJson(res, data, 200, req);
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
sendJson(res, { error: err.message }, 500, req);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
432
700
|
// Server-Sent Events endpoint for data change notifications
|
|
433
701
|
// Clients subscribe here to receive push updates when benchmarks complete,
|
|
434
702
|
// history is written, or leaderboard is updated.
|
|
@@ -454,19 +722,41 @@ export function registerRoutes(router, root, projectName) {
|
|
|
454
722
|
req.on("close", cleanup);
|
|
455
723
|
req.on("aborted", cleanup);
|
|
456
724
|
});
|
|
457
|
-
// OpenRouter model search proxy
|
|
725
|
+
// OpenRouter model search proxy — 10-minute in-memory cache keyed by the
|
|
726
|
+
// last-8 chars of the API key so different keys don't collide while the
|
|
727
|
+
// key itself is never stored in the cache map. Stale cache served (with
|
|
728
|
+
// X-Vskill-Catalog-Age header) when upstream is down.
|
|
458
729
|
router.get("/api/openrouter/models", async (_req, res) => {
|
|
459
|
-
const
|
|
730
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
731
|
+
const storedKey = settingsStore.readKeySync("openrouter");
|
|
732
|
+
const apiKey = envKey || storedKey;
|
|
460
733
|
if (!apiKey) {
|
|
461
734
|
sendJson(res, { error: "OPENROUTER_API_KEY not configured" }, 400);
|
|
462
735
|
return;
|
|
463
736
|
}
|
|
737
|
+
const cacheKey = apiKey.slice(-8);
|
|
738
|
+
const now = Date.now();
|
|
739
|
+
const cached = OPENROUTER_CACHE.get(cacheKey);
|
|
740
|
+
const CACHE_TTL_MS = 600_000; // 10 min
|
|
741
|
+
// Fresh cache hit — serve immediately without upstream.
|
|
742
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
743
|
+
const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
|
|
744
|
+
res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
|
|
745
|
+
sendJson(res, { models: cached.value, ageSec });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
464
748
|
try {
|
|
465
749
|
const resp = await fetch("https://openrouter.ai/api/v1/models", {
|
|
466
750
|
headers: { "Authorization": `Bearer ${apiKey}` },
|
|
467
751
|
signal: AbortSignal.timeout(10_000),
|
|
468
752
|
});
|
|
469
753
|
if (!resp.ok) {
|
|
754
|
+
if (cached) {
|
|
755
|
+
const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
|
|
756
|
+
res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
|
|
757
|
+
sendJson(res, { models: cached.value, ageSec, stale: true });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
470
760
|
sendJson(res, { error: `OpenRouter API returned ${resp.status}` }, 502);
|
|
471
761
|
return;
|
|
472
762
|
}
|
|
@@ -474,40 +764,122 @@ export function registerRoutes(router, root, projectName) {
|
|
|
474
764
|
const models = (data.data || []).map((m) => ({
|
|
475
765
|
id: m.id,
|
|
476
766
|
name: m.name || m.id,
|
|
767
|
+
contextWindow: typeof m.context_length === "number" ? m.context_length : undefined,
|
|
477
768
|
pricing: {
|
|
478
769
|
prompt: parseFloat(m.pricing?.prompt || "0"),
|
|
479
770
|
completion: parseFloat(m.pricing?.completion || "0"),
|
|
480
771
|
},
|
|
481
772
|
}));
|
|
482
|
-
|
|
773
|
+
OPENROUTER_CACHE.set(cacheKey, { value: models, fetchedAt: now });
|
|
774
|
+
res.setHeader?.("X-Vskill-Catalog-Age", "0");
|
|
775
|
+
sendJson(res, { models, ageSec: 0 });
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
if (cached) {
|
|
779
|
+
const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
|
|
780
|
+
res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
|
|
781
|
+
sendJson(res, { models: cached.value, ageSec, stale: true });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
sendJson(res, { error: err.message }, 500);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
// Settings / API key endpoints (0682 — US-004).
|
|
788
|
+
// Keys live on-device only. Never logged, never synced, never returned
|
|
789
|
+
// through GET. Response includes only metadata (stored, updatedAt, tier).
|
|
790
|
+
router.get("/api/settings/keys", async (_req, res) => {
|
|
791
|
+
sendJson(res, settingsStore.listKeys());
|
|
792
|
+
});
|
|
793
|
+
router.post("/api/settings/keys", async (req, res) => {
|
|
794
|
+
// Reject any request that smuggles the key in a query-string — JSON body only.
|
|
795
|
+
const url = req.url || "";
|
|
796
|
+
if (/[?&]key=/.test(url)) {
|
|
797
|
+
sendJson(res, { error: "key must not appear in query string" }, 400);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const body = (await readBody(req));
|
|
801
|
+
if (!body.key || typeof body.key !== "string" || body.key.trim().length === 0) {
|
|
802
|
+
sendJson(res, { error: "key must be non-empty string" }, 400);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (body.provider !== "anthropic" && body.provider !== "openrouter") {
|
|
806
|
+
sendJson(res, { error: `unknown provider: ${String(body.provider)}` }, 400);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
const saved = await settingsStore.saveKey(body.provider, body.key.trim(), body.tier ?? "browser");
|
|
811
|
+
// Prefix hint — non-blocking, purely informational
|
|
812
|
+
let warning;
|
|
813
|
+
if (body.provider === "anthropic" && !body.key.startsWith("sk-ant-")) {
|
|
814
|
+
warning = "key doesn't match typical Anthropic prefix sk-ant-";
|
|
815
|
+
}
|
|
816
|
+
else if (body.provider === "openrouter" && !body.key.startsWith("sk-or-")) {
|
|
817
|
+
warning = "key doesn't match typical OpenRouter prefix sk-or-";
|
|
818
|
+
}
|
|
819
|
+
sendJson(res, {
|
|
820
|
+
ok: true,
|
|
821
|
+
updatedAt: saved.updatedAt,
|
|
822
|
+
tier: saved.tier,
|
|
823
|
+
available: true,
|
|
824
|
+
...(warning ? { warning } : {}),
|
|
825
|
+
});
|
|
483
826
|
}
|
|
484
827
|
catch (err) {
|
|
485
828
|
sendJson(res, { error: err.message }, 500);
|
|
486
829
|
}
|
|
487
830
|
});
|
|
831
|
+
router.delete("/api/settings/keys/:provider", async (req, res) => {
|
|
832
|
+
const provider = req.params?.provider;
|
|
833
|
+
if (provider !== "anthropic" && provider !== "openrouter") {
|
|
834
|
+
sendJson(res, { error: `unknown provider: ${String(provider)}` }, 400);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
await settingsStore.removeKey(provider);
|
|
838
|
+
sendJson(res, { ok: true });
|
|
839
|
+
});
|
|
488
840
|
// Config — expose current provider/model + available providers + project
|
|
489
841
|
// IMPORTANT: Return raw model IDs (e.g. "sonnet"), NOT display models
|
|
490
842
|
// (e.g. "claude-sonnet"). The frontend round-trips config.model back to
|
|
491
843
|
// generate-evals and other endpoints, so it must be a valid CLI model ID.
|
|
492
844
|
router.get("/api/config", async (_req, res) => {
|
|
845
|
+
// On first load (no currentOverrides), try to restore from .vskill/studio.json.
|
|
846
|
+
if (!currentOverrides.provider) {
|
|
847
|
+
const stored = loadStudioSelection(root);
|
|
848
|
+
if (stored) {
|
|
849
|
+
currentOverrides.provider = stored.activeAgent;
|
|
850
|
+
if (stored.activeModel)
|
|
851
|
+
currentOverrides.model = stored.activeModel;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
493
854
|
try {
|
|
494
855
|
// Validate the client can be created (catches missing API keys etc.)
|
|
495
856
|
getClient();
|
|
496
857
|
const providers = await detectAvailableProviders();
|
|
858
|
+
const detection = detectProjectAgents(root);
|
|
497
859
|
sendJson(res, {
|
|
498
860
|
provider: currentOverrides.provider || null,
|
|
499
861
|
model: getEffectiveRawModel(),
|
|
500
862
|
providers,
|
|
863
|
+
detection,
|
|
501
864
|
projectName: projectName || null,
|
|
502
865
|
root,
|
|
503
866
|
});
|
|
504
867
|
}
|
|
505
868
|
catch (err) {
|
|
506
869
|
const providers = await detectAvailableProviders().catch(() => []);
|
|
507
|
-
|
|
870
|
+
const detection = detectProjectAgents(root);
|
|
871
|
+
sendJson(res, {
|
|
872
|
+
provider: null,
|
|
873
|
+
model: "unknown",
|
|
874
|
+
error: err.message,
|
|
875
|
+
providers,
|
|
876
|
+
detection,
|
|
877
|
+
projectName: projectName || null,
|
|
878
|
+
root,
|
|
879
|
+
});
|
|
508
880
|
}
|
|
509
881
|
});
|
|
510
|
-
// Update config — change provider/model at runtime
|
|
882
|
+
// Update config — change provider/model at runtime and persist atomically.
|
|
511
883
|
router.post("/api/config", async (req, res) => {
|
|
512
884
|
const body = (await readBody(req));
|
|
513
885
|
if (body.provider)
|
|
@@ -521,6 +893,21 @@ export function registerRoutes(router, root, projectName) {
|
|
|
521
893
|
// Validate the client can be created
|
|
522
894
|
getClient();
|
|
523
895
|
const providers = await detectAvailableProviders();
|
|
896
|
+
// Persist to .vskill/studio.json (atomic tmp-then-rename). Fire-and-forget
|
|
897
|
+
// from the handler's perspective — errors are logged but not surfaced,
|
|
898
|
+
// matching how currentOverrides already survives process lifetime.
|
|
899
|
+
if (currentOverrides.provider) {
|
|
900
|
+
try {
|
|
901
|
+
await saveStudioSelection(root, {
|
|
902
|
+
activeAgent: currentOverrides.provider,
|
|
903
|
+
activeModel: getEffectiveRawModel(),
|
|
904
|
+
updatedAt: new Date().toISOString(),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
catch (e) {
|
|
908
|
+
console.warn(`[studio.json] atomic write failed: ${e.message}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
524
911
|
sendJson(res, { provider: currentOverrides.provider || null, model: getEffectiveRawModel(), providers });
|
|
525
912
|
}
|
|
526
913
|
catch (err) {
|
|
@@ -539,7 +926,33 @@ export function registerRoutes(router, root, projectName) {
|
|
|
539
926
|
// unknown/missing fields default to `null` (not undefined) so the shape
|
|
540
927
|
// remains JSON-stable for all consumers.
|
|
541
928
|
router.get("/api/skills", async (req, res) => {
|
|
542
|
-
|
|
929
|
+
// 0686: ?scope=own|installed|global and ?agent=<id> query params.
|
|
930
|
+
// When either is present, switch to tri-scope scanning so the response
|
|
931
|
+
// carries the new `scope`/`isSymlink`/`symlinkTarget`/`installMethod`/
|
|
932
|
+
// `sourceAgent` fields. With no filter, we stay on the legacy two-scope
|
|
933
|
+
// path AND still layer the tri-scope enrichment on top so the UI gets a
|
|
934
|
+
// consistent shape either way.
|
|
935
|
+
const url = new URL(req.url ?? "/api/skills", "http://localhost");
|
|
936
|
+
const rawScope = url.searchParams.get("scope") ?? undefined;
|
|
937
|
+
const rawAgent = url.searchParams.get("agent") ?? undefined;
|
|
938
|
+
// Determine which agent's global scope to scan. When the caller doesn't
|
|
939
|
+
// specify one, default to the suggested agent from buildAgentsResponse —
|
|
940
|
+
// that's usually claude-code but falls back to the first detected agent.
|
|
941
|
+
let activeAgent = rawAgent;
|
|
942
|
+
if (!activeAgent) {
|
|
943
|
+
try {
|
|
944
|
+
const detected = await detectInstalledAgents();
|
|
945
|
+
const resp = await buildAgentsResponse({
|
|
946
|
+
root,
|
|
947
|
+
detectedBinaries: new Set(detected.map((a) => a.id)),
|
|
948
|
+
});
|
|
949
|
+
activeAgent = resp.suggested;
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
activeAgent = "claude-code";
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const skills = await scanSkillsTriScope(root, { agentId: activeAgent });
|
|
543
956
|
const enriched = await Promise.all(skills.map(async (s) => {
|
|
544
957
|
let evalCount = 0;
|
|
545
958
|
let assertionCount = 0;
|
|
@@ -553,12 +966,17 @@ export function registerRoutes(router, root, projectName) {
|
|
|
553
966
|
catch { /* no evals */ }
|
|
554
967
|
const benchmark = await readBenchmark(s.dir);
|
|
555
968
|
const meta = buildSkillMetadata(s.dir, s.origin, root);
|
|
556
|
-
// Defensive origin guarantee — scanSkills sets it, but fall back to a
|
|
557
|
-
// recomputation if anything downstream ever widens the type.
|
|
558
969
|
const origin = s.origin ?? classifyOrigin(s.dir, root);
|
|
970
|
+
// Preserve scanner-derived sourceAgent (populated for installed + global
|
|
971
|
+
// scopes) over the metadata-derived one which only covers local wrappers.
|
|
972
|
+
const sourceAgent = s.sourceAgent ?? meta.sourceAgent;
|
|
559
973
|
return {
|
|
560
974
|
...s,
|
|
561
975
|
origin,
|
|
976
|
+
scope: s.scope ?? (origin === "installed" ? "installed" : "own"),
|
|
977
|
+
isSymlink: s.isSymlink ?? false,
|
|
978
|
+
symlinkTarget: s.symlinkTarget ?? null,
|
|
979
|
+
installMethod: s.installMethod ?? (origin === "installed" ? "copied" : "authored"),
|
|
562
980
|
evalCount,
|
|
563
981
|
assertionCount,
|
|
564
982
|
benchmarkStatus: computeBenchmarkStatus(benchmark, evalIds, s.hasEvals),
|
|
@@ -576,10 +994,14 @@ export function registerRoutes(router, root, projectName) {
|
|
|
576
994
|
entryPoint: meta.entryPoint,
|
|
577
995
|
lastModified: meta.lastModified,
|
|
578
996
|
sizeBytes: meta.sizeBytes,
|
|
579
|
-
sourceAgent
|
|
997
|
+
sourceAgent,
|
|
580
998
|
};
|
|
581
999
|
}));
|
|
582
|
-
|
|
1000
|
+
const filtered = filterSkillsByScopeAndAgent(enriched, {
|
|
1001
|
+
scope: rawScope,
|
|
1002
|
+
agent: rawAgent,
|
|
1003
|
+
});
|
|
1004
|
+
sendJson(res, filtered, 200, req);
|
|
583
1005
|
});
|
|
584
1006
|
// Check for skill updates via `vskill outdated --json`
|
|
585
1007
|
router.get("/api/skills/updates", async (req, res) => {
|