/**
* rCred module — Contribution Recognition via CredRank.
*
* Builds a per-space contribution graph from all module activity,
* runs CredRank (modified PageRank on weighted contribution edges),
* and distributes Grain tokens proportional to contribution.
*
* Inspired by SourceCred (RIP 2018–2022) — algorithm lives on.
*/
import { Hono } from 'hono';
import { renderShell } from '../../server/shell';
import { getModuleInfoList } from '../../shared/module';
import type { RSpaceModule } from '../../shared/module';
import type { SyncServer } from '../../server/local-first/sync-server';
import { graphSchema, scoresSchema, configSchema, configDocId, scoresDocId } from './schemas';
import type { CredConfigDoc } from './schemas';
import { createCredRoutes } from './routes';
import { recomputeSpace, ensureConfigDoc } from './grain-engine';
import { renderLanding } from './landing';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// Mount API routes
const apiRoutes = createCredRoutes(() => _syncServer);
routes.route('/', apiRoutes);
// ── Landing page ──
routes.get('/', (c) => {
const space = c.req.param('space');
return c.html(
renderShell({
title: 'rCred — Contribution Recognition',
spaceSlug: space || 'demo',
moduleId: 'rcred',
modules: getModuleInfoList(),
body: ``,
scripts: '',
}),
);
});
// ── Cron: recompute every 6 hours ──
const CRON_INTERVAL = 6 * 60 * 60 * 1000; // 6h
let cronTimer: ReturnType | null = null;
function startCredCron() {
if (cronTimer) return;
cronTimer = setInterval(() => {
if (!_syncServer) return;
const allDocs = _syncServer.listDocs();
const configDocs = allDocs.filter(id => id.endsWith(':rcred:config'));
for (const docId of configDocs) {
const space = docId.split(':')[0];
if (!space) continue;
const config = _syncServer.getDoc(docId);
if (!config?.enabled) continue;
// Check epoch timing
const epochMs = (config.epochLengthDays || 7) * 86_400_000;
if (Date.now() - (config.lastEpochAt || 0) < epochMs) continue;
console.log(`[rCred cron] Recomputing ${space}...`);
recomputeSpace(space, _syncServer);
}
}, CRON_INTERVAL);
}
export const credModule: RSpaceModule = {
id: 'rcred',
name: 'rCred',
icon: '⭐',
description: 'Contribution recognition — Cred scores + Grain distribution via CredRank',
routes,
publicWrite: true, // recompute route handles its own auth
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [graphSchema, scoresSchema, configSchema],
onInit: async ({ syncServer }) => {
_syncServer = syncServer;
startCredCron();
console.log('[rCred] Module initialized, cron started (6h interval)');
// Auto-seed demo space on startup (delayed to let docs load)
setTimeout(() => {
if (!_syncServer) return;
const scores = _syncServer.getDoc(scoresDocId('demo'));
if (!scores) {
console.log('[rCred] Seeding demo space scores...');
recomputeSpace('demo', _syncServer);
}
}, 10_000);
},
feeds: [
{
id: 'rcred-scores',
name: 'Cred Scores',
description: 'CredRank contribution scores per space member',
kind: 'trust',
},
],
landingPage: renderLanding,
settingsSchema: [
{
key: 'enabled',
label: 'Enable CredRank',
type: 'boolean',
description: 'Enable automatic contribution scoring and grain distribution',
default: true,
},
{
key: 'grainPerEpoch',
label: 'Grain per Epoch',
type: 'string',
description: 'Amount of GRAIN tokens distributed each epoch (default: 1000)',
default: '1000',
},
{
key: 'lookbackDays',
label: 'Lookback Window (days)',
type: 'string',
description: 'How far back to look for contributions (default: 90)',
default: '90',
},
],
};