Merge branch 'dev'
CI/CD / deploy (push) Successful in 1m59s Details

This commit is contained in:
Jeff Emmett 2026-04-15 15:26:45 -04:00
commit a5b8ecd234
11 changed files with 2200 additions and 2 deletions

View File

@ -0,0 +1,458 @@
/**
* <folk-cred-dashboard> CredRank contribution recognition dashboard.
*
* Leaderboard, per-contributor breakdown, and admin weight config.
* Fetches from /:space/rcred/api/* endpoints.
*/
import { authFetch } from '../../../shared/auth-fetch';
interface LeaderboardEntry {
rank: number;
did: string;
label: string;
cred: number;
grainLifetime: number;
topModule: string;
lastActive: number;
}
interface ScoresResponse {
scores: LeaderboardEntry[];
totalCred: number;
computedAt: number;
epochId: string;
}
interface ContributorDetail {
did: string;
label: string;
cred: number;
rawScore: number;
grainLifetime: number;
breakdown: Record<string, number>;
epochScores: Record<string, number>;
lastActive: number;
}
interface WeightConfig {
type: string;
weight: number;
description: string;
}
interface ConfigResponse {
weights: Record<string, WeightConfig>;
grainPerEpoch: number;
epochLengthDays: number;
slowFraction: number;
fastFraction: number;
dampingFactor: number;
lookbackDays: number;
enabled: boolean;
lastEpochAt: number;
}
const MODULE_COLORS: Record<string, string> = {
rtasks: '#3b82f6',
rdocs: '#8b5cf6',
rchats: '#06b6d4',
rcal: '#f59e0b',
rvote: '#ec4899',
rflows: '#22c55e',
rtime: '#a78bfa',
rwallet: '#d97706',
};
const MODULE_LABELS: Record<string, string> = {
rtasks: 'Tasks',
rdocs: 'Docs',
rchats: 'Chats',
rcal: 'Calendar',
rvote: 'Voting',
rflows: 'Flows',
rtime: 'Time',
rwallet: 'Wallet',
};
class FolkCredDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private view: 'leaderboard' | 'contributor' | 'config' = 'leaderboard';
private scores: LeaderboardEntry[] = [];
private totalCred = 0;
private computedAt = 0;
private selectedContributor: ContributorDetail | null = null;
private config: ConfigResponse | null = null;
private loading = false;
private recomputing = false;
private error = '';
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.loadScores();
}
private async loadScores() {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/scores?space=${this.space}`);
const data: ScoresResponse = await res.json();
this.scores = data.scores || [];
this.totalCred = data.totalCred || 0;
this.computedAt = data.computedAt || 0;
this.error = '';
} catch (e) {
this.error = 'Failed to load scores';
}
this.loading = false;
this.render();
}
private async loadContributor(did: string) {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/scores/${encodeURIComponent(did)}?space=${this.space}`);
if (!res.ok) throw new Error('Not found');
this.selectedContributor = await res.json();
this.view = 'contributor';
this.error = '';
} catch (e) {
this.error = 'Contributor not found';
}
this.loading = false;
this.render();
}
private async loadConfig() {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/config?space=${this.space}`);
this.config = await res.json();
this.view = 'config';
this.error = '';
} catch (e) {
this.error = 'Failed to load config';
}
this.loading = false;
this.render();
}
private async triggerRecompute() {
this.recomputing = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/recompute?space=${this.space}`, { method: 'POST' });
const result = await res.json();
if (result.success) {
await this.loadScores();
} else {
this.error = result.error || 'Recompute failed';
}
} catch (e) {
this.error = 'Recompute failed';
}
this.recomputing = false;
this.render();
}
private async saveWeights() {
if (!this.config) return;
try {
await authFetch(`/${this.space}/rcred/api/config?space=${this.space}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ weights: this.config.weights }),
});
this.error = '';
} catch (e) {
this.error = 'Failed to save config';
}
this.render();
}
private render() {
const timeAgo = (ts: number) => {
if (!ts) return 'never';
const diff = Date.now() - ts;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
};
let content = '';
if (this.view === 'leaderboard') {
content = this.renderLeaderboard(timeAgo);
} else if (this.view === 'contributor') {
content = this.renderContributor(timeAgo);
} else if (this.view === 'config') {
content = this.renderConfig();
}
this.shadow.innerHTML = `
<style>
:host { display:block; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
* { box-sizing:border-box; margin:0; padding:0; }
.container { max-width:900px; margin:0 auto; padding:1.5rem; }
.header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; flex-wrap:wrap; gap:0.75rem; }
.header h1 { font-size:1.5rem; display:flex; align-items:center; gap:0.5rem; }
.header-actions { display:flex; gap:0.5rem; align-items:center; }
.meta { font-size:0.75rem; color:#64748b; margin-bottom:1rem; }
button { background:#1e293b; color:#e2e8f0; border:1px solid #334155; border-radius:0.375rem;
padding:0.4rem 0.75rem; font-size:0.8rem; cursor:pointer; transition:background 0.15s; }
button:hover { background:#334155; }
button.primary { background:linear-gradient(to right,#f59e0b,#d97706); border:none; color:#000; font-weight:600; }
button.primary:hover { opacity:0.9; }
button:disabled { opacity:0.5; cursor:not-allowed; }
.tab-bar { display:flex; gap:0.25rem; margin-bottom:1rem; }
.tab { padding:0.4rem 0.75rem; border-radius:0.375rem; cursor:pointer; font-size:0.8rem;
background:transparent; border:1px solid transparent; color:#94a3b8; }
.tab:hover { background:#1e293b; }
.tab.active { background:#1e293b; color:#fbbf24; border-color:#334155; }
table { width:100%; border-collapse:collapse; font-size:0.85rem; }
th { text-align:left; padding:0.5rem 0.75rem; color:#94a3b8; font-weight:500; border-bottom:1px solid #1e293b; font-size:0.75rem; text-transform:uppercase; letter-spacing:0.05em; }
td { padding:0.6rem 0.75rem; border-bottom:1px solid rgba(30,41,59,0.5); }
tr:hover td { background:rgba(30,41,59,0.4); }
tr { cursor:pointer; }
.rank-cell { color:#fbbf24; font-weight:700; width:3rem; text-align:center; }
.cred-cell { color:#fbbf24; font-weight:600; }
.grain-cell { color:#d97706; }
.module-badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:0.25rem; font-size:0.7rem; font-weight:500; }
.empty { text-align:center; padding:3rem; color:#64748b; }
.empty h3 { font-size:1.1rem; color:#94a3b8; margin-bottom:0.5rem; }
.error { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:0.375rem; padding:0.5rem 0.75rem; color:#f87171; font-size:0.8rem; margin-bottom:1rem; }
.loading { text-align:center; padding:2rem; color:#64748b; }
/* Contributor detail */
.detail-header { margin-bottom:1.5rem; }
.detail-header h2 { font-size:1.3rem; margin-bottom:0.25rem; }
.detail-stats { display:flex; gap:1.5rem; margin-top:0.75rem; flex-wrap:wrap; }
.stat-card { background:#0f172a; border:1px solid #1e293b; border-radius:0.5rem; padding:0.75rem 1rem; min-width:120px; }
.stat-value { font-size:1.5rem; font-weight:700; }
.stat-label { font-size:0.7rem; color:#64748b; text-transform:uppercase; letter-spacing:0.05em; margin-top:0.25rem; }
.breakdown { margin-top:1.5rem; }
.breakdown h3 { font-size:0.95rem; margin-bottom:0.75rem; color:#94a3b8; }
.bar-row { display:flex; align-items:center; gap:0.5rem; margin-bottom:0.4rem; }
.bar-label { width:60px; font-size:0.75rem; color:#94a3b8; text-align:right; }
.bar-track { flex:1; height:20px; background:#0f172a; border-radius:0.25rem; overflow:hidden; }
.bar-fill { height:100%; border-radius:0.25rem; display:flex; align-items:center; padding-left:0.4rem; font-size:0.65rem; font-weight:600; min-width:fit-content; }
.bar-pct { width:40px; font-size:0.75rem; color:#64748b; }
/* Config */
.weight-row { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem; padding:0.4rem 0; }
.weight-label { width:160px; font-size:0.8rem; color:#94a3b8; }
.weight-slider { flex:1; -webkit-appearance:none; height:6px; background:#1e293b; border-radius:3px; outline:none; }
.weight-slider::-webkit-slider-thumb { -webkit-appearance:none; width:16px; height:16px; border-radius:50%; background:#f59e0b; cursor:pointer; }
.weight-value { width:40px; text-align:right; font-size:0.85rem; font-weight:600; color:#fbbf24; }
@media (max-width:640px) {
.container { padding:1rem; }
.detail-stats { gap:0.75rem; }
.stat-card { min-width:90px; padding:0.5rem 0.75rem; }
.stat-value { font-size:1.2rem; }
}
</style>
<div class="container">${content}</div>`;
// Re-attach event listeners
this.shadow.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).dataset.action!;
if (action === 'recompute') this.triggerRecompute();
else if (action === 'config') this.loadConfig();
else if (action === 'leaderboard') { this.view = 'leaderboard'; this.render(); }
else if (action.startsWith('contributor:')) this.loadContributor(action.slice(12));
else if (action === 'save-weights') this.saveWeights();
});
});
// Weight slider listeners
this.shadow.querySelectorAll('input[data-weight]').forEach(el => {
el.addEventListener('input', (e) => {
const input = e.target as HTMLInputElement;
const key = input.dataset.weight!;
if (this.config?.weights[key]) {
this.config.weights[key].weight = parseFloat(input.value);
const valueEl = this.shadow.querySelector(`[data-weight-value="${key}"]`);
if (valueEl) valueEl.textContent = input.value;
}
});
});
}
private renderLeaderboard(timeAgo: (ts: number) => string): string {
if (this.loading) return '<div class="loading">Loading scores...</div>';
const hasScores = this.scores.length > 0;
return `
<div class="header">
<h1><span style="font-size:1.8rem"></span> Contribution Recognition</h1>
<div class="header-actions">
<button data-action="config">Weights</button>
<button class="primary" data-action="recompute" ${this.recomputing ? 'disabled' : ''}>
${this.recomputing ? 'Computing...' : 'Recompute'}
</button>
</div>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div class="meta">
${hasScores ? `Last computed: ${timeAgo(this.computedAt)} · ${this.scores.length} contributors · ${this.totalCred} total cred` : ''}
</div>
${hasScores ? `
<table>
<thead>
<tr>
<th style="width:3rem;text-align:center">#</th>
<th>Contributor</th>
<th style="text-align:right">Cred </th>
<th style="text-align:right">Grain 🌾</th>
<th>Top Module</th>
<th style="text-align:right">Active</th>
</tr>
</thead>
<tbody>
${this.scores.map(s => `
<tr data-action="contributor:${s.did}">
<td class="rank-cell">${s.rank}</td>
<td style="font-weight:500">${this.escapeHtml(s.label || s.did.slice(0, 20))}</td>
<td class="cred-cell" style="text-align:right">${s.cred}</td>
<td class="grain-cell" style="text-align:right">${s.grainLifetime}</td>
<td>
${s.topModule ? `<span class="module-badge" style="background:${MODULE_COLORS[s.topModule] || '#334155'}22;color:${MODULE_COLORS[s.topModule] || '#94a3b8'}">${MODULE_LABELS[s.topModule] || s.topModule}</span>` : '—'}
</td>
<td style="text-align:right;color:#64748b;font-size:0.8rem">${timeAgo(s.lastActive)}</td>
</tr>`).join('')}
</tbody>
</table>
` : `
<div class="empty">
<h3>No scores yet</h3>
<p>Click <strong>Recompute</strong> to build the contribution graph and run CredRank.</p>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#475569">
Scores are generated from activity across Tasks, Docs, Chats, Calendar, Voting, Flows, Time, and Wallet modules.
</p>
</div>
`}`;
}
private renderContributor(timeAgo: (ts: number) => string): string {
if (this.loading) return '<div class="loading">Loading contributor...</div>';
const c = this.selectedContributor;
if (!c) return '<div class="error">Contributor not found</div>';
const breakdown = Object.entries(c.breakdown).sort(([, a], [, b]) => b - a);
return `
<div class="tab-bar">
<div class="tab" data-action="leaderboard"> Back</div>
</div>
<div class="detail-header">
<h2>${this.escapeHtml(c.label || c.did.slice(0, 20))}</h2>
<div style="font-size:0.8rem;color:#64748b;word-break:break-all">${this.escapeHtml(c.did)}</div>
<div class="detail-stats">
<div class="stat-card">
<div class="stat-value" style="color:#fbbf24">${c.cred}</div>
<div class="stat-label">Cred </div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#d97706">${c.grainLifetime}</div>
<div class="stat-label">Grain 🌾</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#94a3b8">${timeAgo(c.lastActive)}</div>
<div class="stat-label">Last Active</div>
</div>
</div>
</div>
<div class="breakdown">
<h3>Contribution Breakdown</h3>
${breakdown.map(([mod, pct]) => `
<div class="bar-row">
<div class="bar-label">${MODULE_LABELS[mod] || mod}</div>
<div class="bar-track">
<div class="bar-fill" style="width:${Math.max(pct, 2)}%;background:${MODULE_COLORS[mod] || '#64748b'}">
${pct >= 10 ? MODULE_LABELS[mod] || mod : ''}
</div>
</div>
<div class="bar-pct">${pct}%</div>
</div>`).join('')}
</div>`;
}
private renderConfig(): string {
if (this.loading) return '<div class="loading">Loading config...</div>';
if (!this.config) return '<div class="error">Config not loaded</div>';
const weights = Object.entries(this.config.weights).sort(([, a], [, b]) => b.weight - a.weight);
return `
<div class="tab-bar">
<div class="tab" data-action="leaderboard"> Back</div>
<div class="tab active">Weight Configuration</div>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div style="margin-bottom:1.5rem">
<h2 style="font-size:1.2rem;margin-bottom:0.25rem">Contribution Weights</h2>
<p style="font-size:0.8rem;color:#64748b">Adjust how much each type of contribution counts toward Cred. Changes take effect on next recompute.</p>
</div>
${weights.map(([key, w]) => `
<div class="weight-row">
<div class="weight-label" title="${this.escapeHtml(w.description)}">${this.escapeHtml(w.description)}</div>
<input type="range" class="weight-slider" min="0" max="10" step="0.1" value="${w.weight}" data-weight="${key}">
<div class="weight-value" data-weight-value="${key}">${w.weight}</div>
</div>`).join('')}
<div style="margin-top:1.5rem;display:flex;gap:0.5rem">
<button class="primary" data-action="save-weights">Save Weights</button>
<button data-action="leaderboard">Cancel</button>
</div>
<div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #1e293b">
<h3 style="font-size:0.95rem;color:#94a3b8;margin-bottom:0.75rem">Grain Parameters</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.8rem;color:#64748b">
<div>Grain per epoch: <strong style="color:#e2e8f0">${this.config.grainPerEpoch}</strong></div>
<div>Epoch length: <strong style="color:#e2e8f0">${this.config.epochLengthDays} days</strong></div>
<div>Slow fraction: <strong style="color:#e2e8f0">${(this.config.slowFraction * 100).toFixed(0)}%</strong></div>
<div>Fast fraction: <strong style="color:#e2e8f0">${(this.config.fastFraction * 100).toFixed(0)}%</strong></div>
<div>Damping factor: <strong style="color:#e2e8f0">${this.config.dampingFactor}</strong></div>
<div>Lookback: <strong style="color:#e2e8f0">${this.config.lookbackDays} days</strong></div>
</div>
</div>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
customElements.define('folk-cred-dashboard', FolkCredDashboard);

127
modules/rcred/credrank.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* CredRank power iteration (modified PageRank) on a contribution graph.
*
* Pure function, no side effects. Takes nodes + edges + config,
* returns stationary distribution (nodeId raw cred score).
*
* Algorithm:
* 1. Build adjacency from edges (weighted directed graph)
* 2. Normalize outgoing weights transition probabilities
* 3. Seed vector from contribution node weights
* 4. Power iteration: π' = (1-α) × M^T × π + α × seed
* 5. Return full distribution
*/
import type { CredNode, CredEdge, CredConfigDoc } from './schemas';
const MAX_ITERATIONS = 50;
const CONVERGENCE_THRESHOLD = 1e-6;
/**
* Compute CredRank scores via power iteration.
*
* @returns Map<nodeId, rawCredScore> stationary distribution summing to ~1.0
*/
export function computeCredRank(
nodes: CredNode[],
edges: CredEdge[],
config: CredConfigDoc,
): Map<string, number> {
const n = nodes.length;
if (n === 0) return new Map();
// Build node index
const nodeIndex = new Map<string, number>();
for (let i = 0; i < n; i++) {
nodeIndex.set(nodes[i].id, i);
}
// Build adjacency: outgoing[i] = [(targetIndex, weight)]
const outgoing: Array<Array<[number, number]>> = Array.from({ length: n }, () => []);
for (const edge of edges) {
const fromIdx = nodeIndex.get(edge.from);
const toIdx = nodeIndex.get(edge.to);
if (fromIdx === undefined || toIdx === undefined) continue;
outgoing[fromIdx].push([toIdx, edge.weight]);
}
// Normalize outgoing weights → transition probabilities
const transition: Array<Array<[number, number]>> = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const out = outgoing[i];
if (out.length === 0) continue;
const totalWeight = out.reduce((s, [, w]) => s + w, 0);
if (totalWeight <= 0) continue;
for (const [target, weight] of out) {
transition[i].push([target, weight / totalWeight]);
}
}
// Build seed vector from node weights (contribution nodes carry configured weight)
const seed = new Float64Array(n);
let seedSum = 0;
for (let i = 0; i < n; i++) {
const node = nodes[i];
if (node.type === 'contribution' && node.weight > 0) {
seed[i] = node.weight;
seedSum += node.weight;
}
}
// Normalize seed to sum to 1
if (seedSum > 0) {
for (let i = 0; i < n; i++) seed[i] /= seedSum;
} else {
// Uniform seed if no weighted contributions
const uniform = 1 / n;
for (let i = 0; i < n; i++) seed[i] = uniform;
}
// Initialize π uniformly
let pi = new Float64Array(n);
const initVal = 1 / n;
for (let i = 0; i < n; i++) pi[i] = initVal;
const alpha = config.dampingFactor; // teleportation probability
// Power iteration
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
const piNext = new Float64Array(n);
// Matrix-vector multiply: piNext[j] += (1-α) × transition[i→j] × pi[i]
for (let i = 0; i < n; i++) {
if (pi[i] === 0) continue;
const neighbors = transition[i];
if (neighbors.length === 0) {
// Dangling node — distribute uniformly (standard PageRank)
const share = (1 - alpha) * pi[i] / n;
for (let j = 0; j < n; j++) piNext[j] += share;
} else {
for (const [j, prob] of neighbors) {
piNext[j] += (1 - alpha) * prob * pi[i];
}
}
}
// Add teleportation (seed-weighted)
for (let i = 0; i < n; i++) {
piNext[i] += alpha * seed[i];
}
// Check convergence (L1 norm)
let delta = 0;
for (let i = 0; i < n; i++) delta += Math.abs(piNext[i] - pi[i]);
pi = piNext;
if (delta < CONVERGENCE_THRESHOLD) break;
}
// Build result map
const result = new Map<string, number>();
for (let i = 0; i < n; i++) {
result.set(nodes[i].id, pi[i]);
}
return result;
}

View File

@ -0,0 +1,343 @@
/**
* rCred Grain Engine token setup + distribution.
*
* Creates a GRAIN token per space and distributes it proportional
* to CredRank scores using an 80/20 slow/fast split.
*
* Orchestrates the full recompute pipeline: collect rank store distribute.
*/
import * as Automerge from '@automerge/automerge';
import type { SyncServer } from '../../server/local-first/sync-server';
import { mintTokens, getTokenDoc, getBalance, getAllBalances } from '../../server/token-service';
import { tokenDocId, tokenLedgerSchema } from '../../server/token-schemas';
import type { TokenLedgerDoc } from '../../server/token-schemas';
import { collectContribGraph } from './graph-collector';
import { computeCredRank } from './credrank';
import type {
CredConfigDoc, CredScoresDoc, ContribGraphDoc,
CredScore, CredNode,
} from './schemas';
import {
graphDocId, scoresDocId, configDocId,
graphSchema, scoresSchema, configSchema,
} from './schemas';
/** In-memory lock to prevent concurrent recomputes per space. */
const runningSpaces = new Set<string>();
/**
* Ensure the grain token doc exists for a space.
* Returns the token ID (grain-{space}).
*/
export function ensureGrainToken(space: string, syncServer: SyncServer): string {
const tokenId = `grain-${space}`;
const docId = tokenDocId(tokenId);
let doc = syncServer.getDoc<TokenLedgerDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init grain ledger', (d) => {
const init = tokenLedgerSchema.init();
Object.assign(d, init);
});
syncServer.setDoc(docId, doc);
}
if (!doc.token.name) {
syncServer.changeDoc<TokenLedgerDoc>(docId, `define GRAIN token for ${space}`, (d) => {
d.token.id = tokenId;
d.token.name = `Grain (${space})`;
d.token.symbol = 'GRAIN';
d.token.decimals = 0;
d.token.description = `Contribution recognition token for the ${space} space — earned via CredRank`;
d.token.icon = '🌾';
d.token.color = '#d97706';
d.token.createdAt = Date.now();
d.token.createdBy = 'rcred';
});
}
return tokenId;
}
/**
* Distribute grain to contributors based on cred scores.
* Uses 80/20 slow/fast split per SourceCred grain distribution model.
*/
export function distributeGrain(
space: string,
syncServer: SyncServer,
config: CredConfigDoc,
scores: CredScoresDoc,
): { distributed: number; recipients: number } {
const tokenId = config.grainTokenId || `grain-${space}`;
const tokenDoc = getTokenDoc(tokenId);
if (!tokenDoc) return { distributed: 0, recipients: 0 };
const pool = config.grainPerEpoch;
const slowPool = Math.floor(pool * config.slowFraction);
const fastPool = pool - slowPool;
const scoreEntries = Object.values(scores.scores).filter(s => s.cred > 0);
if (scoreEntries.length === 0) return { distributed: 0, recipients: 0 };
// ── Fast distribution: proportional to current epoch cred ──
const totalEpochCred = scoreEntries.reduce((s, e) => s + e.cred, 0);
const fastPayouts = new Map<string, number>();
if (totalEpochCred > 0) {
for (const entry of scoreEntries) {
const payout = Math.floor(fastPool * (entry.cred / totalEpochCred));
if (payout > 0) fastPayouts.set(entry.did, payout);
}
}
// ── Slow distribution: lifetime equity catch-up ──
// Total slow grain ever minted for this epoch calculation
const existingBalances = getAllBalances(tokenDoc);
const totalLifetimeCred = scoreEntries.reduce((s, e) => s + (e.rawScore || e.cred), 0);
// Estimate total slow grain minted so far from existing supplies
const totalSlowMinted = (tokenDoc.token.totalSupply || 0) * config.slowFraction + slowPool;
const slowPayouts = new Map<string, number>();
if (totalLifetimeCred > 0) {
let totalSlowDue = 0;
const dues = new Map<string, number>();
for (const entry of scoreEntries) {
const lifetimeFraction = (entry.rawScore || entry.cred) / totalLifetimeCred;
const due = totalSlowMinted * lifetimeFraction;
const alreadyReceived = entry.grainLifetime;
const payout = Math.max(0, due - alreadyReceived);
dues.set(entry.did, payout);
totalSlowDue += payout;
}
// Scale down if total due exceeds pool
const scale = totalSlowDue > slowPool ? slowPool / totalSlowDue : 1;
for (const [did, payout] of dues) {
const scaled = Math.floor(payout * scale);
if (scaled > 0) slowPayouts.set(did, scaled);
}
}
// ── Mint combined payouts ──
let distributed = 0;
let recipients = 0;
const epochId = scores.epochId;
const allDids = new Set([...fastPayouts.keys(), ...slowPayouts.keys()]);
for (const did of allDids) {
const fast = fastPayouts.get(did) || 0;
const slow = slowPayouts.get(did) || 0;
const total = fast + slow;
if (total <= 0) continue;
const label = scores.scores[did]?.label || did.slice(0, 16);
const success = mintTokens(
tokenId, did, label, total,
`Grain epoch ${epochId}: ${fast} fast + ${slow} slow`,
'rcred',
);
if (success) {
distributed += total;
recipients++;
}
}
return { distributed, recipients };
}
/**
* Ensure the config doc exists with defaults.
*/
export function ensureConfigDoc(space: string, syncServer: SyncServer): CredConfigDoc {
const docId = configDocId(space);
let doc = syncServer.getDoc<CredConfigDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CredConfigDoc>(), 'init rcred config', (d) => {
const init = configSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.grainTokenId = `grain-${space}`;
});
syncServer.setDoc(docId, doc);
}
return doc;
}
/**
* Full recompute pipeline for a space:
* 1. Ensure config + grain token
* 2. Collect contribution graph
* 3. Run CredRank
* 4. Post-process into CredScores
* 5. Store graph + scores docs
* 6. Distribute grain
*/
export function recomputeSpace(
space: string,
syncServer: SyncServer,
): { success: boolean; scores: number; grain: number; error?: string } {
// Prevent concurrent runs
if (runningSpaces.has(space)) {
return { success: false, scores: 0, grain: 0, error: 'Already running' };
}
runningSpaces.add(space);
try {
// 1. Config + token
const config = ensureConfigDoc(space, syncServer);
if (!config.enabled) {
return { success: false, scores: 0, grain: 0, error: 'CredRank disabled' };
}
ensureGrainToken(space, syncServer);
// 2. Collect graph
const { nodes, edges } = collectContribGraph(space, syncServer, config);
if (nodes.length === 0) {
return { success: true, scores: 0, grain: 0 };
}
// 3. Run CredRank
const rawScores = computeCredRank(nodes, edges, config);
// 4. Post-process: filter to contributors, normalize, build breakdown
const contributors = nodes.filter(n => n.type === 'contributor');
const maxRaw = Math.max(...contributors.map(c => rawScores.get(c.id) || 0), 1e-10);
const epochId = `epoch-${Date.now()}`;
const prevScores = syncServer.getDoc<CredScoresDoc>(scoresDocId(space));
const credScores: Record<string, CredScore> = {};
let totalCred = 0;
for (const contributor of contributors) {
const raw = rawScores.get(contributor.id) || 0;
const cred = Math.round((raw / maxRaw) * 1000);
if (cred === 0) continue;
const did = contributor.did || contributor.id.replace('contributor:', '');
// Breakdown by source module: sum raw scores of connected contribution nodes
const breakdown: Record<string, number> = {};
for (const edge of edges) {
if (edge.from !== contributor.id) continue;
const targetNode = nodes.find(n => n.id === edge.to);
if (targetNode?.sourceModule) {
breakdown[targetNode.sourceModule] = (breakdown[targetNode.sourceModule] || 0) + (rawScores.get(targetNode.id) || 0);
}
}
// Normalize breakdown to percentages
const breakdownTotal = Object.values(breakdown).reduce((s, v) => s + v, 0) || 1;
for (const mod of Object.keys(breakdown)) {
breakdown[mod] = Math.round((breakdown[mod] / breakdownTotal) * 100);
}
// Carry forward lifetime grain from previous epoch
const prevEntry = prevScores?.scores[did];
const grainLifetime = prevEntry?.grainLifetime || 0;
credScores[did] = {
did,
label: contributor.label || did.slice(0, 16),
cred,
rawScore: raw,
grainLifetime,
epochScores: { ...(prevEntry?.epochScores || {}), [epochId]: cred },
breakdown,
lastActive: contributor.timestamp || Date.now(),
};
totalCred += cred;
}
// 5. Store graph doc
const gDocId = graphDocId(space);
let gDoc = syncServer.getDoc<ContribGraphDoc>(gDocId);
if (!gDoc) {
gDoc = Automerge.change(Automerge.init<ContribGraphDoc>(), 'init rcred graph', (d) => {
const init = graphSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(gDocId, gDoc);
}
syncServer.changeDoc<ContribGraphDoc>(gDocId, 'Update contribution graph', (d) => {
// Clear and repopulate
const nodeKeys = Object.keys(d.nodes);
for (const k of nodeKeys) delete d.nodes[k];
const edgeKeys = Object.keys(d.edges);
for (const k of edgeKeys) delete d.edges[k];
for (const node of nodes) d.nodes[node.id] = node;
for (const edge of edges) d.edges[edge.id] = edge;
d.lastBuiltAt = Date.now();
d.stats = {
nodeCount: nodes.length,
edgeCount: edges.length,
contributorCount: contributors.length,
contributionCount: nodes.length - contributors.length,
};
});
// 6. Store scores doc
const sDocId = scoresDocId(space);
let sDoc = syncServer.getDoc<CredScoresDoc>(sDocId);
if (!sDoc) {
sDoc = Automerge.change(Automerge.init<CredScoresDoc>(), 'init rcred scores', (d) => {
const init = scoresSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(sDocId, sDoc);
}
syncServer.changeDoc<CredScoresDoc>(sDocId, `CredRank epoch ${epochId}`, (d) => {
const scoreKeys = Object.keys(d.scores);
for (const k of scoreKeys) delete d.scores[k];
for (const [did, score] of Object.entries(credScores)) {
d.scores[did] = score;
}
d.totalCred = totalCred;
d.computedAt = Date.now();
d.epochId = epochId;
});
// 7. Distribute grain
const updatedScores = syncServer.getDoc<CredScoresDoc>(sDocId)!;
const grainResult = distributeGrain(space, syncServer, config, updatedScores);
// Update grain lifetime in scores
if (grainResult.distributed > 0) {
const tokenId = config.grainTokenId || `grain-${space}`;
const tokenDoc = getTokenDoc(tokenId);
if (tokenDoc) {
syncServer.changeDoc<CredScoresDoc>(sDocId, 'Update grain lifetime', (d) => {
for (const did of Object.keys(d.scores)) {
d.scores[did].grainLifetime = getBalance(tokenDoc!, did);
}
});
}
}
// Update config last epoch
syncServer.changeDoc<CredConfigDoc>(configDocId(space), 'Update last epoch', (d) => {
d.lastEpochAt = Date.now();
});
console.log(`[rCred] Recomputed ${space}: ${Object.keys(credScores).length} contributors, ${totalCred} total cred, ${grainResult.distributed} grain to ${grainResult.recipients} recipients`);
return {
success: true,
scores: Object.keys(credScores).length,
grain: grainResult.distributed,
};
} catch (err) {
console.error(`[rCred] Recompute failed for ${space}:`, err);
return { success: false, scores: 0, grain: 0, error: String(err) };
} finally {
runningSpaces.delete(space);
}
}

View File

@ -0,0 +1,479 @@
/**
* rCred Graph Collector builds a contribution graph from all module activity.
*
* Reads Automerge docs via syncServer.getDoc()/listDocs(). Pure function:
* no side effects, no writes, no mutations.
*
* Collects from 8 rApp modules: rTasks, rDocs, rChats, rCal, rVote, rFlows, rTime, rWallet.
*/
import type { SyncServer } from '../../server/local-first/sync-server';
import type { CredNode, CredEdge, CredConfigDoc, ContributionTypeWeight } from './schemas';
// ── Module doc types (imported for type safety) ──
import type { BoardDoc, TaskItem } from '../rtasks/schemas';
import { boardDocId } from '../rtasks/schemas';
import type { NotebookDoc, NoteItem } from '../rdocs/schemas';
import type { ChatChannelDoc, ChatMessage } from '../rchats/schemas';
import type { CalendarDoc, CalendarEvent } from '../rcal/schemas';
import type { ProposalDoc } from '../rvote/schemas';
import type { FlowsDoc } from '../rflows/schemas';
import type { CommitmentsDoc, Commitment } from '../rtime/schemas';
import { commitmentsDocId } from '../rtime/schemas';
import type { IntentsDoc, SolverResultsDoc } from '../rtime/schemas-intent';
import type { WalletDoc } from '../rwallet/schemas';
import { walletDocId } from '../rwallet/schemas';
interface CollectResult {
nodes: CredNode[];
edges: CredEdge[];
}
/** Maximum chat messages processed per channel to bound graph size. */
const MAX_MESSAGES_PER_CHANNEL = 500;
/** Cutoff timestamp based on config.lookbackDays. */
function cutoff(config: CredConfigDoc): number {
return Date.now() - config.lookbackDays * 86_400_000;
}
/** Resolve weight for a contribution type from config. */
function w(config: CredConfigDoc, type: string): number {
return config.weights[type]?.weight ?? 1.0;
}
/** Make a contributor node ID from a DID. */
function contributorId(did: string): string {
return `contributor:${did}`;
}
/** Make a contribution node ID. */
function contribId(module: string, type: string, id: string): string {
return `contribution:${module}:${type}:${id}`;
}
/** Make a contributor node. */
function makeContributor(did: string, label: string): CredNode {
return {
id: contributorId(did),
type: 'contributor',
did,
label,
sourceModule: '',
contributionType: '',
timestamp: 0,
weight: 0,
};
}
/** Make a contribution node. */
function makeContribution(
module: string, type: string, id: string, label: string,
timestamp: number, config: CredConfigDoc,
): CredNode {
return {
id: contribId(module, type, id),
type: 'contribution',
label,
sourceModule: module,
contributionType: type,
timestamp,
weight: w(config, type),
};
}
/** Make an edge. */
function makeEdge(
from: string, to: string, edgeType: CredEdge['type'], weight: number,
): CredEdge {
const id = `edge:${from}${to}`;
return { id, from, to, type: edgeType, weight };
}
// ── Collectors ──
function collectTasks(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docIds = syncServer.listDocs().filter(id => id.startsWith(`${space}:tasks:boards:`));
for (const docId of docIds) {
const doc = syncServer.getDoc<BoardDoc>(docId);
if (!doc?.tasks) continue;
for (const task of Object.values(doc.tasks)) {
if (task.createdAt < cut) continue;
const creator = task.createdBy;
if (!creator) continue;
// Task created
const cNode = makeContribution('rtasks', 'task-created', task.id, task.title, task.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(creator, ''));
edges.push(makeEdge(contributorId(creator), cNode.id, 'authored', w(config, 'task-created')));
// Task completed (different from creation — higher weight)
if (task.status === 'DONE' && task.assigneeId) {
const doneNode = makeContribution('rtasks', 'task-completed', task.id, `Completed: ${task.title}`, task.updatedAt, config);
nodes.push(doneNode);
nodes.push(makeContributor(task.assigneeId, ''));
edges.push(makeEdge(contributorId(task.assigneeId), doneNode.id, 'completed', w(config, 'task-completed')));
}
}
}
return { nodes, edges };
}
function collectDocs(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docIds = syncServer.listDocs().filter(id => id.startsWith(`${space}:notes:notebooks:`));
for (const docId of docIds) {
const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc?.items) continue;
for (const item of Object.values(doc.items)) {
if (item.createdAt < cut) continue;
const author = item.authorId;
if (!author) continue;
// Doc authored
const cNode = makeContribution('rdocs', 'doc-authored', item.id, item.title || 'Untitled', item.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(author, ''));
edges.push(makeEdge(contributorId(author), cNode.id, 'authored', w(config, 'doc-authored')));
// Comments on this doc
if (item.comments) {
for (const thread of Object.values(item.comments)) {
if (!thread.messages) continue;
for (const msg of Object.values(thread.messages)) {
const commentAuthor = (msg as any).authorId;
if (!commentAuthor || (msg as any).createdAt < cut) continue;
const commentNode = makeContribution('rdocs', 'comment-authored', (msg as any).id || `${item.id}-comment`, 'Comment', (msg as any).createdAt, config);
nodes.push(commentNode);
nodes.push(makeContributor(commentAuthor, ''));
edges.push(makeEdge(contributorId(commentAuthor), commentNode.id, 'authored', w(config, 'comment-authored')));
// Comment → doc edge
edges.push(makeEdge(commentNode.id, cNode.id, 'commented-on', 0.5));
}
}
}
}
}
return { nodes, edges };
}
function collectChats(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docIds = syncServer.listDocs().filter(id => id.startsWith(`${space}:chats:channel:`));
for (const docId of docIds) {
const doc = syncServer.getDoc<ChatChannelDoc>(docId);
if (!doc?.messages) continue;
const messages = Object.values(doc.messages);
// Sort by time descending, cap at MAX_MESSAGES_PER_CHANNEL
const sorted = messages
.filter(m => m.createdAt >= cut)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, MAX_MESSAGES_PER_CHANNEL);
for (const msg of sorted) {
if (!msg.authorId) continue;
// Message sent
const cNode = makeContribution('rchats', 'message-sent', msg.id, msg.content?.slice(0, 60) || 'Message', msg.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(msg.authorId, msg.authorName || ''));
edges.push(makeEdge(contributorId(msg.authorId), cNode.id, 'authored', w(config, 'message-sent')));
// Reactions on this message
if (msg.reactions) {
for (const [_emoji, reactors] of Object.entries(msg.reactions)) {
if (!Array.isArray(reactors)) continue;
for (const reactorDid of reactors) {
const rNode = makeContribution('rchats', 'reaction-given', `${msg.id}-react-${reactorDid}`, 'Reaction', msg.createdAt, config);
nodes.push(rNode);
nodes.push(makeContributor(reactorDid, ''));
edges.push(makeEdge(contributorId(reactorDid), rNode.id, 'authored', w(config, 'reaction-given')));
edges.push(makeEdge(rNode.id, cNode.id, 'reacted-to', 0.2));
}
}
}
}
}
return { nodes, edges };
}
function collectCal(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docId = `${space}:cal:events`;
const doc = syncServer.getDoc<CalendarDoc>(docId);
if (!doc?.events) return { nodes, edges };
for (const event of Object.values(doc.events)) {
if (event.createdAt < cut) continue;
// Event scheduled — use sourceId as creator hint or skip
// CalendarEvent has no explicit 'createdBy' field, but sourceType/sourceId gives provenance
// For manually created events, metadata may contain the DID
const scheduledBy = (event as any).scheduledBy || (event.metadata as any)?.createdBy;
if (scheduledBy) {
const cNode = makeContribution('rcal', 'event-scheduled', event.id, event.title, event.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(scheduledBy, ''));
edges.push(makeEdge(contributorId(scheduledBy), cNode.id, 'authored', w(config, 'event-scheduled')));
}
// Event attended
if (event.attendees) {
const eventNode = makeContribution('rcal', 'event-scheduled', event.id, event.title, event.createdAt, config);
// Only add if not already added above
if (!scheduledBy) nodes.push(eventNode);
for (const attendee of event.attendees) {
if (attendee.status !== 'yes') continue;
// Attendees have email not DID — use hashed email as contributor
const attendeeDid = attendee.email
? `email:${simpleHash(attendee.email)}`
: `anon:${simpleHash(attendee.name)}`;
const attendLabel = attendee.name || attendee.email || 'Attendee';
const aNode = makeContribution('rcal', 'event-attended', `${event.id}-${attendeeDid}`, `Attended: ${event.title}`, attendee.respondedAt || event.startTime, config);
nodes.push(aNode);
nodes.push(makeContributor(attendeeDid, attendLabel));
edges.push(makeEdge(contributorId(attendeeDid), aNode.id, 'attended', w(config, 'event-attended')));
edges.push(makeEdge(aNode.id, eventNode.id, 'attended', 0.5));
}
}
}
return { nodes, edges };
}
function collectVotes(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docIds = syncServer.listDocs().filter(id => id.startsWith(`${space}:vote:proposals:`));
for (const docId of docIds) {
const doc = syncServer.getDoc<ProposalDoc>(docId);
if (!doc?.proposal) continue;
const prop = doc.proposal;
if (prop.createdAt < cut) continue;
// Proposal authored
if (prop.authorId) {
const cNode = makeContribution('rvote', 'proposal-authored', prop.id, prop.title, prop.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(prop.authorId, ''));
edges.push(makeEdge(contributorId(prop.authorId), cNode.id, 'authored', w(config, 'proposal-authored')));
// Votes on this proposal
if (doc.votes) {
for (const vote of Object.values(doc.votes)) {
if (!vote.userId || vote.createdAt < cut) continue;
const vNode = makeContribution('rvote', 'vote-cast', vote.id, `Vote on: ${prop.title}`, vote.createdAt, config);
nodes.push(vNode);
nodes.push(makeContributor(vote.userId, ''));
edges.push(makeEdge(contributorId(vote.userId), vNode.id, 'authored', w(config, 'vote-cast')));
edges.push(makeEdge(vNode.id, cNode.id, 'voted-on', 0.5));
}
}
// Final votes
if (doc.finalVotes) {
for (const fv of Object.values(doc.finalVotes)) {
if (!(fv as any).userId || (fv as any).createdAt < cut) continue;
const fvId = `fv-${prop.id}-${(fv as any).userId}`;
const fvNode = makeContribution('rvote', 'vote-cast', fvId, `Final vote: ${prop.title}`, (fv as any).createdAt, config);
nodes.push(fvNode);
nodes.push(makeContributor((fv as any).userId, ''));
edges.push(makeEdge(contributorId((fv as any).userId), fvNode.id, 'authored', w(config, 'vote-cast')));
edges.push(makeEdge(fvNode.id, cNode.id, 'voted-on', 0.5));
}
}
}
}
return { nodes, edges };
}
function collectFlows(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const docId = `${space}:flows:data`;
const doc = syncServer.getDoc<FlowsDoc>(docId);
if (!doc) return { nodes, edges };
// Canvas flows
if (doc.canvasFlows) {
for (const flow of Object.values(doc.canvasFlows)) {
if (flow.createdAt < cut || !flow.createdBy) continue;
const cNode = makeContribution('rflows', 'flow-created', flow.id, flow.name, flow.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(flow.createdBy, ''));
edges.push(makeEdge(contributorId(flow.createdBy), cNode.id, 'authored', w(config, 'flow-created')));
}
}
// Budget allocations
if (doc.budgetAllocations) {
for (const [allocId, alloc] of Object.entries(doc.budgetAllocations)) {
if (!alloc.participantDid || alloc.updatedAt < cut) continue;
const cNode = makeContribution('rflows', 'budget-allocated', allocId, 'Budget allocation', alloc.updatedAt, config);
nodes.push(cNode);
nodes.push(makeContributor(alloc.participantDid, ''));
edges.push(makeEdge(contributorId(alloc.participantDid), cNode.id, 'authored', w(config, 'budget-allocated')));
}
}
return { nodes, edges };
}
function collectTime(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
// Commitments
const cDoc = syncServer.getDoc<CommitmentsDoc>(commitmentsDocId(space));
if (cDoc?.items) {
for (const commitment of Object.values(cDoc.items)) {
if (commitment.createdAt < cut) continue;
const did = commitment.ownerDid;
if (!did) continue;
const cNode = makeContribution('rtime', 'commitment-created', commitment.id, `${commitment.hours}h ${commitment.skill}`, commitment.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(did, commitment.memberName));
edges.push(makeEdge(contributorId(did), cNode.id, 'authored', w(config, 'commitment-created')));
// Settled commitments get extra credit
if (commitment.status === 'settled') {
const sNode = makeContribution('rtime', 'settlement-completed', `settle-${commitment.id}`, `Settled: ${commitment.hours}h ${commitment.skill}`, commitment.createdAt, config);
nodes.push(sNode);
edges.push(makeEdge(contributorId(did), sNode.id, 'completed', w(config, 'settlement-completed')));
}
}
}
// Intents
const iDocId = `${space}:rtime:intents`;
const iDoc = syncServer.getDoc<IntentsDoc>(iDocId);
if (iDoc && (iDoc as any).intents) {
for (const intent of Object.values((iDoc as any).intents as Record<string, any>)) {
if (intent.createdAt < cut || !intent.memberId) continue;
const cNode = makeContribution('rtime', 'commitment-created', intent.id, `Intent: ${intent.skill} ${intent.hours}h`, intent.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(intent.memberId, intent.memberName || ''));
edges.push(makeEdge(contributorId(intent.memberId), cNode.id, 'authored', w(config, 'commitment-created')));
}
}
return { nodes, edges };
}
function collectWallet(space: string, syncServer: SyncServer, config: CredConfigDoc, cut: number): CollectResult {
const nodes: CredNode[] = [];
const edges: CredEdge[] = [];
const doc = syncServer.getDoc<WalletDoc>(walletDocId(space));
if (!doc) return { nodes, edges };
// Watched addresses
if (doc.watchedAddresses) {
for (const [addr, watched] of Object.entries(doc.watchedAddresses)) {
if (!watched.addedBy || watched.addedAt < cut) continue;
const cNode = makeContribution('rwallet', 'address-added', addr, `${watched.label || addr.slice(0, 10)}`, watched.addedAt, config);
nodes.push(cNode);
nodes.push(makeContributor(watched.addedBy, ''));
edges.push(makeEdge(contributorId(watched.addedBy), cNode.id, 'authored', w(config, 'address-added')));
}
}
// TX annotations
if (doc.annotations) {
for (const [txHash, annotation] of Object.entries(doc.annotations)) {
if (!annotation.authorDid || annotation.createdAt < cut) continue;
const cNode = makeContribution('rwallet', 'tx-annotated', txHash, annotation.note?.slice(0, 60) || 'Annotation', annotation.createdAt, config);
nodes.push(cNode);
nodes.push(makeContributor(annotation.authorDid, ''));
edges.push(makeEdge(contributorId(annotation.authorDid), cNode.id, 'authored', w(config, 'tx-annotated')));
}
}
return { nodes, edges };
}
// ── Main collector ──
/**
* Build the full contribution graph for a space.
* Pure function reads only, no writes.
*/
export function collectContribGraph(
space: string,
syncServer: SyncServer,
config: CredConfigDoc,
): { nodes: CredNode[]; edges: CredEdge[] } {
const cut = cutoff(config);
const allNodes: CredNode[] = [];
const allEdges: CredEdge[] = [];
const collectors = [
collectTasks, collectDocs, collectChats, collectCal,
collectVotes, collectFlows, collectTime, collectWallet,
];
for (const collect of collectors) {
const { nodes, edges } = collect(space, syncServer, config, cut);
allNodes.push(...nodes);
allEdges.push(...edges);
}
// Deduplicate contributor nodes (same DID from multiple modules)
const nodeMap = new Map<string, CredNode>();
for (const node of allNodes) {
const existing = nodeMap.get(node.id);
if (existing) {
// Merge: keep the label if we got a better one
if (node.label && !existing.label) existing.label = node.label;
// Accumulate weight for contribution nodes
if (node.type === 'contribution') existing.weight = Math.max(existing.weight, node.weight);
} else {
nodeMap.set(node.id, node);
}
}
// Deduplicate edges (same source→target)
const edgeMap = new Map<string, CredEdge>();
for (const edge of allEdges) {
if (!edgeMap.has(edge.id)) {
edgeMap.set(edge.id, edge);
}
}
return {
nodes: Array.from(nodeMap.values()),
edges: Array.from(edgeMap.values()),
};
}
// ── Util ──
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}

187
modules/rcred/landing.ts Normal file
View File

@ -0,0 +1,187 @@
/**
* rCred landing page SourceCred-inspired contribution recognition.
*
* An ode to SourceCred (2018-2022) and a re-enlivening of the
* PageRank-for-community-contribution protocol.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#fbbf24;background:rgba(251,191,36,0.1);border-color:rgba(251,191,36,0.2)">
Part of the rSpace Ecosystem
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#fbbf24,#f59e0b,#d97706);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-size:2.5rem">
Contribution<br>Recognition
</h1>
<p class="rl-subtitle">
<strong style="color:#e2e8f0">CredRank</strong> builds a living graph of every contribution
across your community &mdash; docs written, tasks completed, events attended, votes cast &mdash;
and runs <strong style="color:#fbbf24">PageRank</strong> to surface who's actually creating value.
Then it pays them in <strong style="color:#fbbf24">Grain</strong>.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rcred" class="rl-cta-primary"
style="background:linear-gradient(to right,#f59e0b,#d97706);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Try the Demo
</span>
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
<!-- In Memoriam -->
<section class="rl-section" style="border-top:none">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">Origin Story</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Standing on the Shoulders of SourceCred
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
<strong style="color:#fbbf24">SourceCred</strong> (2018&ndash;2022) pioneered the idea that
community contributions could be measured through a weighted graph and PageRank.
The project is gone, but the algorithm endures. rCred carries the torch &mdash;
adapted for local-first CRDT communities where every action is already a document.
</p>
</div>
</div>
</section>
<!-- How it Works -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">How It Works</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
From Actions to Grain in Three Steps
</h2>
</div>
<div class="rl-grid-3">
<div class="rl-card" style="border:2px solid rgba(251,191,36,0.35);background:linear-gradient(to bottom right,rgba(251,191,36,0.08),rgba(251,191,36,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#f59e0b;display:flex;align-items:center;justify-content:center">
<span style="font-size:0.9rem">1</span>
</div>
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Graph Collection</h3>
</div>
<p>
Every action across <strong style="color:#e2e8f0">8 rApps</strong> becomes a node
in your contribution graph &mdash; tasks, docs, chats, calendar events, proposals,
flows, time commitments, and wallet activity. Contributors are connected to their work
through weighted edges.
</p>
</div>
<div class="rl-card" style="border:2px solid rgba(251,191,36,0.35);background:linear-gradient(to bottom right,rgba(251,191,36,0.08),rgba(251,191,36,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#f59e0b;display:flex;align-items:center;justify-content:center">
<span style="font-size:0.9rem">2</span>
</div>
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">CredRank</h3>
</div>
<p>
Modified <strong style="color:#e2e8f0">PageRank</strong> runs on the graph via
power iteration. High-value contributions (settled commitments, completed tasks)
carry more weight. Cred flows from contributions to their authors, normalized
to a 0&ndash;1000 scale. Community-configurable weights.
</p>
</div>
<div class="rl-card" style="border:2px solid rgba(251,191,36,0.35);background:linear-gradient(to bottom right,rgba(251,191,36,0.08),rgba(251,191,36,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#f59e0b;display:flex;align-items:center;justify-content:center">
<span style="font-size:0.9rem">3</span>
</div>
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Grain Distribution</h3>
</div>
<p>
<strong style="color:#e2e8f0">GRAIN tokens</strong> are minted proportional to Cred.
80% goes to <em>lifetime equity</em> (catch-up for long-term contributors) and
20% to <em>current epoch</em> (recent activity). Non-transferable, visible in rWallet.
Your community decides the emission rate.
</p>
</div>
</div>
</div>
</section>
<!-- Contribution Types -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">16 Types</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Every Action Counts
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
From chat messages (0.5x) to settled time commitments (4x), each contribution type
has a configurable weight. Space admins tune the weights; the algorithm does the rest.
</p>
</div>
<div class="rl-grid-2" style="max-width:720px;margin:0 auto">
<div class="rl-card" style="border-color:rgba(34,197,94,0.25)">
<h3 style="color:#22c55e;font-size:0.95rem;margin-bottom:0.5rem">High Impact (3&ndash;4x)</h3>
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
<li>Settlement completed &mdash; 4.0x</li>
<li>Task completed &mdash; 3.0x</li>
<li>Proposal authored &mdash; 3.0x</li>
</ul>
</div>
<div class="rl-card" style="border-color:rgba(59,130,246,0.25)">
<h3 style="color:#3b82f6;font-size:0.95rem;margin-bottom:0.5rem">Medium Impact (1.5&ndash;2x)</h3>
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
<li>Doc authored &mdash; 2.0x</li>
<li>Flow created &mdash; 2.0x</li>
<li>Event scheduled &mdash; 2.0x</li>
<li>Commitment created &mdash; 2.0x</li>
<li>Event attended &mdash; 1.5x</li>
<li>Budget allocated &mdash; 1.5x</li>
</ul>
</div>
<div class="rl-card" style="border-color:rgba(251,191,36,0.25)">
<h3 style="color:#fbbf24;font-size:0.95rem;margin-bottom:0.5rem">Standard (1x)</h3>
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
<li>Task created &mdash; 1.0x</li>
<li>Comment authored &mdash; 1.0x</li>
<li>Vote cast &mdash; 1.0x</li>
<li>Address added &mdash; 1.0x</li>
</ul>
</div>
<div class="rl-card" style="border-color:rgba(148,163,184,0.25)">
<h3 style="color:#94a3b8;font-size:0.95rem;margin-bottom:0.5rem">Light Touch (0.2&ndash;0.5x)</h3>
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
<li>Message sent &mdash; 0.5x</li>
<li>TX annotated &mdash; 0.5x</li>
<li>Reaction given &mdash; 0.2x</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Philosophy -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">Philosophy</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Emergent Value Recognition
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
Traditional organizations allocate rewards top-down. CredRank works bottom-up:
value emerges from the structure of contributions themselves. When someone's work
becomes a dependency for others, their cred rises naturally &mdash; no manager needed.
</p>
<p style="font-size:0.95rem;color:#64748b;max-width:640px;margin:1rem auto 0;font-style:italic">
"Make contributions visible. Let the graph speak." &mdash; SourceCred ethos
</p>
</div>
</div>
</section>
`;
}

122
modules/rcred/mod.ts Normal file
View File

@ -0,0 +1,122 @@
/**
* 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 20182022) 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 } 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: `<folk-cred-dashboard space="${space || 'demo'}"></folk-cred-dashboard>`,
scripts: '<script type="module" src="/dist/folk-cred-dashboard.js"></script>',
}),
);
});
// ── Cron: recompute every 6 hours ──
const CRON_INTERVAL = 6 * 60 * 60 * 1000; // 6h
let cronTimer: ReturnType<typeof setInterval> | 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<CredConfigDoc>(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,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [graphSchema, scoresSchema, configSchema],
onInit: async ({ syncServer }) => {
_syncServer = syncServer;
startCredCron();
console.log('[rCred] Module initialized, cron started (6h interval)');
},
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',
},
],
};

182
modules/rcred/routes.ts Normal file
View File

@ -0,0 +1,182 @@
/**
* rCred API routes contribution recognition endpoints.
*/
import { Hono } from 'hono';
import * as Automerge from '@automerge/automerge';
import { verifyToken, extractToken } from '../../server/auth';
import { resolveCallerRole } from '../../server/spaces';
import type { SyncServer } from '../../server/local-first/sync-server';
import { getTokenDoc, getAllBalances, getAllTransfers } from '../../server/token-service';
import type { CredConfigDoc, CredScoresDoc, ContribGraphDoc } from './schemas';
import { scoresDocId, graphDocId, configDocId, configSchema, DEFAULT_WEIGHTS } from './schemas';
import { recomputeSpace, ensureConfigDoc } from './grain-engine';
export function createCredRoutes(getSyncServer: () => SyncServer | null) {
const routes = new Hono();
function ss(): SyncServer {
const s = getSyncServer();
if (!s) throw new Error('SyncServer not initialized');
return s;
}
// ── GET /api/scores — leaderboard ──
routes.get('/api/scores', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const doc = ss().getDoc<CredScoresDoc>(scoresDocId(space));
if (!doc || !doc.scores) return c.json({ scores: [], totalCred: 0, computedAt: 0 });
const scores = Object.values(doc.scores)
.sort((a, b) => b.cred - a.cred)
.map((s, i) => ({
rank: i + 1,
did: s.did,
label: s.label,
cred: s.cred,
grainLifetime: s.grainLifetime,
topModule: Object.entries(s.breakdown).sort(([, a], [, b]) => b - a)[0]?.[0] || '',
lastActive: s.lastActive,
}));
return c.json({
scores,
totalCred: doc.totalCred,
computedAt: doc.computedAt,
epochId: doc.epochId,
});
});
// ── GET /api/scores/:did — single contributor detail ──
routes.get('/api/scores/:did', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
const did = c.req.param('did');
if (!space || !did) return c.json({ error: 'space and did required' }, 400);
const doc = ss().getDoc<CredScoresDoc>(scoresDocId(space));
if (!doc?.scores) return c.json({ error: 'No scores found' }, 404);
const score = doc.scores[did];
if (!score) return c.json({ error: 'Contributor not found' }, 404);
return c.json(score);
});
// ── GET /api/graph — contribution graph ──
routes.get('/api/graph', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const doc = ss().getDoc<ContribGraphDoc>(graphDocId(space));
if (!doc) return c.json({ nodes: {}, edges: {}, stats: null });
return c.json({
nodes: doc.nodes,
edges: doc.edges,
lastBuiltAt: doc.lastBuiltAt,
stats: doc.stats,
});
});
// ── GET /api/config — current weights + grain config ──
routes.get('/api/config', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const config = ensureConfigDoc(space, ss());
return c.json({
weights: config.weights,
grainPerEpoch: config.grainPerEpoch,
epochLengthDays: config.epochLengthDays,
slowFraction: config.slowFraction,
fastFraction: config.fastFraction,
dampingFactor: config.dampingFactor,
lookbackDays: config.lookbackDays,
enabled: config.enabled,
lastEpochAt: config.lastEpochAt,
});
});
// ── PUT /api/config — update weights (auth required) ──
routes.put('/api/config', async (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: 'Auth required' }, 401);
const claims = await verifyToken(token);
if (!claims) return c.json({ error: 'Invalid token' }, 401);
const resolved = await resolveCallerRole(space, claims);
if (!resolved || (resolved.role !== 'admin' && !resolved.isOwner)) {
return c.json({ error: 'Admin access required' }, 403);
}
const body = await c.req.json<Partial<CredConfigDoc>>();
const docId = configDocId(space);
ensureConfigDoc(space, ss());
ss().changeDoc<CredConfigDoc>(docId, 'Update rcred config', (d) => {
if (body.weights) {
for (const [key, val] of Object.entries(body.weights)) {
if (d.weights[key] && typeof val.weight === 'number') {
d.weights[key].weight = Math.max(0, Math.min(10, val.weight));
}
}
}
if (typeof body.grainPerEpoch === 'number') d.grainPerEpoch = body.grainPerEpoch;
if (typeof body.epochLengthDays === 'number') d.epochLengthDays = body.epochLengthDays;
if (typeof body.dampingFactor === 'number') d.dampingFactor = body.dampingFactor;
if (typeof body.lookbackDays === 'number') d.lookbackDays = body.lookbackDays;
if (typeof body.enabled === 'boolean') d.enabled = body.enabled;
});
return c.json({ ok: true });
});
// ── POST /api/recompute — trigger immediate recompute (auth required) ──
routes.post('/api/recompute', async (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: 'Auth required' }, 401);
const claims = await verifyToken(token);
if (!claims) return c.json({ error: 'Invalid token' }, 401);
const resolved = await resolveCallerRole(space, claims);
if (!resolved || resolved.role === 'viewer') {
return c.json({ error: 'Membership required' }, 403);
}
const result = recomputeSpace(space, ss());
return c.json(result);
});
// ── GET /api/grain/balances — grain balances from token-service ──
routes.get('/api/grain/balances', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const tokenId = `grain-${space}`;
const tokenDoc = getTokenDoc(tokenId);
if (!tokenDoc) return c.json({ balances: {} });
const balances = getAllBalances(tokenDoc);
return c.json({ tokenId, balances });
});
// ── GET /api/grain/history — grain ledger entries ──
routes.get('/api/grain/history', (c) => {
const space = c.req.param('space') || c.req.query('space') || '';
if (!space) return c.json({ error: 'space required' }, 400);
const tokenId = `grain-${space}`;
const entries = getAllTransfers().filter(e => e.tokenId === tokenId);
return c.json({ entries: entries.slice(-100) });
});
return routes;
}

195
modules/rcred/schemas.ts Normal file
View File

@ -0,0 +1,195 @@
/**
* rCred Contribution Recognition via CredRank.
*
* Types + DocSchema definitions for contribution graphs, cred scores,
* and grain distribution config.
*
* Doc IDs:
* {space}:rcred:graph ContribGraphDoc
* {space}:rcred:scores CredScoresDoc
* {space}:rcred:config CredConfigDoc
*/
import type { DocSchema } from '../../shared/module';
// ── Graph types ──
export type CredNodeType = 'contributor' | 'contribution';
export type CredEdgeType = 'authored' | 'commented-on' | 'reacted-to' | 'attended' | 'voted-on' | 'completed';
export interface CredNode {
id: string;
type: CredNodeType;
/** DID for contributor nodes */
did?: string;
label: string;
sourceModule: string;
contributionType: string;
timestamp: number;
weight: number;
}
export interface CredEdge {
id: string;
from: string;
to: string;
type: CredEdgeType;
weight: number;
}
export interface ContribGraphDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
nodes: Record<string, CredNode>;
edges: Record<string, CredEdge>;
lastBuiltAt: number;
stats: {
nodeCount: number;
edgeCount: number;
contributorCount: number;
contributionCount: number;
};
}
// ── Score types ──
export interface CredScore {
did: string;
label: string;
cred: number;
rawScore: number;
grainLifetime: number;
epochScores: Record<string, number>;
/** Module → cred contribution */
breakdown: Record<string, number>;
lastActive: number;
}
export interface CredScoresDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
scores: Record<string, CredScore>;
totalCred: number;
computedAt: number;
epochId: string;
}
// ── Config types ──
export interface ContributionTypeWeight {
type: string;
weight: number;
description: string;
}
export interface CredConfigDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
weights: Record<string, ContributionTypeWeight>;
grainPerEpoch: number;
epochLengthDays: number;
/** Fraction of grain allocated to lifetime equity (0-1) */
slowFraction: number;
/** Fraction of grain allocated to current epoch (0-1) */
fastFraction: number;
/** PageRank damping factor (α in teleportation probability) */
dampingFactor: number;
lookbackDays: number;
enabled: boolean;
lastEpochAt: number;
grainTokenId: string;
}
// ── Default contribution weights ──
export const DEFAULT_WEIGHTS: Record<string, ContributionTypeWeight> = {
'task-created': { type: 'task-created', weight: 1.0, description: 'Created a task' },
'task-completed': { type: 'task-completed', weight: 3.0, description: 'Completed a task' },
'doc-authored': { type: 'doc-authored', weight: 2.0, description: 'Authored a document' },
'comment-authored': { type: 'comment-authored', weight: 1.0, description: 'Wrote a comment' },
'message-sent': { type: 'message-sent', weight: 0.5, description: 'Sent a chat message' },
'reaction-given': { type: 'reaction-given', weight: 0.2, description: 'Reacted to a message' },
'event-scheduled': { type: 'event-scheduled', weight: 2.0, description: 'Scheduled an event' },
'event-attended': { type: 'event-attended', weight: 1.5, description: 'Attended an event' },
'proposal-authored': { type: 'proposal-authored', weight: 3.0, description: 'Authored a proposal' },
'vote-cast': { type: 'vote-cast', weight: 1.0, description: 'Cast a vote' },
'flow-created': { type: 'flow-created', weight: 2.0, description: 'Created a flow' },
'budget-allocated': { type: 'budget-allocated', weight: 1.5, description: 'Allocated budget' },
'commitment-created': { type: 'commitment-created', weight: 2.0, description: 'Created a time commitment' },
'settlement-completed': { type: 'settlement-completed', weight: 4.0, description: 'Settled a time commitment' },
'address-added': { type: 'address-added', weight: 1.0, description: 'Added a wallet address' },
'tx-annotated': { type: 'tx-annotated', weight: 0.5, description: 'Annotated a transaction' },
};
// ── Doc ID helpers ──
export function graphDocId(space: string) {
return `${space}:rcred:graph` as const;
}
export function scoresDocId(space: string) {
return `${space}:rcred:scores` as const;
}
export function configDocId(space: string) {
return `${space}:rcred:config` as const;
}
// ── DocSchema definitions (for module registration) ──
export const graphSchema: DocSchema<ContribGraphDoc> = {
pattern: '{space}:rcred:graph',
description: 'Contribution graph — nodes and edges from all module activity',
init: (): ContribGraphDoc => ({
meta: { module: 'rcred', collection: 'graph', version: 1, spaceSlug: '', createdAt: Date.now() },
nodes: {},
edges: {},
lastBuiltAt: 0,
stats: { nodeCount: 0, edgeCount: 0, contributorCount: 0, contributionCount: 0 },
}),
};
export const scoresSchema: DocSchema<CredScoresDoc> = {
pattern: '{space}:rcred:scores',
description: 'CredRank scores — per-contributor cred and grain tallies',
init: (): CredScoresDoc => ({
meta: { module: 'rcred', collection: 'scores', version: 1, spaceSlug: '', createdAt: Date.now() },
scores: {},
totalCred: 0,
computedAt: 0,
epochId: '',
}),
};
export const configSchema: DocSchema<CredConfigDoc> = {
pattern: '{space}:rcred:config',
description: 'CredRank configuration — contribution weights and grain parameters',
init: (): CredConfigDoc => ({
meta: { module: 'rcred', collection: 'config', version: 1, spaceSlug: '', createdAt: Date.now() },
weights: { ...DEFAULT_WEIGHTS },
grainPerEpoch: 1000,
epochLengthDays: 7,
slowFraction: 0.8,
fastFraction: 0.2,
dampingFactor: 0.15,
lookbackDays: 90,
enabled: true,
lastEpochAt: 0,
grainTokenId: '',
}),
};

View File

@ -91,6 +91,7 @@ import { govModule } from "../modules/rgov/mod";
import { sheetsModule } from "../modules/rsheets/mod"; import { sheetsModule } from "../modules/rsheets/mod";
import { exchangeModule } from "../modules/rexchange/mod"; import { exchangeModule } from "../modules/rexchange/mod";
import { auctionsModule } from "../modules/rauctions/mod"; import { auctionsModule } from "../modules/rauctions/mod";
import { credModule } from "../modules/rcred/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces"; import type { SpaceRoleString } from "./spaces";
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell"; import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
@ -153,6 +154,7 @@ registerModule(timeModule);
registerModule(govModule); // Governance decision circuits registerModule(govModule); // Governance decision circuits
registerModule(exchangeModule); // P2P crypto/fiat exchange registerModule(exchangeModule); // P2P crypto/fiat exchange
registerModule(auctionsModule); // Community auctions with USDC registerModule(auctionsModule); // Community auctions with USDC
registerModule(credModule); // Contribution recognition via CredRank
registerModule(designModule); // Scribus DTP + AI design agent registerModule(designModule); // Scribus DTP + AI design agent
// De-emphasized modules (bottom of menu) // De-emphasized modules (bottom of menu)
registerModule(forumModule); registerModule(forumModule);

View File

@ -5,13 +5,14 @@
* Stateless mode: fresh McpServer + transport per request. * Stateless mode: fresh McpServer + transport per request.
* Direct Automerge syncServer access for reads (no HTTP round-trip). * Direct Automerge syncServer access for reads (no HTTP round-trip).
* *
* 105 tools across 35 groups: * 108 tools across 36 groups:
* spaces (2), rcal (4), rnotes (5), rtasks (5), rwallet (4), * spaces (2), rcal (4), rnotes (5), rtasks (5), rwallet (4),
* rsocials (4), rnetwork (3), rinbox (4), rtime (4), rfiles (3), rschedule (4), * rsocials (4), rnetwork (3), rinbox (4), rtime (4), rfiles (3), rschedule (4),
* rvote (3), rchoices (3), rtrips (4), rcart (4), rexchange (4), rbnb (4), * rvote (3), rchoices (3), rtrips (4), rcart (4), rexchange (4), rbnb (4),
* rvnb (3), crowdsurf (2), rbooks (2), rpubs (2), rmeets (2), rtube (2), * rvnb (3), crowdsurf (2), rbooks (2), rpubs (2), rmeets (2), rtube (2),
* rswag (2), rdesign (2), rsplat (2), rphotos (2), rflows (2), rdocs (5), * rswag (2), rdesign (2), rsplat (2), rphotos (2), rflows (2), rdocs (5),
* rdata (1), rforum (2), rchats (3), rmaps (3), rsheets (2), rgov (2) * rdata (1), rforum (2), rchats (3), rmaps (3), rsheets (2), rgov (2),
* rcred (3)
* 1 resource: rspace://spaces/{slug} * 1 resource: rspace://spaces/{slug}
*/ */
@ -56,6 +57,7 @@ import { registerAgentsTools } from "./mcp-tools/ragents";
import { registerMapsTools } from "./mcp-tools/rmaps"; import { registerMapsTools } from "./mcp-tools/rmaps";
import { registerSheetsTools } from "./mcp-tools/rsheets"; import { registerSheetsTools } from "./mcp-tools/rsheets";
import { registerGovTools } from "./mcp-tools/rgov"; import { registerGovTools } from "./mcp-tools/rgov";
import { registerCredTools } from "./mcp-tools/rcred";
function createMcpServerInstance(syncServer: SyncServer): McpServer { function createMcpServerInstance(syncServer: SyncServer): McpServer {
const server = new McpServer({ const server = new McpServer({
@ -99,6 +101,7 @@ function createMcpServerInstance(syncServer: SyncServer): McpServer {
registerMapsTools(server, syncServer); registerMapsTools(server, syncServer);
registerSheetsTools(server, syncServer); registerSheetsTools(server, syncServer);
registerGovTools(server); registerGovTools(server);
registerCredTools(server, syncServer);
return server; return server;
} }

100
server/mcp-tools/rcred.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* MCP tools for rCred (contribution recognition via CredRank).
*
* Tools: rcred_get_scores, rcred_get_contributor, rcred_trigger_recompute
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server";
import { scoresDocId, configDocId } from "../../modules/rcred/schemas";
import type { CredScoresDoc, CredConfigDoc } from "../../modules/rcred/schemas";
import { recomputeSpace, ensureConfigDoc } from "../../modules/rcred/grain-engine";
import { resolveAccess, accessDeniedResponse } from "./_auth";
export function registerCredTools(server: McpServer, syncServer: SyncServer) {
server.tool(
"rcred_get_scores",
"Get CredRank contribution leaderboard for a space (sorted by cred descending)",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"),
limit: z.number().optional().describe("Max results (default 50)"),
},
async ({ space, token, limit }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<CredScoresDoc>(scoresDocId(space));
if (!doc || !doc.scores) {
return { content: [{ type: "text" as const, text: JSON.stringify({ scores: [], totalCred: 0 }) }] };
}
const scores = Object.values(doc.scores)
.sort((a, b) => b.cred - a.cred)
.slice(0, limit || 50)
.map((s, i) => ({
rank: i + 1,
did: s.did,
label: s.label,
cred: s.cred,
grainLifetime: s.grainLifetime,
topModule: Object.entries(s.breakdown).sort(([, a], [, b]) => b - a)[0]?.[0] || '',
}));
return {
content: [{
type: "text" as const,
text: JSON.stringify({
scores,
totalCred: doc.totalCred,
computedAt: doc.computedAt,
epochId: doc.epochId,
}, null, 2),
}],
};
},
);
server.tool(
"rcred_get_contributor",
"Get detailed CredRank scores and module breakdown for a specific contributor",
{
space: z.string().describe("Space slug"),
did: z.string().describe("Contributor DID"),
token: z.string().optional().describe("JWT auth token"),
},
async ({ space, did, token }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<CredScoresDoc>(scoresDocId(space));
if (!doc?.scores) {
return { content: [{ type: "text" as const, text: JSON.stringify({ error: "No scores data found" }) }] };
}
const score = doc.scores[did];
if (!score) {
return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Contributor not found" }) }] };
}
return { content: [{ type: "text" as const, text: JSON.stringify(score, null, 2) }] };
},
);
server.tool(
"rcred_trigger_recompute",
"Trigger an immediate CredRank recompute for a space (requires auth token with member+ role)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token (member+ required)"),
},
async ({ space, token }) => {
const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const result = recomputeSpace(space, syncServer);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
},
);
}