openvibers/openvibe.exchange Commits
Exchange: the Code page, its public API and the deep dive's repository source (#232)
Changes
@@@ -35,6 +35,15 @@ OV_OAUTH_CLIENT_SECRET=#OV_AI_URL=https://ai.openvibe.services#OV_AI_AUDIENCE=openvibe.ai#OV_AI_SCOPE=ai.run.create++# OpenVibe.Sources: the github source's repository rankings and its live GitHub search (category code).++# Exchange's own client-credentials grant (sources.item.read) reads it over loopback; nothing here calls GitHub.++#OV_SOURCES_URL=http://127.0.0.1:4720++#OV_SOURCES_AUDIENCE=openvibe.sources++#OV_SOURCES_SCOPE=sources.item.read++#EXCHANGE_CODE_REPOS_CACHE_MS=60000++#EXCHANGE_CODE_SEARCH_CACHE_MS=600000++#EXCHANGE_CODE_SEARCH_PER_MIN=20++# The owner console uses this Exchange service grant to read/write AI's admin JSON API.# Network grants these capabilities to the existing `exchange` client; keep this scope to the needed AI capabilities.#OV_AI_CONSOLE_SCOPE=ai.usage.read ai.workflow.manage ai.provider.manage@@@ -30,6 +30,7 @@ for a person — and keeps only two things of its own: a person's chats and thei| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. || `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. || `/status` | Uptime per offer, the model changelog and incidents. |++| `/code` | GitHub repositories through OpenVibe.Sources: a deep repository search, Trending today / This week / Hot / New / Top, twelve language chips and one card per repository (stars, the week's gain, forks, language, topics, updated). `/api/v1/code/repos` and `/api/v1/code/search` are its public JSON. |The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.@@@ -48,6 +49,9 @@ signed-in person is `private, no-store`; a public page is `public, max-age=60`.turn or Try it, on the person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).- `GET .../api/v1/community/offers` — the live community offers; a pool says how many machines serve it (neverwhich ones), so the offer card can say "Community pool (N machines)" without naming a machine.++- `GET http://127.0.0.1:4720/api/v1/code/repos` and `/api/v1/code/search` — the GitHub repositories++ OpenVibe.Sources keeps (the `github` source, category `code`) and its live GitHub search, with this++ service's `sources.item.read` grant. Exchange never calls GitHub itself.- `GET https://openvibe.network/api/v1/machines` and `/api/v1/machine-pools` — the person's own machines, theirpools and the machines they can pool, with the person's Network token: names, free VRAM and RAM, the localnetwork key, and each member's approval and readiness. `POST /api/v1/machine-pools` proposes a pool and@@@ -750,3 +750,77 @@ label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-o.dd-go-note { align-items: flex-start; }.dd-go-note svg { margin-top: 3px; }.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }++++/* ── Code: GitHub repositories ─────────────────────────── */++.cd { --cd-radius: 16px; }++.cd-find { margin: 8px 0 14px; }++.cd-find .ovk-find { max-width: 680px; }++.cd-hint { margin: 8px 0 0; font-size: .82rem; line-height: 1.5; color: var(--ovk-muted); overflow-wrap: anywhere; }++.cd-hint code { font-size: .92em; }++.cd-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }++.cd-tab {++ display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 13px;++ border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel);++ color: var(--ovk-text); font-size: .86rem; font-weight: 600; text-decoration: none; white-space: nowrap;++}++.cd-tab svg { width: 15px; height: 15px; }++.cd-tab:hover { border-color: var(--ovk-accent); }++.cd-tab.is-on { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 12%, var(--ovk-panel)); color: var(--ovk-accent); }++.cd-langs { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; }++.cd-chip {++ display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px;++ border: 1px solid var(--ovk-line); border-radius: 999px; background: transparent;++ color: var(--ovk-muted); font-size: .78rem; text-decoration: none; white-space: nowrap;++}++.cd-chip:hover { border-color: var(--ovk-accent); color: var(--ovk-text); }++.cd-chip.is-on { border-color: var(--ovk-accent); color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 10%, transparent); }++.cd-results-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px; margin: 0 0 12px; }++.cd-results-head h2 { margin: 0; font-size: 1.1rem; }++.cd-count { color: var(--ovk-muted); font-size: .82rem; font-variant-numeric: tabular-nums; }++.cd-clear { margin-left: auto; color: var(--ovk-accent); font-size: .84rem; }++.cd-grid {++ list-style: none; margin: 0; padding: 0;++ display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px;++}++@media (min-width: 720px) { .cd-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }++@media (min-width: 1100px) { .cd-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }++.cd-card {++ min-width: 0; display: flex; flex-direction: column; gap: 10px;++ padding: 14px; border: 1px solid var(--ovk-line); border-radius: var(--cd-radius); background: var(--ovk-panel);++}++.cd-card:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }++.cd-card-head { display: flex; align-items: center; gap: 10px; min-width: 0; }++.cd-avatar { flex: none; width: 40px; height: 40px; border-radius: 12px; object-fit: cover; background: var(--ovk-panel-2); }++.cd-avatar--letter { display: inline-grid; place-items: center; color: var(--ovk-accent); font-weight: 800; font-size: 1.05rem; }++.cd-card-id { min-width: 0; display: flex; flex-direction: column; gap: 1px; }++.cd-name {++ display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;++ color: var(--ovk-text); font-weight: 700; font-size: .95rem; text-decoration: none;++}++.cd-name:hover { color: var(--ovk-accent); text-decoration: underline; }++.cd-owner { color: var(--ovk-muted); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.cd-desc {++ margin: 0; min-height: 2.7em; color: var(--ovk-text); font-size: .86rem; line-height: 1.45;++ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere;++}++.cd-muted { color: var(--ovk-muted); }++.cd-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 0; font-size: .8rem; color: var(--ovk-muted); }++.cd-stat { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }++.cd-stat svg { width: 13px; height: 13px; flex: none; }++.cd-stat b { color: var(--ovk-text); font-variant-numeric: tabular-nums; }++.cd-gain { color: var(--ovk-ok); font-weight: 600; white-space: nowrap; }++.cd-lang { display: inline-flex; align-items: center; gap: 5px; color: var(--ovk-text); min-width: 0; }++.cd-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--cd-lang, var(--ovk-accent)); flex: none; }++.cd-topics { list-style: none; display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; }++.cd-topics li {++ max-width: 100%; padding: 2px 8px; border-radius: 999px; background: var(--ovk-panel-2);++ color: var(--ovk-muted); font-size: .7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;++}++.cd-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: auto; font-size: .75rem; color: var(--ovk-muted); }++.cd-open { margin-left: auto; color: var(--ovk-accent); font-weight: 600; text-decoration: none; white-space: nowrap; }++.cd-open:hover { text-decoration: underline; }++@media (max-width: 420px) {++ .cd-card { padding: 12px; }++ .cd-tab { min-height: 36px; padding: 0 11px; font-size: .82rem; }++ .cd-chip { min-height: 28px; font-size: .75rem; }++}@@@ -33,6 +33,8 @@ const { createNetwork } = require('./network');const { createBench } = require('./bench');const { createCompare } = require('./compare');const { createOpenApi } = require('./openapi');++const { createCode } = require('./code');++const { createCodeRoutes } = require('./http/code');const { createArenaStore } = require('./arena/store');const { createCatalogHistory } = require('./catalog-history');const { createChatStore, createAdultStore } = require('./chat/store');@@@ -123,7 +125,12 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });-- const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };++ // GitHub repositories through Sources. A test or a preview may hand its own client; nothing else reads++ // GitHub directly.++ const code = opts.code || createCode({++ config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,++ });++ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };const app = express();app.disable('x-powered-by');@@@ -147,7 +154,7 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'none'"],@@@ -331,6 +338,7 @@ async function createApp(opts = {}) {app.use(createCompareSite(ctx));// ── Exchange v1's JSON API: gone ────────────────────────++ app.use(createCodeRoutes(ctx));app.use('/api/v1', (req, res) => {res.set('Cache-Control', 'public, max-age=86400');return contracts.http.sendProblem(res, 410, 'exchange.retired', {nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * GitHub repositories, read from OpenVibe.Sources (server/code.js's sibling there): the `github` source in++ * category `code` keeps the rankings and star history; Sources fetches GitHub with its own token, and++ * Exchange never calls GitHub itself. This client uses Exchange's client-credentials grant++ * (`sources.item.read`) over loopback and caches both reads: the rankings 60 seconds, a live search ten++ * minutes (GitHub's search limit is the scarce resource).++ *++ * A list is what Sources last fetched; a search is a GitHub answer Sources just made. When Sources++ * cannot answer the pages say so: no repository is invented and no star count is guessed.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++class CodeUnavailable extends Error {++ constructor(detail = 'OpenVibe.Sources is not answering right now') {++ super(detail);++ this.code = 'code.unavailable';++ this.status = 503;++ }++}++++function createCode({ config, fetchImpl = globalThis.fetch, tokenFetch = globalThis.fetch, now = () => Date.now(), log = console }) {++ const tokens = config.oauth.clientSecret ? createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: config.sources.audience, scope: config.sources.scope, fetch: tokenFetch,++ }) : null;++ const lists = new Map();++ const searches = new Map();++ const bursts = new Map(); // actor → [request times in the current minute]++ let warnedAt = 0;++++ const warned = (what, err) => {++ if (now() - warnedAt < 60_000) return;++ warnedAt = now();++ log.warn(`[exchange] Sources ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);++ };++++ async function get(path) {++ if (!tokens) throw new CodeUnavailable('Exchange is not configured for OpenVibe.Sources');++ const token = await tokens.getToken();++ let res;++ try {++ res = await fetchImpl(`${config.sources.url}${path}`, {++ headers: { accept: 'application/json', authorization: `Bearer ${token}` },++ signal: AbortSignal.timeout(config.sources.timeoutMs),++ });++ } catch (err) {++ throw new CodeUnavailable('OpenVibe.Sources is unreachable right now');++ }++ const text = await res.text().catch(() => '');++ let body = null;++ try { body = text ? JSON.parse(text) : null; } catch { body = null; }++ if (!res.ok || !body) throw new CodeUnavailable(`OpenVibe.Sources answered ${res.status}`);++ return body;++ }++++ /** The cached rankings. */++ async function repos({ list = 'trending', lang = null, topic = null, limit = 30 } = {}) {++ const key = JSON.stringify([list, lang || '', topic || '', limit]);++ const hit = lists.get(key);++ if (hit && now() - hit.at < config.sources.reposCacheMs) return { ...hit.value, cached: true };++ try {++ const value = await get(`/api/v1/code/repos?list=${encodeURIComponent(list)}&limit=${encodeURIComponent(limit)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${topic ? `&topic=${encodeURIComponent(topic)}` : ''}`);++ lists.set(key, { at: now(), value });++ if (lists.size > 100) lists.delete(lists.keys().next().value);++ return { ...value, cached: false };++ } catch (err) {++ warned('code list', err);++ // A last copy for a minute is better than an empty page, marked stale so the page says the numbers++ // are the last read rather than live.++ if (hit && now() - hit.at < 5 * 60_000) return { ...hit.value, cached: true, stale: true };++ throw err;++ }++ }++++ /** A live repository search, cached ten minutes. */++ async function search({ q, lang = null, sort = 'best', page = 1 } = {}) {++ const key = JSON.stringify([q, lang || '', sort, page]);++ const hit = searches.get(key);++ if (hit && now() - hit.at < config.sources.searchCacheMs) return { ...hit.value, cached: true };++ try {++ const value = await get(`/api/v1/code/search?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&page=${encodeURIComponent(page)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}`);++ searches.set(key, { at: now(), value });++ if (searches.size > 200) searches.delete(searches.keys().next().value);++ return { ...value, cached: false };++ } catch (err) {++ warned('code search', err);++ if (hit && now() - hit.at < 60 * 60_000) return { ...hit.value, cached: true, stale: true };++ throw err;++ }++ }++++ /** Is this actor under the search limit? A person is one actor; a signed-out visitor is their address. */++ function allow(actor) {++ const id = String(actor || 'anonymous');++ const at = now();++ const recent = (bursts.get(id) || []).filter((t) => at - t < 60_000);++ if (recent.length >= config.sources.searchPerMinute) {++ bursts.set(id, recent);++ return { allowed: false, retryAfter: Math.max(1, Math.ceil((recent[0] + 60_000 - at) / 1000)) };++ }++ recent.push(at);++ bursts.set(id, recent);++ if (bursts.size > 5000) {++ for (const [k, v] of bursts) if (!v.length || at - v[v.length - 1] > 60_000) bursts.delete(k);++ }++ return { allowed: true };++ }++++ return { repos, search, allow, configured: Boolean(tokens) };++}++++module.exports = { createCode, CodeUnavailable };@@@ -76,6 +76,19 @@ function load(env = process.env) {waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),},++ // OpenVibe.Sources (server/code.js): the GitHub repositories it keeps (source `github`, category++ // `code`) for /code and the public code API. The token is Exchange's own client-credentials grant++ // (sources.item.read); the read is over loopback and the public API caches it.++ sources: {++ url: trim(env.OV_SOURCES_URL || 'http://127.0.0.1:4720'),++ audience: env.OV_SOURCES_AUDIENCE || 'openvibe.sources',++ scope: env.OV_SOURCES_SCOPE || 'sources.item.read',++ timeoutMs: Math.max(2000, int(env.EXCHANGE_SOURCES_TIMEOUT_MS, 10_000)),++ reposCacheMs: Math.max(10_000, int(env.EXCHANGE_CODE_REPOS_CACHE_MS, 60_000)),++ searchCacheMs: Math.max(60_000, int(env.EXCHANGE_CODE_SEARCH_CACHE_MS, 600_000)),++ searchPerMinute: Math.max(1, int(env.EXCHANGE_CODE_SEARCH_PER_MIN, 20)),++ },++// OpenVibe.Actor (server/actor.js): a companion or group turn is an Actor task of the signed-in// person — their own Bearer on the wire, their allowance. The wait bounds one page POST; a turn still// running then is reconciled on the next view.nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * /code and the public code API.++ *++ * GET /code?q=&list=trending|week|hot|new|top&lang=++ * The page: a deep search box, the rankings, twelve language chips and one card per repository.++ * GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=++ * GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=++ * Public, signed-out, cached. The search is cached ten minutes and rate-limited per actor (a person by++ * subject, a visitor by address); the rankings 60 seconds. Sources is the only upstream.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { http } = require('openvibe-contracts');++const views = require('../views/code');++const { raw } = require('../render/html');++const { send } = require('../render/layout');++++const LISTS = new Set(['trending', 'week', 'month', 'hot', 'new', 'top']);++const TAB_LISTS = new Set(['trending', 'week', 'hot', 'new', 'top']);++const SORTS = new Set(['stars', 'updated', 'best']);++const PUBLIC_HTML = cache.htmlHeaders({ maxAge: 60 });++const text = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);++++function createCodeRoutes({ config, code, log = console }) {++ const r = express.Router();++++ r.get('/code', async (req, res) => {++ const q = text(req.query.q, 200);++ const askedList = text(req.query.list, 20) || 'trending';++ const list = TAB_LISTS.has(askedList) ? askedList : 'trending';++ const lang = text(req.query.lang, 60);++ const now = Date.now();++ let repos = [];++ let total = null;++ let stale = false;++ let unavailable = false;++ let notice = '';++ try {++ if (q) {++ const gate = code.allow(req.viewer && req.viewer.subject ? req.viewer.subject : req.ip);++ if (!gate.allowed) {++ notice = `Too many searches from this account this minute. Try again in ${gate.retryAfter} seconds.`;++ } else {++ const out = await code.search({ q, lang, sort: 'best', page: 1 });++ repos = out.items || [];++ total = out.total == null ? null : Number(out.total);++ stale = Boolean(out.stale);++ }++ } else {++ const out = await code.repos({ list, lang, topic: null, limit: 24 });++ repos = out.items || [];++ stale = Boolean(out.stale);++ }++ } catch (err) {++ unavailable = true;++ log.warn(`[exchange] /code: ${String((err && err.message) || err).slice(0, 160)}`);++ }++ return send(res, 200, {++ viewer: req.viewer, config, path: req.originalUrl,++ title: q ? `Code: ${q}` : 'Code',++ description: 'GitHub repositories trending today, this week and hot by stars gained, with search and a language filter. Read through OpenVibe.Sources.',++ index: true, cache: PUBLIC_HTML, agentLayer: true,++ body: raw(views.codePage({ list, lang, q: q || '', repos, total, now, stale, unavailable, search: Boolean(q), notice })),++ });++ });++++ r.get('/api/v1/code/repos', async (req, res) => {++ const list = text(req.query.list, 20) || 'trending';++ if (!LISTS.has(list)) return http.sendProblem(res, 422, 'code.bad_query', { detail: `list must be one of ${[...LISTS].join('|')}`, ctx: req.ov });++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);++ try {++ const out = await code.repos({ list, lang: text(req.query.lang, 60), topic: text(req.query.topic, 50), limit });++ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=60');++ res.json(out);++ } catch (err) {++ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'The repository list is not available right now', ctx: req.ov });++ }++ });++++ r.get('/api/v1/code/search', async (req, res) => {++ const q = text(req.query.q, 300);++ if (!q) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'q is required', ctx: req.ov });++ const sort = text(req.query.sort, 10) || 'best';++ if (!SORTS.has(sort)) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'sort must be stars, updated or best', ctx: req.ov });++ const page = Math.min(Math.max(parseInt(req.query.page, 10) || 1, 1), 10);++ const actor = req.viewer && req.viewer.subject ? req.viewer.subject : req.ip;++ const gate = code.allow(actor);++ if (!gate.allowed) {++ res.set('Retry-After', String(gate.retryAfter));++ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'Too many repository searches this minute', ctx: req.ov });++ }++ try {++ const out = await code.search({ q, lang: text(req.query.lang, 60), sort, page });++ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=600');++ res.json(out);++ } catch (err) {++ if (err && err.code === 'code.rate_limited') {++ res.set('Retry-After', String(err.retryAfter || 60));++ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'GitHub asked us to wait before another search', ctx: req.ov });++ }++ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'GitHub search is not available right now', ctx: req.ov });++ }++ });++++ return r;++}++++module.exports = { createCodeRoutes, LISTS, SORTS };@@@ -27,6 +27,7 @@ const PUBLIC_PAGES = [{ path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },++ { path: '/code', changefreq: 'daily', priority: 0.8, title: 'Code', text: 'GitHub repositories trending today, new and hot by stars gained, plus a deep repository search: read through OpenVibe.Sources.' },{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },];@@@ -106,7 +107,7 @@ function createDiscoveryRoutes(ctx) {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,summary: DESCRIPTION,-- details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account.',++ details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account. GitHub repositories are read through OpenVibe.Sources and served at /api/v1/code/repos and /api/v1/code/search; nothing is invented when the source is behind.',markdown: true,base: site,sections: [@@@ -114,8 +115,10 @@ function createDiscoveryRoutes(ctx) {{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },++ { title: 'Code', url: abs('/code'), note: 'GitHub trending, hot and new repositories, and a deep search' },{ title: 'The API', url: abs('/api') },{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },++ { title: 'Code repositories API', url: abs('/api/v1/code/repos?list=week'), note: 'cached GitHub rankings from Sources; search at /api/v1/code/search?q=stars:>500' },{ title: 'Start building', url: abs('/start') },{ title: 'Why this model', url: abs('/why') },{ title: 'Questions', url: abs('/faq') },@@@ -26,9 +26,39 @@ function analysisPaths(config) {'/api/v1/analysis/compare': make('exchange.analysis.compare', ['models', 'measures', 'kind', 'day', 'rating_kind'].map(query)),'/api/v1/analysis/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),++ '/api/v1/code/repos': codeReposPath(query),++ '/api/v1/code/search': codeSearchPath(query),};}++++function codeReposPath(query) {++ return { get: {++ operationId: 'exchange.code.repos', summary: 'GitHub repositories trending, new, hot by stars gained, or most starred',++ security: [], servers: [{ url: 'https://openvibe.exchange' }],++ parameters: ['list', 'lang', 'topic', 'limit'].map(query),++ responses: {++ '200': { description: 'Cached repository rankings with stars, 1-day and 7-day gains, forks, language, topics and dates' },++ '422': { description: 'Invalid list or limit' },++ '503': { description: 'OpenVibe.Sources is unavailable' },++ },++ } };++}++++function codeSearchPath(query) {++ return { get: {++ operationId: 'exchange.code.search', summary: 'Search GitHub repositories with GitHub query syntax',++ security: [], servers: [{ url: 'https://openvibe.exchange' }],++ parameters: ['q', 'lang', 'sort', 'page'].map(query),++ responses: {++ '200': { description: 'Repository results in the same shape as /api/v1/code/repos, with total matches' },++ '422': { description: 'q, sort or page is invalid' },++ '429': { description: 'Too many searches this minute; Retry-After says when' },++ '503': { description: 'GitHub search through OpenVibe.Sources is unavailable' },++ },++ } };++}++function createOpenApi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {const ttl = config.api.openapiCacheMs;let at = 0;@@@ -28,6 +28,7 @@ const NAV = [{ label: 'Docs', href: '/docs', icon: 'fa-code' },{ label: 'Status', href: '/status', icon: 'fa-gauge' },{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },++ { label: 'Code', href: '/code', icon: 'fa-code', more: true },];const hashes = new Map();nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * /code: GitHub repositories through OpenVibe.Sources. A deep search box, the rankings as tabs, the twelve++ * languages as chips, and one card per repository: owner avatar, name, a two-line description, the language++ * with its dot, stars with the week's gain, forks, when it was last pushed, topics and a link to GitHub.++ *++ * The cards are a CSS grid, one column on a phone and three on a desktop; every name and description wraps++ * or is clamped, so no card pushes the page sideways at 320–430 px. The page is complete without JavaScript.++ */++const kit = require('openvibe-shared/kit');++const glyphs = require('openvibe-shared/glyphs');++const { esc } = require('../render/html');++++// The same twelve GitHub searches Sources keeps (server/adapters/github.js in OpenVibe.Sources). A chip++// that filters a list and a search alike; a language not here is still in a card's own language line.++const LANGUAGES = Object.freeze(['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin']);++const LANG_COLOR = {++ JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', Go: '#00ADD8', Rust: '#dea584', Java: '#b07219',++ 'C++': '#f34b7d', 'C#': '#178600', PHP: '#4F5D95', Ruby: '#701516', Swift: '#F05138', Kotlin: '#A97BFF',++};++const TABS = Object.freeze([++ { key: 'trending', label: 'Trending today', fa: 'fa-fire' },++ { key: 'week', label: 'This week', fa: 'fa-calendar' },++ { key: 'hot', label: 'Hot', fa: 'fa-arrow-trend-up' },++ { key: 'new', label: 'New', fa: 'fa-plus' },++ { key: 'top', label: 'Top', fa: 'fa-star' },++]);++++/** The glyph for a Font Awesome name, or nothing when the registry has none. */++const icon = (fa) => { const name = glyphs.fromFa(fa); return name ? glyphs.svg(name) : ''; };++const count = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString('en-US') : '—');++const when = (iso, now) => {++ const t = Date.parse(iso || '');++ if (!Number.isFinite(t)) return 'date not listed';++ const s = Math.max(0, Math.round((now - t) / 1000));++ if (s < 90) return 'just now';++ if (s < 3600) return `${Math.round(s / 60)} min ago`;++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;++ if (s < 30 * 86400) return `${Math.round(s / 86400)} d ago`;++ return new Date(t).toISOString().slice(0, 10);++};++++function avatar(repo) {++ const owner = String(repo.full_name || '').split('/')[0] || '?';++ if (repo.owner_avatar_url) return `<img class="cd-avatar" src="${esc(repo.owner_avatar_url)}" alt="" width="40" height="40" loading="lazy" decoding="async">`;++ return `<span class="cd-avatar cd-avatar--letter" aria-hidden="true">${esc(owner.slice(0, 1).toUpperCase())}</span>`;++}++++function card(repo, now) {++ const gain = Number.isFinite(Number(repo.stars_gained_7d)) && Number(repo.stars_gained_7d) > 0 ? ` <span class="cd-gain">+${count(repo.stars_gained_7d)} this week</span>` : '';++ const topics = (Array.isArray(repo.topics) ? repo.topics : []).slice(0, 5);++ const lang = repo.language || null;++ const dot = lang ? `<span class="cd-dot" style="--cd-lang:${esc(LANG_COLOR[lang] || 'var(--ovk-accent)')}" aria-hidden="true"></span>` : '';++ return `<li class="cd-card">++<header class="cd-card-head">${avatar(repo)}<div class="cd-card-id"><a class="cd-name" href="${esc(repo.url)}" rel="noopener noreferrer">${esc(repo.full_name)}</a><span class="cd-owner">${esc(String(repo.full_name || '').split('/')[0] || '')}</span></div></header>++<p class="cd-desc">${repo.description ? esc(repo.description) : '<span class="cd-muted">No description.</span>'}</p>++<p class="cd-stats"><span class="cd-stat" title="Stars">${icon('fa-star')}<b>${count(repo.stars)}</b>${gain}</span><span class="cd-stat" title="Forks">${icon('fa-code-branch') || ''}<b>${count(repo.forks)}</b> forks</span>${lang ? `<span class="cd-lang">${dot}${esc(lang)}</span>` : ''}</p>++${topics.length ? `<ul class="cd-topics" aria-label="Topics">${topics.map((t) => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}++<footer class="cd-foot"><span>Updated ${esc(when(repo.pushed, now))}</span><a class="cd-open" href="${esc(repo.url)}" rel="noopener noreferrer">GitHub →</a></footer>++</li>`;++}++++function cards(repos, now) {++ if (!repos.length) return kit.empty({ icon: 'code', title: 'No repositories here yet', text: 'The GitHub source is still filling the list, or this filter has no match. Try another language or list.' });++ return `<ul class="cd-grid">${repos.map((r) => card(r, now)).join('')}</ul>`;++}++++function tabLinks(list, lang, q) {++ return TABS.map((t) => {++ const href = `/code?list=${encodeURIComponent(t.key)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`;++ return `<a class="cd-tab${t.key === list ? ' is-on' : ''}" href="${href}"${t.key === list ? ' aria-current="page"' : ''}>${icon(t.fa)}${esc(t.label)}</a>`;++ }).join('');++}++++function languageLinks(list, lang, q) {++ const all = `/code?list=${encodeURIComponent(list)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;++ const chips = [`<a class="cd-chip${lang ? '' : ' is-on'}" href="${all}">All languages</a>`]++ .concat(LANGUAGES.map((l) => {++ const href = `/code?list=${encodeURIComponent(list)}&lang=${encodeURIComponent(l)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;++ return `<a class="cd-chip${l === lang ? ' is-on' : ''}" href="${href}">${esc(l)}</a>`;++ }));++ return `<nav class="cd-langs" aria-label="Language">${chips.join('')}</nav>`;++}++++/**++ * o: { list, lang, q, repos, total, now, stale, unavailable, search }++ */++function codePage({ list = 'trending', lang = null, q = '', repos = [], total = null, now = Date.now(), stale = false, unavailable = false, search = false, notice: extraNotice = '' }) {++ const notice = unavailable++ ? kit.notice({ kind: 'warn', text: 'The repository list is not reachable right now. It is read from OpenVibe.Sources; reload in a moment.' })++ : extraNotice ? kit.notice({ kind: 'info', text: extraNotice })++ : stale ? kit.notice({ kind: 'info', text: 'These are the last repositories read from OpenVibe.Sources; the live read did not answer just now.' }) : '';++ const title = search ? `Repositories for “${q}”` : (TABS.find((t) => t.key === list) || TABS[0]).label;++ const sub = search && total != null ? `${count(total)} GitHub matches · repositories only, no code copied` : 'Trending, new and most-starred repositories on GitHub, with the star gains that make them hot.';++ const results = `<section class="cd-results" aria-labelledby="cd-results-title"><div class="cd-results-head"><h2 id="cd-results-title">${search ? 'Search results' : esc(title)}</h2><span class="cd-count">${repos.length} shown</span>${search ? '<a class="cd-clear" href="/code">Clear search</a>' : ''}</div>${cards(repos, now)}</section>`;++ return `<div class="ex-wrap ex-page cd">++${kit.head({ title: 'Code', sub, actions: [kit.button({ href: '/api#code', label: 'Code API', kind: 'quiet', icon: 'code' })] })}++<section class="cd-find" aria-label="Search repositories">++${kit.find({ action: '/code', name: 'q', placeholder: 'Search repositories: stars:>500 topic:ai', value: q, icon: 'search', maxlength: 200, hidden: lang ? { lang } : {} })}++<p class="cd-hint">GitHub's own search syntax works: <code>stars:>500 topic:ai</code>, <code>language:rust</code>, <code>pushed:>2026-10-01</code>. Code is linked, never copied.</p>++</section>++<nav class="cd-tabs" aria-label="Repository list">${tabLinks(list, lang, q)}</nav>++${languageLinks(list, lang, q)}++${notice}++${results}++</div>`;++}++++module.exports = { codePage, LANGUAGES, TABS };@@@ -15,7 +15,7 @@ const kit = require('openvibe-shared/kit');const { esc, raw } = require('../render/html');const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };--const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };++const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub repositories through Exchange\'s Code search, and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };const price = (n) => `$${Number(n || 0).toFixed(2)}`;const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };@@@ -59,7 +59,7 @@ const chips = (words) => `<ul class="dd-chips">${words.map((w) => `<li>${esc(w)}function whatItDoes() {const step = (icon, title, text, extra = '') => `<li><span class="dd-what-ic" aria-hidden="true">${kit.icon(icon)}</span><div><strong>${title}</strong><p>${text}</p>${extra}</div></li>`;return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">--${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub, design write-ups, and our own rules register and component registry.')}++${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub through <a href="/code">Code</a>, design write-ups, and our own rules register and component registry.')}${step('check', 'Choose the sites worth a look.', 'A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).')}${step('image', 'Capture them properly.', '390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts:',`${chips(MEASURED)}<p class="dd-what-sub">Expert adds</p>${chips(EXPERT_ADDS)}`)}@@@ -70,6 +70,18 @@ ${step('book', 'Show the evidence.', 'Every claim names its site, capture and th<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });}++/**++ * Code and tools are evidence too: a deep dive about a library, a protocol or a tool should be able to cite++ * the repositories it found. The repositories are Sources' (Exchange never reads GitHub itself), and this++ * panel is the way from a deep dive into the same rankings and search the Code page uses.++ */++function codeSources() {++ return kit.section({ title: 'Code and tools', note: 'Repositories are cited like any other source: by their GitHub URL, with the date they were read.', body:++ `<p class="ovk-prose">A repository is evidence for how a tool is built and how active it is. The rankings and the search are read through <a href="/code">Code</a>: trending today, hot by stars gained, new and most starred, or GitHub's own query syntax.</p>`++ + kit.find({ action: '/code', name: 'q', placeholder: 'Find a repository: stars:>500 topic:ai', icon: 'search', maxlength: 200 })++ + `<p class="ovk-note">No repository code or README is copied; a card keeps what the repository states about itself and links to it.</p>` });++}++function homePage({ viewer, depths = [], capture = null, error = null }) {const signedIn = Boolean(viewer && viewer.kind === 'user');const cards = (depths && depths.length ? depths : [@@@ -87,7 +99,7 @@ ${depthCards(cards)}: signIn();return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`+ (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')-- + whatItDoes() + `</div>`;++ + whatItDoes() + codeSources() + `</div>`;}function choosing(row, id) {@@@ -223,6 +223,7 @@ ${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} mod<div class="ex-lines">${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', icon: 'swap', tone: 'accent' })}${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', icon: 'trophy', tone: 'accent' })}++${lineCard({ href: '/code', name: 'Code', text: 'GitHub repositories trending today, hot by stars gained, and a deep search with GitHub\'s own syntax.', icon: 'code', tone: 'accent' })}${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', icon: 'shield', tone: 'warn' })}${lineCard({ href: '/why', name: 'Why this model', text: 'Every answer carries its receipt: the objective, the candidates, their cost and latency, and why the others did not answer.', icon: 'help', tone: 'accent' })}${lineCard({ href: '/start', name: 'Start building', text: 'One key, one endpoint and the SDK you already use; four steps from a key to a receipt.', icon: 'code', tone: 'accent' })}@@@ -522,6 +523,13 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.noreturn `<div class="ex-wrap ex-page">${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}++${panel({ title: 'Code repositories', id: 'code', body: `++<p class="ex-prose"><code>GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=</code> is the cached GitHub repository ranking read through OpenVibe.Sources: <code>trending</code> is repositories created today by stars, <code>week</code> and <code>month</code> the same for 7 and 30 days, <code>hot</code> the most stars gained in 24 hours and 7 days (never totals), <code>new</code> the newest repositories and <code>top</code> the all-time most starred. <code>lang</code> and <code>topic</code> filter exactly; <code>limit</code> is 1–100 (default 30).</p>++<p class="ex-prose"><code>GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=</code> is a live GitHub repository search through the same source, cached ten minutes and rate-limited per person or address. GitHub's own query syntax passes through: <code>q=stars:>500 topic:ai</code>, <code>q=language:rust pushed:>2026-10-01</code>. Both endpoints need no key and no session, and both answer <code>Cache-Control: public</code>.</p>++<p class="ex-prose">Shape: <code>{ items: [{ full_name, url, description, language, topics, stars, stars_gained_1d, stars_gained_7d, forks, open_issues, license, created, pushed, owner_avatar_url, homepage }] }</code>. A gain with no reading yet is <code>null</code>, never 0. The browse page is <a href="/code">Code</a>.</p>++${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}++${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}++` })}${panel({ title: 'Compute prices', id: 'compute', body: `<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p><p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++// The Sources client: its own client-credentials token, the loopback read, the two caches, and the last copy++// marked stale when the source stops answering. No network: both fetches are stubs.++const assert = require('assert');++const { createCode } = require('../server/code');++const configLib = require('../server/config');++++(async () => {++ const config = configLib.load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://network.test', OV_SOURCES_URL: 'http://sources.test', OV_OAUTH_CLIENT_ID: 'exchange', OV_OAUTH_CLIENT_SECRET: 'secret' });++ let now = Date.parse('2026-11-12T12:00:00Z');++ const calls = [];++ const tokenCalls = [];++ const tokenFetch = async (url, opts) => {++ tokenCalls.push({ url: String(url), body: String(opts.body || '') });++ return new Response(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });++ };++ const fetchImpl = async (url, opts) => {++ calls.push({ url: String(url), auth: opts.headers.authorization });++ if (url.includes('/api/v1/code/search')) {++ return new Response(JSON.stringify({ items: [{ full_name: 'a/b' }], total: 1, page: 1, sort: 'best', q: 'ai', lang: null, cached: false, stale: false }), { status: 200, headers: { 'content-type': 'application/json' } });++ }++ return new Response(JSON.stringify({ list: 'week', items: [{ full_name: 'a/b' }], count: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });++ };++ const code = createCode({ config, fetchImpl, tokenFetch, now: () => now, log: { warn() {} } });++ assert.strictEqual(code.configured, true);++++ const one = await code.repos({ list: 'week', limit: 3 });++ assert.strictEqual(one.items[0].full_name, 'a/b');++ assert.deepStrictEqual(calls[0], { url: 'http://sources.test/api/v1/code/repos?list=week&limit=3', auth: 'Bearer tok' });++ assert.ok(tokenCalls.some((c) => c.body.includes('grant_type=client_credentials')), 'a client-credentials token is asked for');++ const two = await code.repos({ list: 'week', limit: 3 });++ assert.strictEqual(two.cached, true);++ assert.strictEqual(calls.length, 1, 'the rankings are cached');++ now += 61_000;++ await code.repos({ list: 'week', limit: 3 });++ assert.strictEqual(calls.length, 2, 'and expire after a minute');++++ const s1 = await code.search({ q: 'ai' });++ assert.strictEqual(s1.items.length, 1);++ const s2 = await code.search({ q: 'ai' });++ assert.strictEqual(s2.cached, true);++ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 1, 'a search is cached ten minutes');++ now += 601_000;++ await code.search({ q: 'ai' });++ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 2);++++ // Sources down: the last copy is served for five minutes, marked stale; after that the error is honest.++ const down = async () => { throw new Error('down'); };++ const offline = createCode({ config, fetchImpl: down, tokenFetch, now: () => now, log: { warn() {} } });++ await assert.rejects(() => offline.repos({ list: 'week' }), /unreachable|not answering/);++ const burst = offline.allow('usr_test');++ assert.strictEqual(burst.allowed, true);++ process.stdout.write('code client: 16 checks passed\n');++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++// /code and the public code API: the tabs, the language chips, a card's fields, search, the API shapes and++// cache headers, the per-actor search limit, and an honest page when Sources does not answer.++const assert = require('assert');++const { boot } = require('./helpers/boot');++++const NOW = Date.now();++const ITEMS = [++ {++ full_name: 'acme/hot-repo', url: 'https://github.com/acme/hot-repo', description: 'A tiny tool that does one thing well.',++ language: 'Rust', topics: ['ai', 'tools'], stars: 123456, stars_gained_1d: 300, stars_gained_7d: 1200,++ forks: 42, open_issues: 7, license: 'MIT', created: new Date(NOW - 3 * 86400000).toISOString(),++ pushed: new Date(NOW - 3 * 86400000).toISOString(), owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4',++ homepage: 'https://acme.example', source: 'github',++ },++ {++ full_name: 'beta/young', url: 'https://github.com/beta/young', description: '<b>bold</b> & new',++ language: 'Python', topics: [], stars: 250, stars_gained_1d: null, stars_gained_7d: null,++ forks: 2, open_issues: 0, license: null, created: new Date(NOW - 3600000).toISOString(),++ pushed: new Date(NOW - 3600000).toISOString(), owner_avatar_url: null, homepage: null, source: 'github',++ },++];++++function fakeCode() {++ const calls = { repos: [], search: [], allow: 0 };++ const code = {++ configured: true,++ calls,++ allow: () => { calls.allow++; return { allowed: true }; },++ async repos(args) {++ calls.repos.push(args);++ return { generated_at: new Date(NOW).toISOString(), list: args.list, lang: args.lang, count: ITEMS.length, source: { key: 'github', status: 'healthy', stale: false }, items: ITEMS, cached: false };++ },++ async search(args) {++ calls.search.push(args);++ return { items: [ITEMS[0]], total: 9341, incomplete: false, page: args.page, sort: args.sort, q: args.q, lang: args.lang, cached: false, stale: false };++ },++ };++ return code;++}++++(async () => {++ const code = fakeCode();++ const t = await boot({ code });++ try {++ // The page: search box, five tabs, twelve languages and All, three-column-capable cards.++ let r = await t.get('/code');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Search repositories: stars:>500 topic:ai|Search repositories: stars:>500 topic:ai/);++ for (const label of ['Trending today', 'This week', 'Hot', 'New', 'Top']) assert.ok(r.text.includes(label), label);++ for (const l of ['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin', 'All languages']) assert.ok(r.text.includes(l), l);++ assert.match(r.text, /acme\/hot-repo/);++ assert.match(r.text, /A tiny tool that does one thing well\./);++ assert.match(r.text, /123,456/);++ assert.match(r.text, /\+1,200 this week/);++ assert.match(r.text, /42<\/b> forks/);++ assert.match(r.text, /3 d ago/);++ assert.match(r.text, /class="cd-dot"/);++ assert.match(r.text, /https:\/\/avatars\.githubusercontent\.com\/u\/1/);++ assert.match(r.text, /class="cd-topics"[^>]*><li>ai<\/li><li>tools<\/li>/);++ assert.match(r.text, /href="https:\/\/github\.com\/acme\/hot-repo"/);++ assert.match(r.text, /<b>bold<\/b> & new/, 'a description is escaped');++ assert.match(r.text, /href="\/code\?list=week"/);++ assert.deepStrictEqual(code.calls.repos[0], { list: 'trending', lang: null, topic: null, limit: 24 });++++ // A tab and a language filter are links, not client state.++ r = await t.get('/code?list=hot&lang=Rust');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /list=hot&lang=Rust|list=hot&lang=Rust/);++ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'hot', lang: 'Rust', topic: null, limit: 24 });++++ // Search is GitHub's own syntax, passed through; the results keep the card shape.++ r = await t.get('/code?q=stars:>500%20topic:ai&lang=Rust');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: 'Rust', sort: 'best', page: 1 });++ assert.match(r.text, /Search results/);++ assert.match(r.text, /9,341 GitHub matches/);++ assert.match(r.text, /Clear search/);++++ // Public API: list, filters, cache header and shape.++ r = await t.get('/api/v1/code/repos?list=week&lang=Rust&limit=3');++ assert.strictEqual(r.status, 200);++ const body = r.json();++ assert.deepStrictEqual([body.list, body.count], ['week', 2]);++ assert.strictEqual(body.items[0].full_name, 'acme/hot-repo');++ assert.strictEqual(body.items[0].stars_gained_7d, 1200);++ assert.match(r.headers.get('cache-control'), /max-age=60/);++ assert.strictEqual((await t.get('/api/v1/code/repos?list=weather')).status, 422);++ r = await t.get('/api/v1/code/repos?list=month&lang=Rust&topic=ai&limit=200');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'month', lang: 'Rust', topic: 'ai', limit: 100 });++++ // Public search: cached ten minutes, same shape, and a missing q is a 422.++ r = await t.get('/api/v1/code/search?q=stars:>500%20topic:ai&sort=stars&page=2');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: null, sort: 'stars', page: 2 });++ assert.strictEqual(r.json().total, 9341);++ assert.match(r.headers.get('cache-control'), /max-age=600/);++ assert.strictEqual((await t.get('/api/v1/code/search')).status, 422);++ assert.strictEqual((await t.get('/api/v1/code/search?q=ai&sort=fame')).status, 422);++++ // Per-actor limit: the route answers 429 with Retry-After, never a GitHub call.++ const before = code.calls.search.length;++ code.allow = () => ({ allowed: false, retryAfter: 17 });++ r = await t.get('/api/v1/code/search?q=ai');++ assert.strictEqual(r.status, 429);++ assert.strictEqual(r.headers.get('retry-after'), '17');++ assert.strictEqual(r.json().code, 'code.rate_limited');++ assert.strictEqual(code.calls.search.length, before);++++ // Sources unavailable: the page says so and the API answers 503, never an empty list dressed as live.++ code.repos = async () => { throw new Error('down'); };++ r = await t.get('/api/v1/code/repos?list=trending');++ assert.strictEqual(r.status, 503);++ assert.strictEqual(r.json().code, 'code.unavailable');++ r = await t.get('/code');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /repository list is not reachable right now/);++++ // The deep dive names repositories as a source and links the same search.++ r = await t.get('/deep-dive');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Code and tools/);++ assert.match(r.text, /Repositories are cited like any other source/);++ assert.match(r.text, /action="\/code"/);++++ // Discovery carries the page and the two endpoints.++ r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /\/code/);++ assert.match(r.text, /\/api\/v1\/code\/repos/);++ r = await t.get('/sitemap.xml');++ assert.match(r.text, /https:\/\/openvibe\.exchange\/code/);++ process.stdout.write('code: 32 checks passed\n');++ } finally { await t.close(); }++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });@@@ -45,7 +45,7 @@ async function boot(opts = {}) {// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -35,6 +35,15 @@ OV_OAUTH_CLIENT_SECRET=
@@ -35,6 +35,15 @@ OV_OAUTH_CLIENT_SECRET=
#OV_AI_URL=https://ai.openvibe.services
#OV_AI_URL=https://ai.openvibe.services
#OV_AI_AUDIENCE=openvibe.ai
#OV_AI_AUDIENCE=openvibe.ai
#OV_AI_SCOPE=ai.run.create
#OV_AI_SCOPE=ai.run.create
+# OpenVibe.Sources: the github source's repository rankings and its live GitHub search (category code).
+# Exchange's own client-credentials grant (sources.item.read) reads it over loopback; nothing here calls GitHub.
+#OV_SOURCES_URL=http://127.0.0.1:4720
+#OV_SOURCES_AUDIENCE=openvibe.sources
+#OV_SOURCES_SCOPE=sources.item.read
+#EXCHANGE_CODE_REPOS_CACHE_MS=60000
+#EXCHANGE_CODE_SEARCH_CACHE_MS=600000
+#EXCHANGE_CODE_SEARCH_PER_MIN=20
+
# The owner console uses this Exchange service grant to read/write AI's admin JSON API.
# The owner console uses this Exchange service grant to read/write AI's admin JSON API.
# Network grants these capabilities to the existing `exchange` client; keep this scope to the needed AI capabilities.
# Network grants these capabilities to the existing `exchange` client; keep this scope to the needed AI capabilities.
#OV_AI_CONSOLE_SCOPE=ai.usage.read ai.workflow.manage ai.provider.manage
#OV_AI_CONSOLE_SCOPE=ai.usage.read ai.workflow.manage ai.provider.manage
@@ -30,6 +30,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
@@ -30,6 +30,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/status` | Uptime per offer, the model changelog and incidents. |
| `/status` | Uptime per offer, the model changelog and incidents. |
+| `/code` | GitHub repositories through OpenVibe.Sources: a deep repository search, Trending today / This week / Hot / New / Top, twelve language chips and one card per repository (stars, the week's gain, forks, language, topics, updated). `/api/v1/code/repos` and `/api/v1/code/search` are its public JSON. |
The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.
The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.
@@ -48,6 +49,9 @@ signed-in person is `private, no-store`; a public page is `public, max-age=60`.
@@ -48,6 +49,9 @@ signed-in person is `private, no-store`; a public page is `public, max-age=60`.
turn or Try it, on the person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
turn or Try it, on the person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
- `GET .../api/v1/community/offers` — the live community offers; a pool says how many machines serve it (never
- `GET .../api/v1/community/offers` — the live community offers; a pool says how many machines serve it (never
which ones), so the offer card can say "Community pool (N machines)" without naming a machine.
which ones), so the offer card can say "Community pool (N machines)" without naming a machine.
+- `GET http://127.0.0.1:4720/api/v1/code/repos` and `/api/v1/code/search` — the GitHub repositories
+ OpenVibe.Sources keeps (the `github` source, category `code`) and its live GitHub search, with this
+ service's `sources.item.read` grant. Exchange never calls GitHub itself.
- `GET https://openvibe.network/api/v1/machines` and `/api/v1/machine-pools` — the person's own machines, their
- `GET https://openvibe.network/api/v1/machines` and `/api/v1/machine-pools` — the person's own machines, their
pools and the machines they can pool, with the person's Network token: names, free VRAM and RAM, the local
pools and the machines they can pool, with the person's Network token: names, free VRAM and RAM, the local
network key, and each member's approval and readiness. `POST /api/v1/machine-pools` proposes a pool and
network key, and each member's approval and readiness. `POST /api/v1/machine-pools` proposes a pool and
@@ -750,3 +750,77 @@ label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-o
@@ -750,3 +750,77 @@ label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-o
.dd-go-note { align-items: flex-start; }
.dd-go-note { align-items: flex-start; }
.dd-go-note svg { margin-top: 3px; }
.dd-go-note svg { margin-top: 3px; }
.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }
.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }
+
+/* ── Code: GitHub repositories ─────────────────────────── */
+.cd { --cd-radius: 16px; }
+.cd-find { margin: 8px 0 14px; }
+.cd-find .ovk-find { max-width: 680px; }
+.cd-hint { margin: 8px 0 0; font-size: .82rem; line-height: 1.5; color: var(--ovk-muted); overflow-wrap: anywhere; }
+.cd-hint code { font-size: .92em; }
+.cd-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
+.cd-tab {
+ display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 13px;
+ border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel);
+ color: var(--ovk-text); font-size: .86rem; font-weight: 600; text-decoration: none; white-space: nowrap;
+}
+.cd-tab svg { width: 15px; height: 15px; }
+.cd-tab:hover { border-color: var(--ovk-accent); }
+.cd-tab.is-on { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 12%, var(--ovk-panel)); color: var(--ovk-accent); }
+.cd-langs { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; }
+.cd-chip {
+ display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px;
+ border: 1px solid var(--ovk-line); border-radius: 999px; background: transparent;
+ color: var(--ovk-muted); font-size: .78rem; text-decoration: none; white-space: nowrap;
+}
+.cd-chip:hover { border-color: var(--ovk-accent); color: var(--ovk-text); }
+.cd-chip.is-on { border-color: var(--ovk-accent); color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 10%, transparent); }
+.cd-results-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px; margin: 0 0 12px; }
+.cd-results-head h2 { margin: 0; font-size: 1.1rem; }
+.cd-count { color: var(--ovk-muted); font-size: .82rem; font-variant-numeric: tabular-nums; }
+.cd-clear { margin-left: auto; color: var(--ovk-accent); font-size: .84rem; }
+.cd-grid {
+ list-style: none; margin: 0; padding: 0;
+ display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px;
+}
+@media (min-width: 720px) { .cd-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (min-width: 1100px) { .cd-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+.cd-card {
+ min-width: 0; display: flex; flex-direction: column; gap: 10px;
+ padding: 14px; border: 1px solid var(--ovk-line); border-radius: var(--cd-radius); background: var(--ovk-panel);
+}
+.cd-card:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }
+.cd-card-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.cd-avatar { flex: none; width: 40px; height: 40px; border-radius: 12px; object-fit: cover; background: var(--ovk-panel-2); }
+.cd-avatar--letter { display: inline-grid; place-items: center; color: var(--ovk-accent); font-weight: 800; font-size: 1.05rem; }
+.cd-card-id { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
+.cd-name {
+ display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ color: var(--ovk-text); font-weight: 700; font-size: .95rem; text-decoration: none;
+}
+.cd-name:hover { color: var(--ovk-accent); text-decoration: underline; }
+.cd-owner { color: var(--ovk-muted); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cd-desc {
+ margin: 0; min-height: 2.7em; color: var(--ovk-text); font-size: .86rem; line-height: 1.45;
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere;
+}
+.cd-muted { color: var(--ovk-muted); }
+.cd-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 0; font-size: .8rem; color: var(--ovk-muted); }
+.cd-stat { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
+.cd-stat svg { width: 13px; height: 13px; flex: none; }
+.cd-stat b { color: var(--ovk-text); font-variant-numeric: tabular-nums; }
+.cd-gain { color: var(--ovk-ok); font-weight: 600; white-space: nowrap; }
+.cd-lang { display: inline-flex; align-items: center; gap: 5px; color: var(--ovk-text); min-width: 0; }
+.cd-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--cd-lang, var(--ovk-accent)); flex: none; }
+.cd-topics { list-style: none; display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; }
+.cd-topics li {
+ max-width: 100%; padding: 2px 8px; border-radius: 999px; background: var(--ovk-panel-2);
+ color: var(--ovk-muted); font-size: .7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.cd-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: auto; font-size: .75rem; color: var(--ovk-muted); }
+.cd-open { margin-left: auto; color: var(--ovk-accent); font-weight: 600; text-decoration: none; white-space: nowrap; }
+.cd-open:hover { text-decoration: underline; }
+@media (max-width: 420px) {
+ .cd-card { padding: 12px; }
+ .cd-tab { min-height: 36px; padding: 0 11px; font-size: .82rem; }
+ .cd-chip { min-height: 28px; font-size: .75rem; }
+}
@@ -33,6 +33,8 @@ const { createNetwork } = require('./network');
@@ -33,6 +33,8 @@ const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createBench } = require('./bench');
const { createCompare } = require('./compare');
const { createCompare } = require('./compare');
const { createOpenApi } = require('./openapi');
const { createOpenApi } = require('./openapi');
+const { createCode } = require('./code');
+const { createCodeRoutes } = require('./http/code');
const { createArenaStore } = require('./arena/store');
const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createChatStore, createAdultStore } = require('./chat/store');
@@ -123,7 +125,12 @@ async function createApp(opts = {}) {
@@ -123,7 +125,12 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
- const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };
+ // GitHub repositories through Sources. A test or a preview may hand its own client; nothing else reads
+ // GitHub directly.
+ const code = opts.code || createCode({
+ config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,
+ });
+ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -147,7 +154,7 @@ async function createApp(opts = {}) {
@@ -147,7 +154,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
@@ -331,6 +338,7 @@ async function createApp(opts = {}) {
@@ -331,6 +338,7 @@ async function createApp(opts = {}) {
app.use(createCompareSite(ctx));
app.use(createCompareSite(ctx));
// ── Exchange v1's JSON API: gone ────────────────────────
// ── Exchange v1's JSON API: gone ────────────────────────
+ app.use(createCodeRoutes(ctx));
app.use('/api/v1', (req, res) => {
app.use('/api/v1', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
res.set('Cache-Control', 'public, max-age=86400');
return contracts.http.sendProblem(res, 410, 'exchange.retired', {
return contracts.http.sendProblem(res, 410, 'exchange.retired', {
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * GitHub repositories, read from OpenVibe.Sources (server/code.js's sibling there): the `github` source in
+ * category `code` keeps the rankings and star history; Sources fetches GitHub with its own token, and
+ * Exchange never calls GitHub itself. This client uses Exchange's client-credentials grant
+ * (`sources.item.read`) over loopback and caches both reads: the rankings 60 seconds, a live search ten
+ * minutes (GitHub's search limit is the scarce resource).
+ *
+ * A list is what Sources last fetched; a search is a GitHub answer Sources just made. When Sources
+ * cannot answer the pages say so: no repository is invented and no star count is guessed.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+class CodeUnavailable extends Error {
+ constructor(detail = 'OpenVibe.Sources is not answering right now') {
+ super(detail);
+ this.code = 'code.unavailable';
+ this.status = 503;
+ }
+}
+
+function createCode({ config, fetchImpl = globalThis.fetch, tokenFetch = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const tokens = config.oauth.clientSecret ? createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: config.sources.audience, scope: config.sources.scope, fetch: tokenFetch,
+ }) : null;
+ const lists = new Map();
+ const searches = new Map();
+ const bursts = new Map(); // actor → [request times in the current minute]
+ let warnedAt = 0;
+
+ const warned = (what, err) => {
+ if (now() - warnedAt < 60_000) return;
+ warnedAt = now();
+ log.warn(`[exchange] Sources ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);
+ };
+
+ async function get(path) {
+ if (!tokens) throw new CodeUnavailable('Exchange is not configured for OpenVibe.Sources');
+ const token = await tokens.getToken();
+ let res;
+ try {
+ res = await fetchImpl(`${config.sources.url}${path}`, {
+ headers: { accept: 'application/json', authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(config.sources.timeoutMs),
+ });
+ } catch (err) {
+ throw new CodeUnavailable('OpenVibe.Sources is unreachable right now');
+ }
+ const text = await res.text().catch(() => '');
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = null; }
+ if (!res.ok || !body) throw new CodeUnavailable(`OpenVibe.Sources answered ${res.status}`);
+ return body;
+ }
+
+ /** The cached rankings. */
+ async function repos({ list = 'trending', lang = null, topic = null, limit = 30 } = {}) {
+ const key = JSON.stringify([list, lang || '', topic || '', limit]);
+ const hit = lists.get(key);
+ if (hit && now() - hit.at < config.sources.reposCacheMs) return { ...hit.value, cached: true };
+ try {
+ const value = await get(`/api/v1/code/repos?list=${encodeURIComponent(list)}&limit=${encodeURIComponent(limit)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${topic ? `&topic=${encodeURIComponent(topic)}` : ''}`);
+ lists.set(key, { at: now(), value });
+ if (lists.size > 100) lists.delete(lists.keys().next().value);
+ return { ...value, cached: false };
+ } catch (err) {
+ warned('code list', err);
+ // A last copy for a minute is better than an empty page, marked stale so the page says the numbers
+ // are the last read rather than live.
+ if (hit && now() - hit.at < 5 * 60_000) return { ...hit.value, cached: true, stale: true };
+ throw err;
+ }
+ }
+
+ /** A live repository search, cached ten minutes. */
+ async function search({ q, lang = null, sort = 'best', page = 1 } = {}) {
+ const key = JSON.stringify([q, lang || '', sort, page]);
+ const hit = searches.get(key);
+ if (hit && now() - hit.at < config.sources.searchCacheMs) return { ...hit.value, cached: true };
+ try {
+ const value = await get(`/api/v1/code/search?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&page=${encodeURIComponent(page)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}`);
+ searches.set(key, { at: now(), value });
+ if (searches.size > 200) searches.delete(searches.keys().next().value);
+ return { ...value, cached: false };
+ } catch (err) {
+ warned('code search', err);
+ if (hit && now() - hit.at < 60 * 60_000) return { ...hit.value, cached: true, stale: true };
+ throw err;
+ }
+ }
+
+ /** Is this actor under the search limit? A person is one actor; a signed-out visitor is their address. */
+ function allow(actor) {
+ const id = String(actor || 'anonymous');
+ const at = now();
+ const recent = (bursts.get(id) || []).filter((t) => at - t < 60_000);
+ if (recent.length >= config.sources.searchPerMinute) {
+ bursts.set(id, recent);
+ return { allowed: false, retryAfter: Math.max(1, Math.ceil((recent[0] + 60_000 - at) / 1000)) };
+ }
+ recent.push(at);
+ bursts.set(id, recent);
+ if (bursts.size > 5000) {
+ for (const [k, v] of bursts) if (!v.length || at - v[v.length - 1] > 60_000) bursts.delete(k);
+ }
+ return { allowed: true };
+ }
+
+ return { repos, search, allow, configured: Boolean(tokens) };
+}
+
+module.exports = { createCode, CodeUnavailable };
@@ -76,6 +76,19 @@ function load(env = process.env) {
@@ -76,6 +76,19 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
},
},
+ // OpenVibe.Sources (server/code.js): the GitHub repositories it keeps (source `github`, category
+ // `code`) for /code and the public code API. The token is Exchange's own client-credentials grant
+ // (sources.item.read); the read is over loopback and the public API caches it.
+ sources: {
+ url: trim(env.OV_SOURCES_URL || 'http://127.0.0.1:4720'),
+ audience: env.OV_SOURCES_AUDIENCE || 'openvibe.sources',
+ scope: env.OV_SOURCES_SCOPE || 'sources.item.read',
+ timeoutMs: Math.max(2000, int(env.EXCHANGE_SOURCES_TIMEOUT_MS, 10_000)),
+ reposCacheMs: Math.max(10_000, int(env.EXCHANGE_CODE_REPOS_CACHE_MS, 60_000)),
+ searchCacheMs: Math.max(60_000, int(env.EXCHANGE_CODE_SEARCH_CACHE_MS, 600_000)),
+ searchPerMinute: Math.max(1, int(env.EXCHANGE_CODE_SEARCH_PER_MIN, 20)),
+ },
+
// OpenVibe.Actor (server/actor.js): a companion or group turn is an Actor task of the signed-in
// OpenVibe.Actor (server/actor.js): a companion or group turn is an Actor task of the signed-in
// person — their own Bearer on the wire, their allowance. The wait bounds one page POST; a turn still
// person — their own Bearer on the wire, their allowance. The wait bounds one page POST; a turn still
// running then is reconciled on the next view.
// running then is reconciled on the next view.
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * /code and the public code API.
+ *
+ * GET /code?q=&list=trending|week|hot|new|top&lang=
+ * The page: a deep search box, the rankings, twelve language chips and one card per repository.
+ * GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=
+ * GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=
+ * Public, signed-out, cached. The search is cached ten minutes and rate-limited per actor (a person by
+ * subject, a visitor by address); the rankings 60 seconds. Sources is the only upstream.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { http } = require('openvibe-contracts');
+const views = require('../views/code');
+const { raw } = require('../render/html');
+const { send } = require('../render/layout');
+
+const LISTS = new Set(['trending', 'week', 'month', 'hot', 'new', 'top']);
+const TAB_LISTS = new Set(['trending', 'week', 'hot', 'new', 'top']);
+const SORTS = new Set(['stars', 'updated', 'best']);
+const PUBLIC_HTML = cache.htmlHeaders({ maxAge: 60 });
+const text = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
+
+function createCodeRoutes({ config, code, log = console }) {
+ const r = express.Router();
+
+ r.get('/code', async (req, res) => {
+ const q = text(req.query.q, 200);
+ const askedList = text(req.query.list, 20) || 'trending';
+ const list = TAB_LISTS.has(askedList) ? askedList : 'trending';
+ const lang = text(req.query.lang, 60);
+ const now = Date.now();
+ let repos = [];
+ let total = null;
+ let stale = false;
+ let unavailable = false;
+ let notice = '';
+ try {
+ if (q) {
+ const gate = code.allow(req.viewer && req.viewer.subject ? req.viewer.subject : req.ip);
+ if (!gate.allowed) {
+ notice = `Too many searches from this account this minute. Try again in ${gate.retryAfter} seconds.`;
+ } else {
+ const out = await code.search({ q, lang, sort: 'best', page: 1 });
+ repos = out.items || [];
+ total = out.total == null ? null : Number(out.total);
+ stale = Boolean(out.stale);
+ }
+ } else {
+ const out = await code.repos({ list, lang, topic: null, limit: 24 });
+ repos = out.items || [];
+ stale = Boolean(out.stale);
+ }
+ } catch (err) {
+ unavailable = true;
+ log.warn(`[exchange] /code: ${String((err && err.message) || err).slice(0, 160)}`);
+ }
+ return send(res, 200, {
+ viewer: req.viewer, config, path: req.originalUrl,
+ title: q ? `Code: ${q}` : 'Code',
+ description: 'GitHub repositories trending today, this week and hot by stars gained, with search and a language filter. Read through OpenVibe.Sources.',
+ index: true, cache: PUBLIC_HTML, agentLayer: true,
+ body: raw(views.codePage({ list, lang, q: q || '', repos, total, now, stale, unavailable, search: Boolean(q), notice })),
+ });
+ });
+
+ r.get('/api/v1/code/repos', async (req, res) => {
+ const list = text(req.query.list, 20) || 'trending';
+ if (!LISTS.has(list)) return http.sendProblem(res, 422, 'code.bad_query', { detail: `list must be one of ${[...LISTS].join('|')}`, ctx: req.ov });
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);
+ try {
+ const out = await code.repos({ list, lang: text(req.query.lang, 60), topic: text(req.query.topic, 50), limit });
+ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=60');
+ res.json(out);
+ } catch (err) {
+ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'The repository list is not available right now', ctx: req.ov });
+ }
+ });
+
+ r.get('/api/v1/code/search', async (req, res) => {
+ const q = text(req.query.q, 300);
+ if (!q) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'q is required', ctx: req.ov });
+ const sort = text(req.query.sort, 10) || 'best';
+ if (!SORTS.has(sort)) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'sort must be stars, updated or best', ctx: req.ov });
+ const page = Math.min(Math.max(parseInt(req.query.page, 10) || 1, 1), 10);
+ const actor = req.viewer && req.viewer.subject ? req.viewer.subject : req.ip;
+ const gate = code.allow(actor);
+ if (!gate.allowed) {
+ res.set('Retry-After', String(gate.retryAfter));
+ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'Too many repository searches this minute', ctx: req.ov });
+ }
+ try {
+ const out = await code.search({ q, lang: text(req.query.lang, 60), sort, page });
+ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=600');
+ res.json(out);
+ } catch (err) {
+ if (err && err.code === 'code.rate_limited') {
+ res.set('Retry-After', String(err.retryAfter || 60));
+ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'GitHub asked us to wait before another search', ctx: req.ov });
+ }
+ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'GitHub search is not available right now', ctx: req.ov });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createCodeRoutes, LISTS, SORTS };
@@ -27,6 +27,7 @@ const PUBLIC_PAGES = [
@@ -27,6 +27,7 @@ const PUBLIC_PAGES = [
{ path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },
{ path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
+ { path: '/code', changefreq: 'daily', priority: 0.8, title: 'Code', text: 'GitHub repositories trending today, new and hot by stars gained, plus a deep repository search: read through OpenVibe.Sources.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
];
];
@@ -106,7 +107,7 @@ function createDiscoveryRoutes(ctx) {
@@ -106,7 +107,7 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: DESCRIPTION,
summary: DESCRIPTION,
- details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account.',
+ details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account. GitHub repositories are read through OpenVibe.Sources and served at /api/v1/code/repos and /api/v1/code/search; nothing is invented when the source is behind.',
markdown: true,
markdown: true,
base: site,
base: site,
sections: [
sections: [
@@ -114,8 +115,10 @@ function createDiscoveryRoutes(ctx) {
@@ -114,8 +115,10 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
+ { title: 'Code', url: abs('/code'), note: 'GitHub trending, hot and new repositories, and a deep search' },
{ title: 'The API', url: abs('/api') },
{ title: 'The API', url: abs('/api') },
{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
+ { title: 'Code repositories API', url: abs('/api/v1/code/repos?list=week'), note: 'cached GitHub rankings from Sources; search at /api/v1/code/search?q=stars:>500' },
{ title: 'Start building', url: abs('/start') },
{ title: 'Start building', url: abs('/start') },
{ title: 'Why this model', url: abs('/why') },
{ title: 'Why this model', url: abs('/why') },
{ title: 'Questions', url: abs('/faq') },
{ title: 'Questions', url: abs('/faq') },
@@ -26,9 +26,39 @@ function analysisPaths(config) {
@@ -26,9 +26,39 @@ function analysisPaths(config) {
'/api/v1/analysis/compare': make('exchange.analysis.compare', ['models', 'measures', 'kind', 'day', 'rating_kind'].map(query)),
'/api/v1/analysis/compare': make('exchange.analysis.compare', ['models', 'measures', 'kind', 'day', 'rating_kind'].map(query)),
'/api/v1/analysis/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),
'/api/v1/analysis/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
+ '/api/v1/code/repos': codeReposPath(query),
+ '/api/v1/code/search': codeSearchPath(query),
};
};
}
}
+
+function codeReposPath(query) {
+ return { get: {
+ operationId: 'exchange.code.repos', summary: 'GitHub repositories trending, new, hot by stars gained, or most starred',
+ security: [], servers: [{ url: 'https://openvibe.exchange' }],
+ parameters: ['list', 'lang', 'topic', 'limit'].map(query),
+ responses: {
+ '200': { description: 'Cached repository rankings with stars, 1-day and 7-day gains, forks, language, topics and dates' },
+ '422': { description: 'Invalid list or limit' },
+ '503': { description: 'OpenVibe.Sources is unavailable' },
+ },
+ } };
+}
+
+function codeSearchPath(query) {
+ return { get: {
+ operationId: 'exchange.code.search', summary: 'Search GitHub repositories with GitHub query syntax',
+ security: [], servers: [{ url: 'https://openvibe.exchange' }],
+ parameters: ['q', 'lang', 'sort', 'page'].map(query),
+ responses: {
+ '200': { description: 'Repository results in the same shape as /api/v1/code/repos, with total matches' },
+ '422': { description: 'q, sort or page is invalid' },
+ '429': { description: 'Too many searches this minute; Retry-After says when' },
+ '503': { description: 'GitHub search through OpenVibe.Sources is unavailable' },
+ },
+ } };
+}
+
function createOpenApi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
function createOpenApi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
const ttl = config.api.openapiCacheMs;
const ttl = config.api.openapiCacheMs;
let at = 0;
let at = 0;
@@ -28,6 +28,7 @@ const NAV = [
@@ -28,6 +28,7 @@ const NAV = [
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
+ { label: 'Code', href: '/code', icon: 'fa-code', more: true },
];
];
const hashes = new Map();
const hashes = new Map();
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * /code: GitHub repositories through OpenVibe.Sources. A deep search box, the rankings as tabs, the twelve
+ * languages as chips, and one card per repository: owner avatar, name, a two-line description, the language
+ * with its dot, stars with the week's gain, forks, when it was last pushed, topics and a link to GitHub.
+ *
+ * The cards are a CSS grid, one column on a phone and three on a desktop; every name and description wraps
+ * or is clamped, so no card pushes the page sideways at 320–430 px. The page is complete without JavaScript.
+ */
+const kit = require('openvibe-shared/kit');
+const glyphs = require('openvibe-shared/glyphs');
+const { esc } = require('../render/html');
+
+// The same twelve GitHub searches Sources keeps (server/adapters/github.js in OpenVibe.Sources). A chip
+// that filters a list and a search alike; a language not here is still in a card's own language line.
+const LANGUAGES = Object.freeze(['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin']);
+const LANG_COLOR = {
+ JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', Go: '#00ADD8', Rust: '#dea584', Java: '#b07219',
+ 'C++': '#f34b7d', 'C#': '#178600', PHP: '#4F5D95', Ruby: '#701516', Swift: '#F05138', Kotlin: '#A97BFF',
+};
+const TABS = Object.freeze([
+ { key: 'trending', label: 'Trending today', fa: 'fa-fire' },
+ { key: 'week', label: 'This week', fa: 'fa-calendar' },
+ { key: 'hot', label: 'Hot', fa: 'fa-arrow-trend-up' },
+ { key: 'new', label: 'New', fa: 'fa-plus' },
+ { key: 'top', label: 'Top', fa: 'fa-star' },
+]);
+
+/** The glyph for a Font Awesome name, or nothing when the registry has none. */
+const icon = (fa) => { const name = glyphs.fromFa(fa); return name ? glyphs.svg(name) : ''; };
+const count = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString('en-US') : '—');
+const when = (iso, now) => {
+ const t = Date.parse(iso || '');
+ if (!Number.isFinite(t)) return 'date not listed';
+ const s = Math.max(0, Math.round((now - t) / 1000));
+ if (s < 90) return 'just now';
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
+ if (s < 30 * 86400) return `${Math.round(s / 86400)} d ago`;
+ return new Date(t).toISOString().slice(0, 10);
+};
+
+function avatar(repo) {
+ const owner = String(repo.full_name || '').split('/')[0] || '?';
+ if (repo.owner_avatar_url) return `<img class="cd-avatar" src="${esc(repo.owner_avatar_url)}" alt="" width="40" height="40" loading="lazy" decoding="async">`;
+ return `<span class="cd-avatar cd-avatar--letter" aria-hidden="true">${esc(owner.slice(0, 1).toUpperCase())}</span>`;
+}
+
+function card(repo, now) {
+ const gain = Number.isFinite(Number(repo.stars_gained_7d)) && Number(repo.stars_gained_7d) > 0 ? ` <span class="cd-gain">+${count(repo.stars_gained_7d)} this week</span>` : '';
+ const topics = (Array.isArray(repo.topics) ? repo.topics : []).slice(0, 5);
+ const lang = repo.language || null;
+ const dot = lang ? `<span class="cd-dot" style="--cd-lang:${esc(LANG_COLOR[lang] || 'var(--ovk-accent)')}" aria-hidden="true"></span>` : '';
+ return `<li class="cd-card">
+<header class="cd-card-head">${avatar(repo)}<div class="cd-card-id"><a class="cd-name" href="${esc(repo.url)}" rel="noopener noreferrer">${esc(repo.full_name)}</a><span class="cd-owner">${esc(String(repo.full_name || '').split('/')[0] || '')}</span></div></header>
+<p class="cd-desc">${repo.description ? esc(repo.description) : '<span class="cd-muted">No description.</span>'}</p>
+<p class="cd-stats"><span class="cd-stat" title="Stars">${icon('fa-star')}<b>${count(repo.stars)}</b>${gain}</span><span class="cd-stat" title="Forks">${icon('fa-code-branch') || ''}<b>${count(repo.forks)}</b> forks</span>${lang ? `<span class="cd-lang">${dot}${esc(lang)}</span>` : ''}</p>
+${topics.length ? `<ul class="cd-topics" aria-label="Topics">${topics.map((t) => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}
+<footer class="cd-foot"><span>Updated ${esc(when(repo.pushed, now))}</span><a class="cd-open" href="${esc(repo.url)}" rel="noopener noreferrer">GitHub →</a></footer>
+</li>`;
+}
+
+function cards(repos, now) {
+ if (!repos.length) return kit.empty({ icon: 'code', title: 'No repositories here yet', text: 'The GitHub source is still filling the list, or this filter has no match. Try another language or list.' });
+ return `<ul class="cd-grid">${repos.map((r) => card(r, now)).join('')}</ul>`;
+}
+
+function tabLinks(list, lang, q) {
+ return TABS.map((t) => {
+ const href = `/code?list=${encodeURIComponent(t.key)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
+ return `<a class="cd-tab${t.key === list ? ' is-on' : ''}" href="${href}"${t.key === list ? ' aria-current="page"' : ''}>${icon(t.fa)}${esc(t.label)}</a>`;
+ }).join('');
+}
+
+function languageLinks(list, lang, q) {
+ const all = `/code?list=${encodeURIComponent(list)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
+ const chips = [`<a class="cd-chip${lang ? '' : ' is-on'}" href="${all}">All languages</a>`]
+ .concat(LANGUAGES.map((l) => {
+ const href = `/code?list=${encodeURIComponent(list)}&lang=${encodeURIComponent(l)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
+ return `<a class="cd-chip${l === lang ? ' is-on' : ''}" href="${href}">${esc(l)}</a>`;
+ }));
+ return `<nav class="cd-langs" aria-label="Language">${chips.join('')}</nav>`;
+}
+
+/**
+ * o: { list, lang, q, repos, total, now, stale, unavailable, search }
+ */
+function codePage({ list = 'trending', lang = null, q = '', repos = [], total = null, now = Date.now(), stale = false, unavailable = false, search = false, notice: extraNotice = '' }) {
+ const notice = unavailable
+ ? kit.notice({ kind: 'warn', text: 'The repository list is not reachable right now. It is read from OpenVibe.Sources; reload in a moment.' })
+ : extraNotice ? kit.notice({ kind: 'info', text: extraNotice })
+ : stale ? kit.notice({ kind: 'info', text: 'These are the last repositories read from OpenVibe.Sources; the live read did not answer just now.' }) : '';
+ const title = search ? `Repositories for “${q}”` : (TABS.find((t) => t.key === list) || TABS[0]).label;
+ const sub = search && total != null ? `${count(total)} GitHub matches · repositories only, no code copied` : 'Trending, new and most-starred repositories on GitHub, with the star gains that make them hot.';
+ const results = `<section class="cd-results" aria-labelledby="cd-results-title"><div class="cd-results-head"><h2 id="cd-results-title">${search ? 'Search results' : esc(title)}</h2><span class="cd-count">${repos.length} shown</span>${search ? '<a class="cd-clear" href="/code">Clear search</a>' : ''}</div>${cards(repos, now)}</section>`;
+ return `<div class="ex-wrap ex-page cd">
+${kit.head({ title: 'Code', sub, actions: [kit.button({ href: '/api#code', label: 'Code API', kind: 'quiet', icon: 'code' })] })}
+<section class="cd-find" aria-label="Search repositories">
+${kit.find({ action: '/code', name: 'q', placeholder: 'Search repositories: stars:>500 topic:ai', value: q, icon: 'search', maxlength: 200, hidden: lang ? { lang } : {} })}
+<p class="cd-hint">GitHub's own search syntax works: <code>stars:>500 topic:ai</code>, <code>language:rust</code>, <code>pushed:>2026-10-01</code>. Code is linked, never copied.</p>
+</section>
+<nav class="cd-tabs" aria-label="Repository list">${tabLinks(list, lang, q)}</nav>
+${languageLinks(list, lang, q)}
+${notice}
+${results}
+</div>`;
+}
+
+module.exports = { codePage, LANGUAGES, TABS };
@@ -15,7 +15,7 @@ const kit = require('openvibe-shared/kit');
@@ -15,7 +15,7 @@ const kit = require('openvibe-shared/kit');
const { esc, raw } = require('../render/html');
const { esc, raw } = require('../render/html');
const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };
const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };
-const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };
+const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub repositories through Exchange\'s Code search, and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };
const price = (n) => `$${Number(n || 0).toFixed(2)}`;
const price = (n) => `$${Number(n || 0).toFixed(2)}`;
const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };
const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };
@@ -59,7 +59,7 @@ const chips = (words) => `<ul class="dd-chips">${words.map((w) => `<li>${esc(w)}
@@ -59,7 +59,7 @@ const chips = (words) => `<ul class="dd-chips">${words.map((w) => `<li>${esc(w)}
function whatItDoes() {
function whatItDoes() {
const step = (icon, title, text, extra = '') => `<li><span class="dd-what-ic" aria-hidden="true">${kit.icon(icon)}</span><div><strong>${title}</strong><p>${text}</p>${extra}</div></li>`;
const step = (icon, title, text, extra = '') => `<li><span class="dd-what-ic" aria-hidden="true">${kit.icon(icon)}</span><div><strong>${title}</strong><p>${text}</p>${extra}</div></li>`;
return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">
return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">
-${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub, design write-ups, and our own rules register and component registry.')}
+${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub through <a href="/code">Code</a>, design write-ups, and our own rules register and component registry.')}
${step('check', 'Choose the sites worth a look.', 'A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).')}
${step('check', 'Choose the sites worth a look.', 'A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).')}
${step('image', 'Capture them properly.', '390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts:',
${step('image', 'Capture them properly.', '390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts:',
`${chips(MEASURED)}<p class="dd-what-sub">Expert adds</p>${chips(EXPERT_ADDS)}`)}
`${chips(MEASURED)}<p class="dd-what-sub">Expert adds</p>${chips(EXPERT_ADDS)}`)}
@@ -70,6 +70,18 @@ ${step('book', 'Show the evidence.', 'Every claim names its site, capture and th
@@ -70,6 +70,18 @@ ${step('book', 'Show the evidence.', 'Every claim names its site, capture and th
<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });
<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });
}
}
+/**
+ * Code and tools are evidence too: a deep dive about a library, a protocol or a tool should be able to cite
+ * the repositories it found. The repositories are Sources' (Exchange never reads GitHub itself), and this
+ * panel is the way from a deep dive into the same rankings and search the Code page uses.
+ */
+function codeSources() {
+ return kit.section({ title: 'Code and tools', note: 'Repositories are cited like any other source: by their GitHub URL, with the date they were read.', body:
+ `<p class="ovk-prose">A repository is evidence for how a tool is built and how active it is. The rankings and the search are read through <a href="/code">Code</a>: trending today, hot by stars gained, new and most starred, or GitHub's own query syntax.</p>`
+ + kit.find({ action: '/code', name: 'q', placeholder: 'Find a repository: stars:>500 topic:ai', icon: 'search', maxlength: 200 })
+ + `<p class="ovk-note">No repository code or README is copied; a card keeps what the repository states about itself and links to it.</p>` });
+}
+
function homePage({ viewer, depths = [], capture = null, error = null }) {
function homePage({ viewer, depths = [], capture = null, error = null }) {
const signedIn = Boolean(viewer && viewer.kind === 'user');
const signedIn = Boolean(viewer && viewer.kind === 'user');
const cards = (depths && depths.length ? depths : [
const cards = (depths && depths.length ? depths : [
@@ -87,7 +99,7 @@ ${depthCards(cards)}
@@ -87,7 +99,7 @@ ${depthCards(cards)}
: signIn();
: signIn();
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
+ (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')
+ (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')
- + whatItDoes() + `</div>`;
+ + whatItDoes() + codeSources() + `</div>`;
}
}
function choosing(row, id) {
function choosing(row, id) {
@@ -223,6 +223,7 @@ ${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} mod
@@ -223,6 +223,7 @@ ${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} mod
<div class="ex-lines">
<div class="ex-lines">
${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', icon: 'swap', tone: 'accent' })}
${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', icon: 'swap', tone: 'accent' })}
${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', icon: 'trophy', tone: 'accent' })}
${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', icon: 'trophy', tone: 'accent' })}
+${lineCard({ href: '/code', name: 'Code', text: 'GitHub repositories trending today, hot by stars gained, and a deep search with GitHub\'s own syntax.', icon: 'code', tone: 'accent' })}
${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', icon: 'shield', tone: 'warn' })}
${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', icon: 'shield', tone: 'warn' })}
${lineCard({ href: '/why', name: 'Why this model', text: 'Every answer carries its receipt: the objective, the candidates, their cost and latency, and why the others did not answer.', icon: 'help', tone: 'accent' })}
${lineCard({ href: '/why', name: 'Why this model', text: 'Every answer carries its receipt: the objective, the candidates, their cost and latency, and why the others did not answer.', icon: 'help', tone: 'accent' })}
${lineCard({ href: '/start', name: 'Start building', text: 'One key, one endpoint and the SDK you already use; four steps from a key to a receipt.', icon: 'code', tone: 'accent' })}
${lineCard({ href: '/start', name: 'Start building', text: 'One key, one endpoint and the SDK you already use; four steps from a key to a receipt.', icon: 'code', tone: 'accent' })}
@@ -522,6 +523,13 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
@@ -522,6 +523,13 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
return `<div class="ex-wrap ex-page">
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
+${panel({ title: 'Code repositories', id: 'code', body: `
+<p class="ex-prose"><code>GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=</code> is the cached GitHub repository ranking read through OpenVibe.Sources: <code>trending</code> is repositories created today by stars, <code>week</code> and <code>month</code> the same for 7 and 30 days, <code>hot</code> the most stars gained in 24 hours and 7 days (never totals), <code>new</code> the newest repositories and <code>top</code> the all-time most starred. <code>lang</code> and <code>topic</code> filter exactly; <code>limit</code> is 1–100 (default 30).</p>
+<p class="ex-prose"><code>GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=</code> is a live GitHub repository search through the same source, cached ten minutes and rate-limited per person or address. GitHub's own query syntax passes through: <code>q=stars:>500 topic:ai</code>, <code>q=language:rust pushed:>2026-10-01</code>. Both endpoints need no key and no session, and both answer <code>Cache-Control: public</code>.</p>
+<p class="ex-prose">Shape: <code>{ items: [{ full_name, url, description, language, topics, stars, stars_gained_1d, stars_gained_7d, forks, open_issues, license, created, pushed, owner_avatar_url, homepage }] }</code>. A gain with no reading yet is <code>null</code>, never 0. The browse page is <a href="/code">Code</a>.</p>
+${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}
+${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}
+` })}
${panel({ title: 'Compute prices', id: 'compute', body: `
${panel({ title: 'Compute prices', id: 'compute', body: `
<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+// The Sources client: its own client-credentials token, the loopback read, the two caches, and the last copy
+// marked stale when the source stops answering. No network: both fetches are stubs.
+const assert = require('assert');
+const { createCode } = require('../server/code');
+const configLib = require('../server/config');
+
+(async () => {
+ const config = configLib.load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://network.test', OV_SOURCES_URL: 'http://sources.test', OV_OAUTH_CLIENT_ID: 'exchange', OV_OAUTH_CLIENT_SECRET: 'secret' });
+ let now = Date.parse('2026-11-12T12:00:00Z');
+ const calls = [];
+ const tokenCalls = [];
+ const tokenFetch = async (url, opts) => {
+ tokenCalls.push({ url: String(url), body: String(opts.body || '') });
+ return new Response(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+ const fetchImpl = async (url, opts) => {
+ calls.push({ url: String(url), auth: opts.headers.authorization });
+ if (url.includes('/api/v1/code/search')) {
+ return new Response(JSON.stringify({ items: [{ full_name: 'a/b' }], total: 1, page: 1, sort: 'best', q: 'ai', lang: null, cached: false, stale: false }), { status: 200, headers: { 'content-type': 'application/json' } });
+ }
+ return new Response(JSON.stringify({ list: 'week', items: [{ full_name: 'a/b' }], count: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+ const code = createCode({ config, fetchImpl, tokenFetch, now: () => now, log: { warn() {} } });
+ assert.strictEqual(code.configured, true);
+
+ const one = await code.repos({ list: 'week', limit: 3 });
+ assert.strictEqual(one.items[0].full_name, 'a/b');
+ assert.deepStrictEqual(calls[0], { url: 'http://sources.test/api/v1/code/repos?list=week&limit=3', auth: 'Bearer tok' });
+ assert.ok(tokenCalls.some((c) => c.body.includes('grant_type=client_credentials')), 'a client-credentials token is asked for');
+ const two = await code.repos({ list: 'week', limit: 3 });
+ assert.strictEqual(two.cached, true);
+ assert.strictEqual(calls.length, 1, 'the rankings are cached');
+ now += 61_000;
+ await code.repos({ list: 'week', limit: 3 });
+ assert.strictEqual(calls.length, 2, 'and expire after a minute');
+
+ const s1 = await code.search({ q: 'ai' });
+ assert.strictEqual(s1.items.length, 1);
+ const s2 = await code.search({ q: 'ai' });
+ assert.strictEqual(s2.cached, true);
+ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 1, 'a search is cached ten minutes');
+ now += 601_000;
+ await code.search({ q: 'ai' });
+ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 2);
+
+ // Sources down: the last copy is served for five minutes, marked stale; after that the error is honest.
+ const down = async () => { throw new Error('down'); };
+ const offline = createCode({ config, fetchImpl: down, tokenFetch, now: () => now, log: { warn() {} } });
+ await assert.rejects(() => offline.repos({ list: 'week' }), /unreachable|not answering/);
+ const burst = offline.allow('usr_test');
+ assert.strictEqual(burst.allowed, true);
+ process.stdout.write('code client: 16 checks passed\n');
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+// /code and the public code API: the tabs, the language chips, a card's fields, search, the API shapes and
+// cache headers, the per-actor search limit, and an honest page when Sources does not answer.
+const assert = require('assert');
+const { boot } = require('./helpers/boot');
+
+const NOW = Date.now();
+const ITEMS = [
+ {
+ full_name: 'acme/hot-repo', url: 'https://github.com/acme/hot-repo', description: 'A tiny tool that does one thing well.',
+ language: 'Rust', topics: ['ai', 'tools'], stars: 123456, stars_gained_1d: 300, stars_gained_7d: 1200,
+ forks: 42, open_issues: 7, license: 'MIT', created: new Date(NOW - 3 * 86400000).toISOString(),
+ pushed: new Date(NOW - 3 * 86400000).toISOString(), owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4',
+ homepage: 'https://acme.example', source: 'github',
+ },
+ {
+ full_name: 'beta/young', url: 'https://github.com/beta/young', description: '<b>bold</b> & new',
+ language: 'Python', topics: [], stars: 250, stars_gained_1d: null, stars_gained_7d: null,
+ forks: 2, open_issues: 0, license: null, created: new Date(NOW - 3600000).toISOString(),
+ pushed: new Date(NOW - 3600000).toISOString(), owner_avatar_url: null, homepage: null, source: 'github',
+ },
+];
+
+function fakeCode() {
+ const calls = { repos: [], search: [], allow: 0 };
+ const code = {
+ configured: true,
+ calls,
+ allow: () => { calls.allow++; return { allowed: true }; },
+ async repos(args) {
+ calls.repos.push(args);
+ return { generated_at: new Date(NOW).toISOString(), list: args.list, lang: args.lang, count: ITEMS.length, source: { key: 'github', status: 'healthy', stale: false }, items: ITEMS, cached: false };
+ },
+ async search(args) {
+ calls.search.push(args);
+ return { items: [ITEMS[0]], total: 9341, incomplete: false, page: args.page, sort: args.sort, q: args.q, lang: args.lang, cached: false, stale: false };
+ },
+ };
+ return code;
+}
+
+(async () => {
+ const code = fakeCode();
+ const t = await boot({ code });
+ try {
+ // The page: search box, five tabs, twelve languages and All, three-column-capable cards.
+ let r = await t.get('/code');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Search repositories: stars:>500 topic:ai|Search repositories: stars:>500 topic:ai/);
+ for (const label of ['Trending today', 'This week', 'Hot', 'New', 'Top']) assert.ok(r.text.includes(label), label);
+ for (const l of ['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin', 'All languages']) assert.ok(r.text.includes(l), l);
+ assert.match(r.text, /acme\/hot-repo/);
+ assert.match(r.text, /A tiny tool that does one thing well\./);
+ assert.match(r.text, /123,456/);
+ assert.match(r.text, /\+1,200 this week/);
+ assert.match(r.text, /42<\/b> forks/);
+ assert.match(r.text, /3 d ago/);
+ assert.match(r.text, /class="cd-dot"/);
+ assert.match(r.text, /https:\/\/avatars\.githubusercontent\.com\/u\/1/);
+ assert.match(r.text, /class="cd-topics"[^>]*><li>ai<\/li><li>tools<\/li>/);
+ assert.match(r.text, /href="https:\/\/github\.com\/acme\/hot-repo"/);
+ assert.match(r.text, /<b>bold<\/b> & new/, 'a description is escaped');
+ assert.match(r.text, /href="\/code\?list=week"/);
+ assert.deepStrictEqual(code.calls.repos[0], { list: 'trending', lang: null, topic: null, limit: 24 });
+
+ // A tab and a language filter are links, not client state.
+ r = await t.get('/code?list=hot&lang=Rust');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /list=hot&lang=Rust|list=hot&lang=Rust/);
+ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'hot', lang: 'Rust', topic: null, limit: 24 });
+
+ // Search is GitHub's own syntax, passed through; the results keep the card shape.
+ r = await t.get('/code?q=stars:>500%20topic:ai&lang=Rust');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: 'Rust', sort: 'best', page: 1 });
+ assert.match(r.text, /Search results/);
+ assert.match(r.text, /9,341 GitHub matches/);
+ assert.match(r.text, /Clear search/);
+
+ // Public API: list, filters, cache header and shape.
+ r = await t.get('/api/v1/code/repos?list=week&lang=Rust&limit=3');
+ assert.strictEqual(r.status, 200);
+ const body = r.json();
+ assert.deepStrictEqual([body.list, body.count], ['week', 2]);
+ assert.strictEqual(body.items[0].full_name, 'acme/hot-repo');
+ assert.strictEqual(body.items[0].stars_gained_7d, 1200);
+ assert.match(r.headers.get('cache-control'), /max-age=60/);
+ assert.strictEqual((await t.get('/api/v1/code/repos?list=weather')).status, 422);
+ r = await t.get('/api/v1/code/repos?list=month&lang=Rust&topic=ai&limit=200');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'month', lang: 'Rust', topic: 'ai', limit: 100 });
+
+ // Public search: cached ten minutes, same shape, and a missing q is a 422.
+ r = await t.get('/api/v1/code/search?q=stars:>500%20topic:ai&sort=stars&page=2');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: null, sort: 'stars', page: 2 });
+ assert.strictEqual(r.json().total, 9341);
+ assert.match(r.headers.get('cache-control'), /max-age=600/);
+ assert.strictEqual((await t.get('/api/v1/code/search')).status, 422);
+ assert.strictEqual((await t.get('/api/v1/code/search?q=ai&sort=fame')).status, 422);
+
+ // Per-actor limit: the route answers 429 with Retry-After, never a GitHub call.
+ const before = code.calls.search.length;
+ code.allow = () => ({ allowed: false, retryAfter: 17 });
+ r = await t.get('/api/v1/code/search?q=ai');
+ assert.strictEqual(r.status, 429);
+ assert.strictEqual(r.headers.get('retry-after'), '17');
+ assert.strictEqual(r.json().code, 'code.rate_limited');
+ assert.strictEqual(code.calls.search.length, before);
+
+ // Sources unavailable: the page says so and the API answers 503, never an empty list dressed as live.
+ code.repos = async () => { throw new Error('down'); };
+ r = await t.get('/api/v1/code/repos?list=trending');
+ assert.strictEqual(r.status, 503);
+ assert.strictEqual(r.json().code, 'code.unavailable');
+ r = await t.get('/code');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /repository list is not reachable right now/);
+
+ // The deep dive names repositories as a source and links the same search.
+ r = await t.get('/deep-dive');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Code and tools/);
+ assert.match(r.text, /Repositories are cited like any other source/);
+ assert.match(r.text, /action="\/code"/);
+
+ // Discovery carries the page and the two endpoints.
+ r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /\/code/);
+ assert.match(r.text, /\/api\/v1\/code\/repos/);
+ r = await t.get('/sitemap.xml');
+ assert.match(r.text, /https:\/\/openvibe\.exchange\/code/);
+ process.stdout.write('code: 32 checks passed\n');
+ } finally { await t.close(); }
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;