Compare commits
2 Commits
8cb6ca838e
...
a5b8ecd234
| Author | SHA1 | Date |
|---|---|---|
|
|
a5b8ecd234 | |
|
|
5362806b72 |
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('folk-cred-dashboard', FolkCredDashboard);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 — docs written, tasks completed, events attended, votes cast —
|
||||
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–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 —
|
||||
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 — 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–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–4x)</h3>
|
||||
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
|
||||
<li>Settlement completed — 4.0x</li>
|
||||
<li>Task completed — 3.0x</li>
|
||||
<li>Proposal authored — 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–2x)</h3>
|
||||
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
|
||||
<li>Doc authored — 2.0x</li>
|
||||
<li>Flow created — 2.0x</li>
|
||||
<li>Event scheduled — 2.0x</li>
|
||||
<li>Commitment created — 2.0x</li>
|
||||
<li>Event attended — 1.5x</li>
|
||||
<li>Budget allocated — 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 — 1.0x</li>
|
||||
<li>Comment authored — 1.0x</li>
|
||||
<li>Vote cast — 1.0x</li>
|
||||
<li>Address added — 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–0.5x)</h3>
|
||||
<ul style="color:#94a3b8;font-size:0.85rem;line-height:1.8;padding-left:1rem;margin:0">
|
||||
<li>Message sent — 0.5x</li>
|
||||
<li>TX annotated — 0.5x</li>
|
||||
<li>Reaction given — 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 — 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." — SourceCred ethos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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 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 } 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: '',
|
||||
}),
|
||||
};
|
||||
|
|
@ -91,6 +91,7 @@ import { govModule } from "../modules/rgov/mod";
|
|||
import { sheetsModule } from "../modules/rsheets/mod";
|
||||
import { exchangeModule } from "../modules/rexchange/mod";
|
||||
import { auctionsModule } from "../modules/rauctions/mod";
|
||||
import { credModule } from "../modules/rcred/mod";
|
||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||
import type { SpaceRoleString } from "./spaces";
|
||||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||||
|
|
@ -153,6 +154,7 @@ registerModule(timeModule);
|
|||
registerModule(govModule); // Governance decision circuits
|
||||
registerModule(exchangeModule); // P2P crypto/fiat exchange
|
||||
registerModule(auctionsModule); // Community auctions with USDC
|
||||
registerModule(credModule); // Contribution recognition via CredRank
|
||||
registerModule(designModule); // Scribus DTP + AI design agent
|
||||
// De-emphasized modules (bottom of menu)
|
||||
registerModule(forumModule);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* Stateless mode: fresh McpServer + transport per request.
|
||||
* 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),
|
||||
* rsocials (4), rnetwork (3), rinbox (4), rtime (4), rfiles (3), rschedule (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),
|
||||
* 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}
|
||||
*/
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ import { registerAgentsTools } from "./mcp-tools/ragents";
|
|||
import { registerMapsTools } from "./mcp-tools/rmaps";
|
||||
import { registerSheetsTools } from "./mcp-tools/rsheets";
|
||||
import { registerGovTools } from "./mcp-tools/rgov";
|
||||
import { registerCredTools } from "./mcp-tools/rcred";
|
||||
|
||||
function createMcpServerInstance(syncServer: SyncServer): McpServer {
|
||||
const server = new McpServer({
|
||||
|
|
@ -99,6 +101,7 @@ function createMcpServerInstance(syncServer: SyncServer): McpServer {
|
|||
registerMapsTools(server, syncServer);
|
||||
registerSheetsTools(server, syncServer);
|
||||
registerGovTools(server);
|
||||
registerCredTools(server, syncServer);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue