diff --git a/modules/rcred/components/folk-cred-dashboard.ts b/modules/rcred/components/folk-cred-dashboard.ts new file mode 100644 index 00000000..2b405a25 --- /dev/null +++ b/modules/rcred/components/folk-cred-dashboard.ts @@ -0,0 +1,458 @@ +/** + * — 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; + epochScores: Record; + lastActive: number; +} + +interface WeightConfig { + type: string; + weight: number; + description: string; +} + +interface ConfigResponse { + weights: Record; + grainPerEpoch: number; + epochLengthDays: number; + slowFraction: number; + fastFraction: number; + dampingFactor: number; + lookbackDays: number; + enabled: boolean; + lastEpochAt: number; +} + +const MODULE_COLORS: Record = { + rtasks: '#3b82f6', + rdocs: '#8b5cf6', + rchats: '#06b6d4', + rcal: '#f59e0b', + rvote: '#ec4899', + rflows: '#22c55e', + rtime: '#a78bfa', + rwallet: '#d97706', +}; + +const MODULE_LABELS: Record = { + 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 = ` + +
${content}
`; + + // 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 '
Loading scores...
'; + + const hasScores = this.scores.length > 0; + + return ` +
+

Contribution Recognition

+
+ + +
+
+ +${this.error ? `
${this.error}
` : ''} + +
+ ${hasScores ? `Last computed: ${timeAgo(this.computedAt)} · ${this.scores.length} contributors · ${this.totalCred} total cred` : ''} +
+ +${hasScores ? ` + + + + + + + + + + + + + ${this.scores.map(s => ` + + + + + + + + `).join('')} + +
#ContributorCred ⭐Grain 🌾Top ModuleActive
${s.rank}${this.escapeHtml(s.label || s.did.slice(0, 20))}${s.cred}${s.grainLifetime} + ${s.topModule ? `${MODULE_LABELS[s.topModule] || s.topModule}` : '—'} + ${timeAgo(s.lastActive)}
+` : ` +
+

No scores yet

+

Click Recompute to build the contribution graph and run CredRank.

+

+ Scores are generated from activity across Tasks, Docs, Chats, Calendar, Voting, Flows, Time, and Wallet modules. +

+
+`}`; + } + + private renderContributor(timeAgo: (ts: number) => string): string { + if (this.loading) return '
Loading contributor...
'; + const c = this.selectedContributor; + if (!c) return '
Contributor not found
'; + + const breakdown = Object.entries(c.breakdown).sort(([, a], [, b]) => b - a); + + return ` +
+
← Back
+
+ +
+

${this.escapeHtml(c.label || c.did.slice(0, 20))}

+
${this.escapeHtml(c.did)}
+ +
+
+
${c.cred}
+
Cred ⭐
+
+
+
${c.grainLifetime}
+
Grain 🌾
+
+
+
${timeAgo(c.lastActive)}
+
Last Active
+
+
+
+ +
+

Contribution Breakdown

+ ${breakdown.map(([mod, pct]) => ` +
+
${MODULE_LABELS[mod] || mod}
+
+
+ ${pct >= 10 ? MODULE_LABELS[mod] || mod : ''} +
+
+
${pct}%
+
`).join('')} +
`; + } + + private renderConfig(): string { + if (this.loading) return '
Loading config...
'; + if (!this.config) return '
Config not loaded
'; + + const weights = Object.entries(this.config.weights).sort(([, a], [, b]) => b.weight - a.weight); + + return ` +
+
← Back
+
Weight Configuration
+
+ +${this.error ? `
${this.error}
` : ''} + +
+

Contribution Weights

+

Adjust how much each type of contribution counts toward Cred. Changes take effect on next recompute.

+
+ +${weights.map(([key, w]) => ` +
+
${this.escapeHtml(w.description)}
+ +
${w.weight}
+
`).join('')} + +
+ + +
+ +
+

Grain Parameters

+
+
Grain per epoch: ${this.config.grainPerEpoch}
+
Epoch length: ${this.config.epochLengthDays} days
+
Slow fraction: ${(this.config.slowFraction * 100).toFixed(0)}%
+
Fast fraction: ${(this.config.fastFraction * 100).toFixed(0)}%
+
Damping factor: ${this.config.dampingFactor}
+
Lookback: ${this.config.lookbackDays} days
+
+
`; + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } +} + +customElements.define('folk-cred-dashboard', FolkCredDashboard); diff --git a/modules/rcred/credrank.ts b/modules/rcred/credrank.ts new file mode 100644 index 00000000..712f5c15 --- /dev/null +++ b/modules/rcred/credrank.ts @@ -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 — stationary distribution summing to ~1.0 + */ +export function computeCredRank( + nodes: CredNode[], + edges: CredEdge[], + config: CredConfigDoc, +): Map { + const n = nodes.length; + if (n === 0) return new Map(); + + // Build node index + const nodeIndex = new Map(); + for (let i = 0; i < n; i++) { + nodeIndex.set(nodes[i].id, i); + } + + // Build adjacency: outgoing[i] = [(targetIndex, weight)] + const outgoing: Array> = 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.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(); + for (let i = 0; i < n; i++) { + result.set(nodes[i].id, pi[i]); + } + + return result; +} diff --git a/modules/rcred/grain-engine.ts b/modules/rcred/grain-engine.ts new file mode 100644 index 00000000..34cc7ab5 --- /dev/null +++ b/modules/rcred/grain-engine.ts @@ -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(); + +/** + * 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(docId); + + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init grain ledger', (d) => { + const init = tokenLedgerSchema.init(); + Object.assign(d, init); + }); + syncServer.setDoc(docId, doc); + } + + if (!doc.token.name) { + syncServer.changeDoc(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(); + 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(); + if (totalLifetimeCred > 0) { + let totalSlowDue = 0; + const dues = new Map(); + + 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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(scoresDocId(space)); + + const credScores: Record = {}; + 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 = {}; + 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(gDocId); + if (!gDoc) { + gDoc = Automerge.change(Automerge.init(), 'init rcred graph', (d) => { + const init = graphSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(gDocId, gDoc); + } + + syncServer.changeDoc(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(sDocId); + if (!sDoc) { + sDoc = Automerge.change(Automerge.init(), 'init rcred scores', (d) => { + const init = scoresSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(sDocId, sDoc); + } + + syncServer.changeDoc(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(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(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(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); + } +} diff --git a/modules/rcred/graph-collector.ts b/modules/rcred/graph-collector.ts new file mode 100644 index 00000000..2de0bfc2 --- /dev/null +++ b/modules/rcred/graph-collector.ts @@ -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(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(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(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(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(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(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(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(iDocId); + if (iDoc && (iDoc as any).intents) { + for (const intent of Object.values((iDoc as any).intents as Record)) { + 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(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(); + 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(); + 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); +} diff --git a/modules/rcred/landing.ts b/modules/rcred/landing.ts new file mode 100644 index 00000000..13b59ffc --- /dev/null +++ b/modules/rcred/landing.ts @@ -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 ` + +
+ + Part of the rSpace Ecosystem + +

+ Contribution
Recognition +

+

+ CredRank builds a living graph of every contribution + across your community — docs written, tasks completed, events attended, votes cast — + and runs PageRank to surface who's actually creating value. + Then it pays them in Grain. +

+ +
+ + +
+
+
+ Origin Story +

+ Standing on the Shoulders of SourceCred +

+

+ SourceCred (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. +

+
+
+
+ + +
+
+
+ How It Works +

+ From Actions to Grain in Three Steps +

+
+ +
+
+
+
+ 1 +
+

Graph Collection

+
+

+ Every action across 8 rApps 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. +

+
+ +
+
+
+ 2 +
+

CredRank

+
+

+ Modified PageRank 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. +

+
+ +
+
+
+ 3 +
+

Grain Distribution

+
+

+ GRAIN tokens are minted proportional to Cred. + 80% goes to lifetime equity (catch-up for long-term contributors) and + 20% to current epoch (recent activity). Non-transferable, visible in rWallet. + Your community decides the emission rate. +

+
+
+
+
+ + +
+
+
+ 16 Types +

+ Every Action Counts +

+

+ 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. +

+
+ +
+
+

High Impact (3–4x)

+
    +
  • Settlement completed — 4.0x
  • +
  • Task completed — 3.0x
  • +
  • Proposal authored — 3.0x
  • +
+
+
+

Medium Impact (1.5–2x)

+
    +
  • Doc authored — 2.0x
  • +
  • Flow created — 2.0x
  • +
  • Event scheduled — 2.0x
  • +
  • Commitment created — 2.0x
  • +
  • Event attended — 1.5x
  • +
  • Budget allocated — 1.5x
  • +
+
+
+

Standard (1x)

+
    +
  • Task created — 1.0x
  • +
  • Comment authored — 1.0x
  • +
  • Vote cast — 1.0x
  • +
  • Address added — 1.0x
  • +
+
+
+

Light Touch (0.2–0.5x)

+
    +
  • Message sent — 0.5x
  • +
  • TX annotated — 0.5x
  • +
  • Reaction given — 0.2x
  • +
+
+
+
+
+ + +
+
+
+ Philosophy +

+ Emergent Value Recognition +

+

+ 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. +

+

+ "Make contributions visible. Let the graph speak." — SourceCred ethos +

+
+
+
+`; +} diff --git a/modules/rcred/mod.ts b/modules/rcred/mod.ts new file mode 100644 index 00000000..2c4d3485 --- /dev/null +++ b/modules/rcred/mod.ts @@ -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: ``, + scripts: '', + }), + ); +}); + +// ── Cron: recompute every 6 hours ── +const CRON_INTERVAL = 6 * 60 * 60 * 1000; // 6h +let cronTimer: ReturnType | null = null; + +function startCredCron() { + if (cronTimer) return; + cronTimer = setInterval(() => { + if (!_syncServer) return; + const allDocs = _syncServer.listDocs(); + const configDocs = allDocs.filter(id => id.endsWith(':rcred:config')); + for (const docId of configDocs) { + const space = docId.split(':')[0]; + if (!space) continue; + const config = _syncServer.getDoc(docId); + if (!config?.enabled) continue; + + // Check epoch timing + const epochMs = (config.epochLengthDays || 7) * 86_400_000; + if (Date.now() - (config.lastEpochAt || 0) < epochMs) continue; + + console.log(`[rCred cron] Recomputing ${space}...`); + recomputeSpace(space, _syncServer); + } + }, CRON_INTERVAL); +} + +export const credModule: RSpaceModule = { + id: 'rcred', + name: 'rCred', + icon: '⭐', + description: 'Contribution recognition — Cred scores + Grain distribution via CredRank', + routes, + + 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', + }, + ], +}; diff --git a/modules/rcred/routes.ts b/modules/rcred/routes.ts new file mode 100644 index 00000000..520a4e9a --- /dev/null +++ b/modules/rcred/routes.ts @@ -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(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(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(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>(); + const docId = configDocId(space); + ensureConfigDoc(space, ss()); + + ss().changeDoc(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; +} diff --git a/modules/rcred/schemas.ts b/modules/rcred/schemas.ts new file mode 100644 index 00000000..bda86c6b --- /dev/null +++ b/modules/rcred/schemas.ts @@ -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; + edges: Record; + 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; + /** Module → cred contribution */ + breakdown: Record; + lastActive: number; +} + +export interface CredScoresDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + scores: Record; + 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; + 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 = { + '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 = { + 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 = { + 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 = { + 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: '', + }), +}; diff --git a/server/index.ts b/server/index.ts index b98f1e59..68eca54a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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); diff --git a/server/mcp-server.ts b/server/mcp-server.ts index 04a785eb..55c7928b 100644 --- a/server/mcp-server.ts +++ b/server/mcp-server.ts @@ -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; } diff --git a/server/mcp-tools/rcred.ts b/server/mcp-tools/rcred.ts new file mode 100644 index 00000000..b4d0bdf5 --- /dev/null +++ b/server/mcp-tools/rcred.ts @@ -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(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(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) }] }; + }, + ); +}