feat(rtime): add intent-routed resource-backed commitments

Integrate Anoma-style intent routing into rTime so that commitments
become resource-backed intents that a solver can compose into multi-party
collaboration recommendations.

New modules:
- schemas-intent.ts: Intent, SolverResult, SkillCurve, Reputation types + 4 CRDT doc schemas
- solver.ts: Mycelium Clustering algorithm (bipartite graph matching, VP filtering, scoring)
- settlement.ts: Atomic settlement via saga pattern with escrow confirm/rollback
- skill-curve.ts: Per-skill bonding curve (demand/supply pricing)
- reputation.ts: Per-skill reputation scoring with time-based decay
- intent-routes.ts: 10 Hono API routes (intent CRUD, solver, settlement, curves, reputation)

Modified:
- schemas.ts: Added intentId/status fields to Commitment
- mod.ts: Registered 4 new docSchemas, mounted intent routes, added Collaborate output path
- folk-timebank-app.ts: Added Collaborate tab with intent cards, solver results panel,
  accept/reject buttons, create intent modal, skill price display, and status rings on pool orbs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 22:36:06 -07:00
parent 16fe8f7626
commit 08cae267fe
9 changed files with 2048 additions and 4 deletions

View File

@ -79,6 +79,7 @@ class Orb {
phase: number;
opacity = 0;
color: string;
statusRing: 'offer' | 'need' | 'matched' | null = null; // intent status ring color
constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) {
this.c = c;
@ -152,6 +153,19 @@ class Orb {
ctx.lineWidth = 1.5;
ctx.stroke();
// Status ring (green=offer, blue=need, gold=matched)
if (this.statusRing) {
const ringColor = this.statusRing === 'offer' ? '#10b981'
: this.statusRing === 'need' ? '#3b82f6' : '#fbbf24';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 4, 0, Math.PI * 2);
ctx.strokeStyle = ringColor;
ctx.lineWidth = 2.5;
ctx.setLineDash(this.statusRing === 'matched' ? [] : [6, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
if (this.hoverT > 0.05) {
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
ctx.beginPath();
@ -231,7 +245,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string,
class FolkTimebankApp extends HTMLElement {
private shadow: ShadowRoot;
private space = 'demo';
private currentView: 'pool' | 'weave' = 'pool';
private currentView: 'pool' | 'weave' | 'collaborate' = 'pool';
// Pool state
private canvas!: HTMLCanvasElement;
@ -270,6 +284,12 @@ class FolkTimebankApp extends HTMLElement {
private commitments: Commitment[] = [];
private tasks: TaskData[] = [];
// Collaborate state
private intents: any[] = [];
private solverResults: any[] = [];
private skillPrices: Record<string, number> = {};
private memberIntentStatus: Map<string, 'offer' | 'need' | 'matched'> = new Map();
// Exec state
private execStepStates: Record<string, Record<number, string>> = {};
@ -282,7 +302,7 @@ class FolkTimebankApp extends HTMLElement {
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this.space = val;
if (name === 'view' && (val === 'pool' || val === 'weave')) this.currentView = val;
if (name === 'view' && (val === 'pool' || val === 'weave' || val === 'collaborate')) this.currentView = val;
}
connectedCallback() {
@ -292,6 +312,7 @@ class FolkTimebankApp extends HTMLElement {
this.render();
this.setupPool();
this.setupWeave();
this.setupCollaborate();
this.fetchData();
}
@ -328,6 +349,7 @@ class FolkTimebankApp extends HTMLElement {
<div class="tab-bar">
<div class="tab active" data-view="pool">Commitment Pool</div>
<div class="tab" data-view="weave">Weaving Dashboard</div>
<div class="tab" data-view="collaborate">Collaborate</div>
</div>
<div class="stats-bar">
<div class="stat"><span class="stat-value" id="statHours">0</span>&nbsp;hours available</div>
@ -376,6 +398,71 @@ class FolkTimebankApp extends HTMLElement {
</svg>
</div>
</div>
<div id="collaborate-view" style="display:none">
<div class="collab-panel">
<div class="collab-section">
<div class="collab-section-header">
<h3>Active Intents</h3>
<button class="collab-create-btn" id="createIntentBtn">+ New Intent</button>
</div>
<div class="collab-intents" id="collabIntents">
<div class="collab-empty">No active intents yet. Create an offer or need to get started.</div>
</div>
</div>
<div class="collab-section">
<div class="collab-section-header">
<h3>Recommended Collaborations</h3>
<button class="collab-solver-btn" id="runSolverBtn">Run Solver</button>
</div>
<div class="collab-results" id="collabResults">
<div class="collab-empty">No solver results yet. Create intents and run the solver to find matches.</div>
</div>
</div>
<div class="collab-section collab-prices-section">
<h3>Skill Market Prices</h3>
<div class="collab-prices" id="collabPrices"></div>
</div>
</div>
</div>
</div>
<!-- Create Intent Modal -->
<div class="modal-overlay" id="intentModalOverlay">
<div class="modal">
<h3>Create Intent</h3>
<div class="modal-field">
<label>Type</label>
<div class="intent-type-toggle" id="intentTypeToggle">
<button class="intent-type-btn active" data-type="offer">Offer</button>
<button class="intent-type-btn" data-type="need">Need</button>
</div>
</div>
<div class="modal-field">
<label>Skill</label>
<select id="intentSkill">
<option value="facilitation">Facilitation</option>
<option value="design">Design</option>
<option value="tech">Tech</option>
<option value="outreach">Outreach</option>
<option value="logistics">Logistics</option>
</select>
</div>
<div class="modal-field">
<label>Hours</label>
<input type="number" id="intentHours" min="1" max="100" value="2">
</div>
<div class="modal-field">
<label>Description</label>
<input type="text" id="intentDesc" placeholder="What will you offer or need?">
</div>
<div class="intent-cost" id="intentCost" style="display:none">
<span>Escrow cost: </span><strong id="intentCostValue">0</strong> tokens
</div>
<div class="modal-actions">
<button class="modal-cancel" id="intentCancel">Cancel</button>
<button class="modal-submit" id="intentSubmit">Create Intent</button>
</div>
</div>
</div>
<!-- Add Commitment Modal -->
@ -457,16 +544,19 @@ class FolkTimebankApp extends HTMLElement {
// Tab switching
this.shadow.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave';
const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave' | 'collaborate';
if (view === this.currentView) return;
this.currentView = view;
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
const poolView = this.shadow.getElementById('pool-view')!;
const weaveView = this.shadow.getElementById('weave-view')!;
const collabView = this.shadow.getElementById('collaborate-view')!;
poolView.style.display = view === 'pool' ? 'block' : 'none';
weaveView.style.display = view === 'weave' ? 'flex' : 'none';
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
if (view === 'pool') this.resizePoolCanvas();
if (view === 'weave') this.rebuildSidebar();
if (view === 'collaborate') this.refreshCollaborate();
});
});
@ -536,7 +626,11 @@ class FolkTimebankApp extends HTMLElement {
}
private buildOrbs() {
this.orbs = this.commitments.map(c => new Orb(c, this.basketCX, this.basketCY, this.basketR));
this.orbs = this.commitments.map(c => {
const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR);
orb.statusRing = this.memberIntentStatus.get(c.memberName) || null;
return orb;
});
}
private resolveCollisions() {
@ -1290,6 +1384,256 @@ class FolkTimebankApp extends HTMLElement {
await this._initTour();
this._tour?.start();
}
// ── Collaborate setup ──
private setupCollaborate() {
// Create Intent modal
const intentModal = this.shadow.getElementById('intentModalOverlay')!;
const createBtn = this.shadow.getElementById('createIntentBtn');
const solverBtn = this.shadow.getElementById('runSolverBtn');
createBtn?.addEventListener('click', () => {
intentModal.classList.add('visible');
(this.shadow.getElementById('intentDesc') as HTMLInputElement).value = '';
(this.shadow.getElementById('intentHours') as HTMLInputElement).value = '2';
this.updateIntentCost();
});
this.shadow.getElementById('intentCancel')?.addEventListener('click', () => {
intentModal.classList.remove('visible');
});
// Type toggle
this.shadow.querySelectorAll('.intent-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.shadow.querySelectorAll('.intent-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.updateIntentCost();
});
});
// Cost update on skill/hours change
this.shadow.getElementById('intentSkill')?.addEventListener('change', () => this.updateIntentCost());
this.shadow.getElementById('intentHours')?.addEventListener('input', () => this.updateIntentCost());
// Submit intent
this.shadow.getElementById('intentSubmit')?.addEventListener('click', () => this.submitIntent());
// Run solver
solverBtn?.addEventListener('click', () => this.triggerSolver());
}
private updateIntentCost() {
const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement;
const type = typeBtn?.dataset.type || 'offer';
const costDiv = this.shadow.getElementById('intentCost')!;
const costVal = this.shadow.getElementById('intentCostValue')!;
if (type === 'need') {
const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value;
const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1;
const price = this.skillPrices[skill] || 100;
costVal.textContent = String(price * hours);
costDiv.style.display = 'block';
} else {
costDiv.style.display = 'none';
}
}
private async submitIntent() {
const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement;
const type = typeBtn?.dataset.type || 'offer';
const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value;
const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1;
const description = (this.shadow.getElementById('intentDesc') as HTMLInputElement).value;
try {
const resp = await fetch(`/${this.space}/rtime/api/intent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, skill, hours, description }),
});
if (!resp.ok) {
const err = await resp.json();
alert(err.error || 'Failed to create intent');
return;
}
this.shadow.getElementById('intentModalOverlay')!.classList.remove('visible');
this.refreshCollaborate();
} catch {
alert('Failed to create intent');
}
}
private async triggerSolver() {
try {
await fetch(`/${this.space}/rtime/api/solver/run`, { method: 'POST' });
this.refreshCollaborate();
} catch {
// ignore
}
}
private async refreshCollaborate() {
const base = `/${this.space}/rtime`;
try {
const [iResp, sResp, cResp] = await Promise.all([
fetch(`${base}/api/intents`),
fetch(`${base}/api/solver-results`),
fetch(`${base}/api/skill-curves`),
]);
if (iResp.ok) {
const data = await iResp.json();
this.intents = data.intents || [];
}
if (sResp.ok) {
const data = await sResp.json();
this.solverResults = data.results || [];
}
if (cResp.ok) {
const data = await cResp.json();
this.skillPrices = data.prices || {};
}
} catch {
// offline
}
// Build member intent status map for pool view status rings
this.memberIntentStatus.clear();
for (const intent of this.intents) {
if (intent.status !== 'active' && intent.status !== 'matched') continue;
const existing = this.memberIntentStatus.get(intent.memberName);
if (intent.status === 'matched') {
this.memberIntentStatus.set(intent.memberName, 'matched');
} else if (!existing) {
this.memberIntentStatus.set(intent.memberName, intent.type);
}
}
this.renderIntentsList();
this.renderSolverResults();
this.renderSkillPrices();
}
private renderIntentsList() {
const container = this.shadow.getElementById('collabIntents');
if (!container) return;
const activeIntents = this.intents.filter(i => i.status === 'active' || i.status === 'matched');
if (activeIntents.length === 0) {
container.innerHTML = '<div class="collab-empty">No active intents yet. Create an offer or need to get started.</div>';
return;
}
container.innerHTML = activeIntents.map(intent => {
const color = SKILL_COLORS[intent.skill] || '#8b5cf6';
const typeLabel = intent.type === 'offer' ? 'OFFER' : 'NEED';
const typeClass = intent.type === 'offer' ? 'intent-offer' : 'intent-need';
return `
<div class="intent-card ${typeClass}">
<div class="intent-card-header">
<span class="intent-type-badge ${typeClass}">${typeLabel}</span>
<span class="intent-skill-badge" style="background:${color}20;color:${color}">${SKILL_LABELS[intent.skill] || intent.skill}</span>
<span class="intent-hours">${intent.hours}h</span>
<span class="intent-status-badge intent-status-${intent.status}">${intent.status}</span>
</div>
<div class="intent-card-body">
<div class="intent-member">${intent.memberName}</div>
<div class="intent-desc">${intent.description || 'No description'}</div>
</div>
</div>
`;
}).join('');
}
private renderSolverResults() {
const container = this.shadow.getElementById('collabResults');
if (!container) return;
if (this.solverResults.length === 0) {
container.innerHTML = '<div class="collab-empty">No solver results yet. Create intents and run the solver to find matches.</div>';
return;
}
container.innerHTML = this.solverResults.map(result => {
const scorePercent = Math.round(result.score * 100);
const skills = (result.skills || []).map((s: string) => {
const color = SKILL_COLORS[s] || '#8b5cf6';
return `<span class="intent-skill-badge" style="background:${color}20;color:${color}">${SKILL_LABELS[s] || s}</span>`;
}).join('');
const acceptCount = Object.values(result.acceptances || {}).filter(Boolean).length;
const totalMembers = (result.members || []).length;
return `
<div class="solver-result-card">
<div class="solver-result-header">
<div class="solver-score">
<div class="solver-score-ring" style="--score: ${scorePercent}%">
<span>${scorePercent}</span>
</div>
</div>
<div class="solver-result-info">
<div class="solver-skills">${skills}</div>
<div class="solver-meta">${result.totalHours}h total · ${totalMembers} members · ${acceptCount}/${totalMembers} accepted</div>
</div>
</div>
<div class="solver-result-members">
${(result.members || []).map((m: string) => {
const accepted = result.acceptances?.[m];
const icon = accepted ? '✓' : '○';
const cls = accepted ? 'accepted' : 'pending';
return `<span class="solver-member ${cls}">${icon} ${m.split(':').pop()}</span>`;
}).join('')}
</div>
<div class="solver-result-actions">
<button class="solver-accept-btn" data-result-id="${result.id}">Accept</button>
<button class="solver-reject-btn" data-result-id="${result.id}">Reject</button>
</div>
</div>
`;
}).join('');
// Bind accept/reject buttons
container.querySelectorAll('.solver-accept-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const resultId = (btn as HTMLElement).dataset.resultId;
try {
await fetch(`/${this.space}/rtime/api/solver-results/${resultId}/accept`, { method: 'POST' });
this.refreshCollaborate();
} catch { /* ignore */ }
});
});
container.querySelectorAll('.solver-reject-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const resultId = (btn as HTMLElement).dataset.resultId;
try {
await fetch(`/${this.space}/rtime/api/solver-results/${resultId}/reject`, { method: 'POST' });
this.refreshCollaborate();
} catch { /* ignore */ }
});
});
}
private renderSkillPrices() {
const container = this.shadow.getElementById('collabPrices');
if (!container) return;
const skills = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
container.innerHTML = skills.map(skill => {
const color = SKILL_COLORS[skill] || '#8b5cf6';
const price = this.skillPrices[skill] || 100;
return `
<div class="price-card">
<div class="price-dot" style="background:${color}"></div>
<span class="price-skill">${SKILL_LABELS[skill] || skill}</span>
<span class="price-value">${price} tok/h</span>
</div>
`;
}).join('');
}
}
// ── CSS ──
@ -1658,6 +2002,255 @@ const CSS_TEXT = `
gap: 0.5rem;
}
/* Collaborate view */
#collaborate-view {
width: 100%; height: 100%;
position: absolute; top: 0; left: 0;
flex-direction: column;
overflow-y: auto;
padding: 1.25rem;
gap: 1rem;
}
.collab-panel {
max-width: 800px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.collab-section {
background: #1e293b;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 1px solid #334155;
}
.collab-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.collab-section-header h3 {
font-size: 0.95rem;
font-weight: 600;
color: #f1f5f9;
margin: 0;
}
.collab-create-btn, .collab-solver-btn {
padding: 0.35rem 0.85rem;
background: linear-gradient(135deg, #8b5cf6, #ec4899);
color: #fff;
border: none;
border-radius: 0.375rem;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.collab-create-btn:hover, .collab-solver-btn:hover { opacity: 0.85; }
.collab-solver-btn {
background: linear-gradient(135deg, #3b82f6, #06b6d4);
}
.collab-empty {
color: #64748b;
font-size: 0.82rem;
text-align: center;
padding: 1.5rem 0;
}
.collab-intents, .collab-results {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Intent cards */
.intent-card {
background: #0f172a;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #334155;
transition: border-color 0.15s;
}
.intent-card:hover { border-color: #475569; }
.intent-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
flex-wrap: wrap;
}
.intent-type-badge {
font-size: 0.68rem;
font-weight: 700;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
letter-spacing: 0.04em;
}
.intent-type-badge.intent-offer { background: rgba(16,185,129,0.15); color: #10b981; }
.intent-type-badge.intent-need { background: rgba(59,130,246,0.15); color: #3b82f6; }
.intent-skill-badge {
font-size: 0.72rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
}
.intent-hours {
font-size: 0.78rem;
font-weight: 600;
color: #f1f5f9;
margin-left: auto;
}
.intent-status-badge {
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
border-radius: 1rem;
background: #334155;
color: #94a3b8;
}
.intent-status-active { background: rgba(16,185,129,0.15); color: #10b981; }
.intent-status-matched { background: rgba(251,191,36,0.15); color: #fbbf24; }
.intent-status-settled { background: rgba(139,92,246,0.15); color: #8b5cf6; }
.intent-member {
font-size: 0.82rem;
font-weight: 600;
color: #e2e8f0;
}
.intent-desc {
font-size: 0.78rem;
color: #64748b;
line-height: 1.4;
}
/* Intent type toggle */
.intent-type-toggle {
display: flex;
gap: 0;
border: 1px solid #475569;
border-radius: 0.375rem;
overflow: hidden;
}
.intent-type-btn {
flex: 1;
padding: 0.45rem 0.75rem;
background: #0f172a;
color: #94a3b8;
border: none;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.intent-type-btn.active {
background: linear-gradient(135deg, #8b5cf6, #ec4899);
color: #fff;
}
.intent-cost {
font-size: 0.82rem;
color: #94a3b8;
padding: 0.5rem 0;
}
.intent-cost strong { color: #fbbf24; }
/* Solver result cards */
.solver-result-card {
background: #0f172a;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #334155;
}
.solver-result-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.solver-score {
flex-shrink: 0;
}
.solver-score-ring {
width: 40px; height: 40px;
border-radius: 50%;
background: conic-gradient(#8b5cf6 var(--score, 0%), #334155 var(--score, 0%));
display: flex;
align-items: center;
justify-content: center;
}
.solver-score-ring span {
background: #0f172a;
width: 30px; height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
color: #f1f5f9;
}
.solver-result-info { flex: 1; }
.solver-skills { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.solver-meta { font-size: 0.75rem; color: #64748b; margin-top: 0.25rem; }
.solver-result-members {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.solver-member {
font-size: 0.72rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
background: #1e293b;
color: #94a3b8;
}
.solver-member.accepted { background: rgba(16,185,129,0.15); color: #10b981; }
.solver-result-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.solver-accept-btn, .solver-reject-btn {
padding: 0.3rem 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
}
.solver-accept-btn {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
}
.solver-reject-btn {
background: #334155;
color: #94a3b8;
}
/* Skill prices */
.collab-prices-section h3 {
font-size: 0.95rem;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 0.75rem;
}
.collab-prices {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.price-card {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.7rem;
background: #0f172a;
border-radius: 0.375rem;
border: 1px solid #334155;
}
.price-dot { width: 8px; height: 8px; border-radius: 50%; }
.price-skill { font-size: 0.78rem; color: #94a3b8; }
.price-value { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; margin-left: 0.25rem; }
@media (max-width: 768px) {
.sidebar { width: 200px; }
.exec-panel { width: 95vw; }

View File

@ -0,0 +1,408 @@
/**
* Intent API routes Hono sub-router for intent CRUD, solver, and settlement.
*
* All routes are relative to the module mount point (/:space/rtime/).
*/
import { Hono } from 'hono';
import * as Automerge from '@automerge/automerge';
import { verifyToken, extractToken } from '../../server/auth';
import { burnTokensEscrow, reverseBurn } from '../../server/token-service';
import type { SyncServer } from '../../server/local-first/sync-server';
import {
intentsDocId, solverResultsDocId,
skillCurvesDocId, reputationDocId,
intentsSchema, solverResultsSchema,
skillCurvesSchema, reputationSchema,
} from './schemas-intent';
import type {
IntentsDoc, SolverResultsDoc,
SkillCurvesDoc, ReputationDoc,
Intent, IntentStatus,
} from './schemas-intent';
import type { Skill } from './schemas';
import { getSkillPrice, calculateIntentCost, getAllSkillPrices, getSkillCurveConfig } from './skill-curve';
import { getMemberSkillReputation, getMemberOverallReputation } from './reputation';
import { solve } from './solver';
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
/**
* Create the intent routes sub-router.
* Requires a SyncServer reference (passed from mod.ts onInit).
*/
export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono {
const routes = new Hono();
// ── Helpers ──
function syncServer(): SyncServer {
const ss = getSyncServer();
if (!ss) throw new Error('SyncServer not initialized');
return ss;
}
function ensureIntentsDoc(space: string): IntentsDoc {
const docId = intentsDocId(space);
let doc = syncServer().getDoc<IntentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<IntentsDoc>(), 'init intents', (d) => {
const init = intentsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureSolverResultsDoc(space: string): SolverResultsDoc {
const docId = solverResultsDocId(space);
let doc = syncServer().getDoc<SolverResultsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SolverResultsDoc>(), 'init solver results', (d) => {
const init = solverResultsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureSkillCurvesDoc(space: string): SkillCurvesDoc {
const docId = skillCurvesDocId(space);
let doc = syncServer().getDoc<SkillCurvesDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SkillCurvesDoc>(), 'init skill curves', (d) => {
const init = skillCurvesSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureReputationDoc(space: string): ReputationDoc {
const docId = reputationDocId(space);
let doc = syncServer().getDoc<ReputationDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ReputationDoc>(), 'init reputation', (d) => {
const init = reputationSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
const token = extractToken(headers);
if (!token) return null;
try {
const claims = await verifyToken(token);
return { did: claims.sub, username: (claims as any).username || claims.sub };
} catch {
return null;
}
}
// ── Intent CRUD ──
// POST /api/intent — Create a new intent
routes.post('/api/intent', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const body = await c.req.json();
const { type, skill, hours, description, minReputation, maxPrice, preferredMembers, expiresAt } = body;
// Validate
if (!type || !['offer', 'need'].includes(type)) {
return c.json({ error: 'type must be "offer" or "need"' }, 400);
}
if (!skill || !VALID_SKILLS.includes(skill)) {
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
}
if (!hours || hours < 1 || hours > 100) {
return c.json({ error: 'hours must be between 1 and 100' }, 400);
}
ensureIntentsDoc(space);
ensureSkillCurvesDoc(space);
const id = crypto.randomUUID();
const now = Date.now();
let escrowTxId: string | undefined;
// For need intents: lock tokens in escrow
if (type === 'need') {
const curvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const cost = calculateIntentCost(skill, hours, curvesDoc);
const offRampId = `intent-${id}`;
const success = burnTokensEscrow(
'cusdc',
auth.did,
auth.username,
cost,
offRampId,
`Intent escrow: ${hours}h ${skill} @ ${getSkillPrice(skill, curvesDoc)} tokens/h`,
);
if (!success) {
return c.json({ error: 'Insufficient balance for escrow' }, 402);
}
escrowTxId = offRampId;
}
// Write intent
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'create intent', (d) => {
d.intents[id] = {
id,
memberId: auth.did,
memberName: auth.username,
type,
skill,
hours: Math.max(1, Math.min(100, hours)),
description: description || '',
status: 'active',
createdAt: now,
...(minReputation != null ? { minReputation } : {}),
...(maxPrice != null ? { maxPrice } : {}),
...(preferredMembers?.length ? { preferredMembers } : {}),
...(escrowTxId ? { escrowTxId } : {}),
...(expiresAt ? { expiresAt } : {}),
} as any;
});
// Update skill curves
updateSkillCurves(space, syncServer());
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
return c.json(doc.intents[id], 201);
});
// PATCH /api/intent/:id — Update or withdraw an intent
routes.patch('/api/intent/:id', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const id = c.req.param('id');
const body = await c.req.json();
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intent = doc.intents[id];
if (!intent) return c.json({ error: 'Intent not found' }, 404);
if (intent.memberId !== auth.did) return c.json({ error: 'Not your intent' }, 403);
if (intent.status !== 'active') return c.json({ error: `Cannot modify intent in status: ${intent.status}` }, 400);
// Handle withdrawal
if (body.status === 'withdrawn') {
// Reverse escrow if need intent
if (intent.escrowTxId) {
reverseBurn('cusdc', intent.escrowTxId);
}
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'withdraw intent', (d) => {
d.intents[id].status = 'withdrawn';
});
updateSkillCurves(space, syncServer());
return c.json({ ok: true, status: 'withdrawn' });
}
// Handle field updates
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'update intent', (d) => {
if (body.description !== undefined) d.intents[id].description = body.description;
if (body.minReputation !== undefined) d.intents[id].minReputation = body.minReputation;
if (body.maxPrice !== undefined) d.intents[id].maxPrice = body.maxPrice;
if (body.preferredMembers !== undefined) d.intents[id].preferredMembers = body.preferredMembers;
});
const updated = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
return c.json(updated.intents[id]);
});
// GET /api/intents — List active intents for space
routes.get('/api/intents', (c) => {
const space = c.req.param('space') || 'demo';
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intents = Object.values(doc.intents);
return c.json({ intents });
});
// GET /api/intents/:memberId — List member's intents
routes.get('/api/intents/:memberId', (c) => {
const space = c.req.param('space') || 'demo';
const memberId = c.req.param('memberId');
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intents = Object.values(doc.intents).filter(i => i.memberId === memberId);
return c.json({ intents });
});
// ── Solver ──
// GET /api/solver-results — Get current solver recommendations
routes.get('/api/solver-results', (c) => {
const space = c.req.param('space') || 'demo';
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const results = Object.values(doc.results)
.filter(r => r.status === 'proposed' || r.status === 'accepted');
return c.json({ results });
});
// POST /api/solver/run — Manual solver trigger
routes.post('/api/solver/run', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultCount = runSolver(space);
return c.json({ ok: true, resultsGenerated: resultCount });
});
// POST /api/solver-results/:id/accept — Member accepts a recommendation
routes.post('/api/solver-results/:id/accept', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultId = c.req.param('id');
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const result = doc.results[resultId];
if (!result) return c.json({ error: 'Result not found' }, 404);
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
if (result.status !== 'proposed' && result.status !== 'accepted') {
return c.json({ error: `Cannot accept result in status: ${result.status}` }, 400);
}
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'accept result', (d) => {
d.results[resultId].acceptances[auth.did] = true;
});
// Check if all accepted → auto-settle
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const updatedResult = updated.results[resultId];
if (isReadyForSettlement(updatedResult)) {
// Mark as accepted first
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'mark accepted', (d) => {
d.results[resultId].status = 'accepted';
});
const outcome = await settleResult(resultId, space, syncServer());
return c.json({ ok: true, settled: outcome.success, outcome });
}
return c.json({
ok: true,
settled: false,
acceptances: updatedResult.acceptances,
remaining: updatedResult.members.filter(m => !updatedResult.acceptances[m]),
});
});
// POST /api/solver-results/:id/reject — Member rejects a recommendation
routes.post('/api/solver-results/:id/reject', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultId = c.req.param('id');
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const result = doc.results[resultId];
if (!result) return c.json({ error: 'Result not found' }, 404);
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'reject result', (d) => {
d.results[resultId].status = 'rejected';
});
return c.json({ ok: true });
});
// ── Skill Curves ──
// GET /api/skill-curves — Get current skill prices
routes.get('/api/skill-curves', (c) => {
const space = c.req.param('space') || 'demo';
ensureSkillCurvesDoc(space);
const doc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const prices = getAllSkillPrices(doc);
return c.json({ curves: Object.values(doc.curves), prices, config: getSkillCurveConfig() });
});
// ── Reputation ──
// GET /api/reputation/:memberId — Get member reputation
routes.get('/api/reputation/:memberId', (c) => {
const space = c.req.param('space') || 'demo';
const memberId = c.req.param('memberId');
ensureReputationDoc(space);
const doc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
const entries = Object.values(doc.entries).filter(e => e.memberId === memberId);
const overall = getMemberOverallReputation(memberId, doc);
return c.json({ memberId, overall, skills: entries });
});
// ── Internal: Run solver ──
function runSolver(space: string): number {
ensureIntentsDoc(space);
ensureSolverResultsDoc(space);
ensureSkillCurvesDoc(space);
ensureReputationDoc(space);
const intentsDoc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const reputationDoc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
const skillCurvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc);
if (candidates.length === 0) return 0;
// Clear old proposed results and write new ones
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'solver: write results', (d) => {
// Remove stale proposed results
for (const [id, result] of Object.entries(d.results)) {
if (result.status === 'proposed') delete d.results[id];
}
// Add new results
const now = Date.now();
for (const candidate of candidates) {
const id = crypto.randomUUID();
d.results[id] = {
id,
...candidate,
createdAt: now,
} as any;
}
});
console.log(`[rTime] Solver produced ${candidates.length} results for space "${space}"`);
return candidates.length;
}
return routes;
}

View File

@ -27,12 +27,21 @@ import type {
CommitmentsDoc, TasksDoc,
Commitment, Task, Connection, ExecState, Skill,
} from './schemas';
import {
intentsSchema, solverResultsSchema,
skillCurvesSchema, reputationSchema,
} from './schemas-intent';
import { createIntentRoutes } from './intent-routes';
const routes = new Hono();
// ── SyncServer ref (set during onInit) ──
let _syncServer: SyncServer | null = null;
// ── Mount intent routes ──
const intentRoutes = createIntentRoutes(() => _syncServer);
routes.route('/', intentRoutes);
// ── Automerge helpers ──
function ensureCommitmentsDoc(space: string): CommitmentsDoc {
@ -391,6 +400,10 @@ export const timeModule: RSpaceModule = {
docSchemas: [
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init },
{ pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
{ pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init },
{ pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init },
{ pattern: '{space}:rtime:reputation', description: 'Per-member per-skill reputation', init: reputationSchema.init },
],
routes,
landingPage: renderLanding,
@ -411,6 +424,7 @@ export const timeModule: RSpaceModule = {
outputPaths: [
{ path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" },
{ path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" },
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
],
onboardingActions: [
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' },

105
modules/rtime/reputation.ts Normal file
View File

@ -0,0 +1,105 @@
/**
* Per-skill reputation scoring with time-based decay.
*
* Score formula: baseScore × decayFactor
* baseScore = (avgRating / 5) × 80 + (min(completedHours, 50) / 50) × 20
* decayFactor = e^(-λ × daysSinceLastRating) where λ = 0.005
*
* Score range: 0-100. New members start at 50 (neutral).
*/
import type { Skill } from './schemas';
import type { ReputationDoc, ReputationEntry, ReputationRating } from './schemas-intent';
// ── Constants ──
/** Default score for members with no reputation */
export const DEFAULT_REPUTATION = 50;
/** Decay rate (per day). At λ=0.005, score halves after ~139 days of inactivity */
const DECAY_LAMBDA = 0.005;
/** Rating weight (out of 100) */
const RATING_WEIGHT = 80;
/** Hours weight (out of 100) */
const HOURS_WEIGHT = 20;
/** Hours cap for scoring (diminishing returns beyond this) */
const HOURS_CAP = 50;
const MS_PER_DAY = 86_400_000;
// ── Scoring ──
/**
* Calculate the reputation score for a member-skill pair.
*/
export function calculateScore(entry: ReputationEntry, now: number = Date.now()): number {
if (entry.ratings.length === 0) return DEFAULT_REPUTATION;
// Average rating (1-5 scale)
const avgRating = entry.ratings.reduce((sum, r) => sum + r.score, 0) / entry.ratings.length;
// Base score: rating component + hours component
const ratingComponent = (avgRating / 5) * RATING_WEIGHT;
const hoursComponent = (Math.min(entry.completedHours, HOURS_CAP) / HOURS_CAP) * HOURS_WEIGHT;
const baseScore = ratingComponent + hoursComponent;
// Decay based on time since most recent rating
const lastRating = entry.ratings.reduce((latest, r) => Math.max(latest, r.timestamp), 0);
const daysSince = (now - lastRating) / MS_PER_DAY;
const decayFactor = Math.exp(-DECAY_LAMBDA * Math.max(0, daysSince));
return Math.round(baseScore * decayFactor);
}
/**
* Get a member's reputation for a specific skill, or DEFAULT_REPUTATION if none.
*/
export function getMemberSkillReputation(
memberId: string,
skill: Skill,
reputationDoc: ReputationDoc,
now?: number,
): number {
const key = `${memberId}:${skill}`;
const entry = reputationDoc.entries[key];
if (!entry) return DEFAULT_REPUTATION;
return calculateScore(entry, now);
}
/**
* Get a member's average reputation across all skills they have entries for.
*/
export function getMemberOverallReputation(
memberId: string,
reputationDoc: ReputationDoc,
now?: number,
): number {
const entries = Object.values(reputationDoc.entries)
.filter(e => e.memberId === memberId);
if (entries.length === 0) return DEFAULT_REPUTATION;
const total = entries.reduce((sum, e) => sum + calculateScore(e, now), 0);
return Math.round(total / entries.length);
}
/**
* Build a reputation entry key from memberId and skill.
*/
export function reputationKey(memberId: string, skill: Skill): string {
return `${memberId}:${skill}`;
}
/**
* Create a new rating to add to a reputation entry.
*/
export function createRating(fromMemberId: string, score: number): ReputationRating {
return {
from: fromMemberId,
score: Math.max(1, Math.min(5, Math.round(score))),
timestamp: Date.now(),
};
}

View File

@ -0,0 +1,207 @@
/**
* rTime Intent Schemas Anoma-style resource-backed commitments.
*
* DocId formats:
* {space}:rtime:intents IntentsDoc (active intents pool)
* {space}:rtime:solver-results SolverResultsDoc (solver recommendations)
* {space}:rtime:skill-curves SkillCurvesDoc (per-skill pricing)
* {space}:rtime:reputation ReputationDoc (per-member per-skill reputation)
*/
import type { DocSchema } from '../../shared/local-first/document';
import type { Skill } from './schemas';
// ── Intent ──
export type IntentType = 'offer' | 'need';
export type IntentStatus = 'active' | 'matched' | 'settled' | 'withdrawn';
export interface Intent {
id: string;
memberId: string; // DID of the member
memberName: string;
type: IntentType;
skill: Skill;
hours: number;
description: string;
// Validity predicates
minReputation?: number; // minimum counterparty reputation (0-100)
maxPrice?: number; // maximum acceptable skill price (tokens/hour)
preferredMembers?: string[];// preferred collaborator DIDs
escrowTxId?: string; // token escrow reference (for needs)
status: IntentStatus;
createdAt: number;
expiresAt?: number;
}
// ── Solver Result ──
export type SolverResultStatus = 'proposed' | 'accepted' | 'rejected' | 'settled';
export interface SolverResult {
id: string;
intents: string[]; // intent IDs in this cluster
members: string[]; // participating member IDs
skills: Skill[];
totalHours: number;
score: number; // solver confidence score (0-1)
status: SolverResultStatus;
acceptances: Record<string, boolean>; // memberId → accepted?
createdAt: number;
}
// ── Skill Curve ──
export interface SkillCurveEntry {
skill: Skill;
supplyHours: number; // total offered hours
demandHours: number; // total needed hours
currentPrice: number; // calculated price per hour (tokens)
history: Array<{ price: number; timestamp: number }>;
}
// ── Reputation ──
export interface ReputationRating {
from: string; // rater memberId
score: number; // 1-5 stars
timestamp: number;
}
export interface ReputationEntry {
memberId: string;
skill: Skill;
score: number; // 0-100 computed score
completedHours: number;
ratings: ReputationRating[];
}
// ── Documents ──
export interface IntentsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
intents: Record<string, Intent>;
}
export interface SolverResultsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
results: Record<string, SolverResult>;
}
export interface SkillCurvesDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
curves: Record<string, SkillCurveEntry>;
}
export interface ReputationDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
entries: Record<string, ReputationEntry>;
}
// ── DocId helpers ──
export function intentsDocId(space: string) {
return `${space}:rtime:intents` as const;
}
export function solverResultsDocId(space: string) {
return `${space}:rtime:solver-results` as const;
}
export function skillCurvesDocId(space: string) {
return `${space}:rtime:skill-curves` as const;
}
export function reputationDocId(space: string) {
return `${space}:rtime:reputation` as const;
}
// ── Schema registrations ──
export const intentsSchema: DocSchema<IntentsDoc> = {
module: 'rtime',
collection: 'intents',
version: 1,
init: (): IntentsDoc => ({
meta: {
module: 'rtime',
collection: 'intents',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
intents: {},
}),
};
export const solverResultsSchema: DocSchema<SolverResultsDoc> = {
module: 'rtime',
collection: 'solver-results',
version: 1,
init: (): SolverResultsDoc => ({
meta: {
module: 'rtime',
collection: 'solver-results',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
results: {},
}),
};
export const skillCurvesSchema: DocSchema<SkillCurvesDoc> = {
module: 'rtime',
collection: 'skill-curves',
version: 1,
init: (): SkillCurvesDoc => ({
meta: {
module: 'rtime',
collection: 'skill-curves',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
curves: {},
}),
};
export const reputationSchema: DocSchema<ReputationDoc> = {
module: 'rtime',
collection: 'reputation',
version: 1,
init: (): ReputationDoc => ({
meta: {
module: 'rtime',
collection: 'reputation',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
entries: {},
}),
};

View File

@ -38,6 +38,8 @@ export interface Commitment {
desc: string;
cyclosMemberId?: string;
createdAt: number;
intentId?: string; // links commitment to its intent
status?: 'active' | 'matched' | 'settled' | 'withdrawn';
}
// ── Task / Connection / ExecState ──

266
modules/rtime/settlement.ts Normal file
View File

@ -0,0 +1,266 @@
/**
* Atomic settlement via saga pattern.
*
* When all members accept a solver result:
* 1. Validate all acceptances
* 2. Confirm escrow burns for need intents
* 3. Create Connection entries in CommitmentsDoc
* 4. Create Task entries in TasksDoc
* 5. Update intent statuses 'settled'
* 6. Update reputation entries
* 7. Update skill curve supply/demand counters
*
* On failure at any step: reverse all confirmed burns and abort.
*/
import * as Automerge from '@automerge/automerge';
import type { SyncServer } from '../../server/local-first/sync-server';
import { confirmBurn, reverseBurn } from '../../server/token-service';
import { commitmentsDocId, tasksDocId } from './schemas';
import type { CommitmentsDoc, TasksDoc, Skill } from './schemas';
import {
intentsDocId, solverResultsDocId,
skillCurvesDocId, reputationDocId,
} from './schemas-intent';
import type {
IntentsDoc, SolverResultsDoc,
SkillCurvesDoc, ReputationDoc,
SolverResult, Intent,
} from './schemas-intent';
import { reputationKey, DEFAULT_REPUTATION } from './reputation';
import { recalculateCurve } from './skill-curve';
// ── Settlement ──
export interface SettlementOutcome {
success: boolean;
resultId: string;
connectionsCreated: number;
tasksCreated: number;
error?: string;
}
/**
* Settle a solver result atomic multi-doc update with saga rollback.
*/
export async function settleResult(
resultId: string,
space: string,
syncServer: SyncServer,
): Promise<SettlementOutcome> {
const outcome: SettlementOutcome = {
success: false,
resultId,
connectionsCreated: 0,
tasksCreated: 0,
};
// Load all docs
const solverDoc = syncServer.getDoc<SolverResultsDoc>(solverResultsDocId(space));
const intentsDoc = syncServer.getDoc<IntentsDoc>(intentsDocId(space));
if (!solverDoc || !intentsDoc) {
outcome.error = 'Required documents not found';
return outcome;
}
const result = solverDoc.results[resultId];
if (!result) {
outcome.error = `Solver result ${resultId} not found`;
return outcome;
}
// Step 1: Validate all acceptances
const allAccepted = result.members.every(m => result.acceptances[m] === true);
if (!allAccepted) {
outcome.error = 'Not all members have accepted';
return outcome;
}
if (result.status !== 'proposed' && result.status !== 'accepted') {
outcome.error = `Result status is ${result.status}, expected proposed or accepted`;
return outcome;
}
// Gather intents
const intents = result.intents
.map(id => intentsDoc.intents[id])
.filter(Boolean);
if (intents.length !== result.intents.length) {
outcome.error = 'Some intents in the result no longer exist';
return outcome;
}
// Step 2: Confirm escrow burns for need intents (with saga tracking)
const confirmedEscrows: string[] = [];
const needIntents = intents.filter(i => i.type === 'need' && i.escrowTxId);
for (const need of needIntents) {
const success = confirmBurn('cusdc', need.escrowTxId!);
if (!success) {
// Rollback all previously confirmed burns
for (const txId of confirmedEscrows) {
reverseBurn('cusdc', txId);
}
outcome.error = `Failed to confirm escrow for intent ${need.id} (txId: ${need.escrowTxId})`;
return outcome;
}
confirmedEscrows.push(need.escrowTxId!);
}
// Step 3 & 4: Create connections and tasks
const now = Date.now();
const offerIntents = intents.filter(i => i.type === 'offer');
const needIntentsAll = intents.filter(i => i.type === 'need');
// Create a task for this collaboration
const taskId = crypto.randomUUID();
const taskSkills = [...new Set(intents.map(i => i.skill))];
const taskNeeds: Record<string, number> = {};
for (const need of needIntentsAll) {
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
}
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create task', (d) => {
d.tasks[taskId] = {
id: taskId,
name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`,
description: `Auto-generated from solver result. Members: ${result.members.length}`,
needs: taskNeeds,
links: [],
notes: `Settled from solver result ${resultId}`,
} as any;
});
outcome.tasksCreated = 1;
// Create connections (offer → task)
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create connections', (d) => {
for (const offer of offerIntents) {
// Find or create a commitment for this offer in CommitmentsDoc
const connId = crypto.randomUUID();
d.connections[connId] = {
id: connId,
fromCommitmentId: offer.id, // Using intent ID as reference
toTaskId: taskId,
skill: offer.skill,
} as any;
outcome.connectionsCreated++;
}
});
// Create commitment entries for each offer intent (so they appear in the pool)
syncServer.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'settlement: create commitments', (d) => {
for (const offer of offerIntents) {
if (!d.items[offer.id]) {
d.items[offer.id] = {
id: offer.id,
memberName: offer.memberName,
hours: offer.hours,
skill: offer.skill,
desc: offer.description,
createdAt: offer.createdAt,
intentId: offer.id,
status: 'settled',
} as any;
}
}
});
// Step 5: Update intent statuses
syncServer.changeDoc<IntentsDoc>(intentsDocId(space), 'settlement: mark intents settled', (d) => {
for (const intent of intents) {
if (d.intents[intent.id]) {
d.intents[intent.id].status = 'settled';
}
}
});
// Step 6: Update solver result status
syncServer.changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'settlement: mark result settled', (d) => {
if (d.results[resultId]) {
d.results[resultId].status = 'settled';
}
});
// Step 7: Update reputation (initial entry — actual ratings come later)
syncServer.changeDoc<ReputationDoc>(reputationDocId(space), 'settlement: init reputation', (d) => {
for (const intent of intents) {
const key = reputationKey(intent.memberId, intent.skill);
if (!d.entries[key]) {
d.entries[key] = {
memberId: intent.memberId,
skill: intent.skill,
score: DEFAULT_REPUTATION,
completedHours: 0,
ratings: [],
} as any;
}
// Add completed hours
d.entries[key].completedHours += intent.hours;
}
});
// Step 8: Update skill curves
updateSkillCurves(space, syncServer);
outcome.success = true;
return outcome;
}
/**
* Recalculate skill curves from current active intents.
*/
export function updateSkillCurves(space: string, syncServer: SyncServer): void {
const intentsDoc = syncServer.getDoc<IntentsDoc>(intentsDocId(space));
if (!intentsDoc) return;
const activeIntents = Object.values(intentsDoc.intents)
.filter(i => i.status === 'active');
// Aggregate supply/demand per skill
const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
const aggregates = new Map<Skill, { supply: number; demand: number }>();
for (const skill of skills) {
aggregates.set(skill, { supply: 0, demand: 0 });
}
for (const intent of activeIntents) {
const agg = aggregates.get(intent.skill);
if (!agg) continue;
if (intent.type === 'offer') agg.supply += intent.hours;
else agg.demand += intent.hours;
}
syncServer.changeDoc<SkillCurvesDoc>(skillCurvesDocId(space), 'update skill curves', (d) => {
for (const [skill, agg] of aggregates) {
const updated = recalculateCurve(skill, agg.supply, agg.demand);
const now = Date.now();
if (!d.curves[skill]) {
d.curves[skill] = {
...updated,
history: [{ price: updated.currentPrice, timestamp: now }],
} as any;
} else {
d.curves[skill].supplyHours = updated.supplyHours;
d.curves[skill].demandHours = updated.demandHours;
d.curves[skill].currentPrice = updated.currentPrice;
// Append to history (keep last 100 entries)
if (!d.curves[skill].history) d.curves[skill].history = [] as any;
d.curves[skill].history.push({ price: updated.currentPrice, timestamp: now } as any);
if (d.curves[skill].history.length > 100) {
d.curves[skill].history.splice(0, d.curves[skill].history.length - 100);
}
}
}
});
}
/**
* Check if a result is ready for settlement (all members accepted).
*/
export function isReadyForSettlement(result: SolverResult): boolean {
if (result.status !== 'proposed' && result.status !== 'accepted') return false;
return result.members.every(m => result.acceptances[m] === true);
}

View File

@ -0,0 +1,96 @@
/**
* Per-skill bonding curve demand/supply pricing for intent marketplace.
*
* Formula: price = BASE_SKILL_PRICE × (demandHours / supplyHours) ^ ELASTICITY
*
* High demand + low supply higher price incentivizes offers.
* All prices in tokens per hour.
*/
import type { Skill } from './schemas';
import type { SkillCurvesDoc, SkillCurveEntry } from './schemas-intent';
// ── Curve parameters ──
/** Base price in tokens per hour */
export const BASE_SKILL_PRICE = 100;
/** Elasticity exponent — 0.5 = square root (moderate responsiveness) */
export const ELASTICITY = 0.5;
/** Minimum supply to avoid division by zero / extreme prices */
const MIN_SUPPLY = 1;
/** Maximum price cap (10x base) to prevent runaway pricing */
const MAX_PRICE = BASE_SKILL_PRICE * 10;
// ── Price functions ──
/**
* Calculate the current price per hour for a skill.
* Returns tokens per hour.
*/
export function getSkillPrice(skill: Skill, curves: SkillCurvesDoc): number {
const curve = curves.curves[skill];
if (!curve || curve.supplyHours <= 0) return BASE_SKILL_PRICE;
const supply = Math.max(curve.supplyHours, MIN_SUPPLY);
const demand = Math.max(curve.demandHours, 0);
const ratio = demand / supply;
const price = Math.round(BASE_SKILL_PRICE * Math.pow(ratio, ELASTICITY));
return Math.min(price, MAX_PRICE);
}
/**
* Calculate the total cost for a need intent (hours × skill price).
*/
export function calculateIntentCost(skill: Skill, hours: number, curves: SkillCurvesDoc): number {
const price = getSkillPrice(skill, curves);
return price * hours;
}
/**
* Get all current skill prices.
*/
export function getAllSkillPrices(curves: SkillCurvesDoc): Record<Skill, number> {
const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
const prices = {} as Record<Skill, number>;
for (const skill of skills) {
prices[skill] = getSkillPrice(skill, curves);
}
return prices;
}
/**
* Create or update a skill curve entry after an intent is created/withdrawn.
* Returns the updated curve entry.
*/
export function recalculateCurve(
skill: Skill,
supplyHours: number,
demandHours: number,
): Omit<SkillCurveEntry, 'history'> {
const supply = Math.max(supplyHours, MIN_SUPPLY);
const demand = Math.max(demandHours, 0);
const ratio = demand / supply;
const price = Math.min(Math.round(BASE_SKILL_PRICE * Math.pow(ratio, ELASTICITY)), MAX_PRICE);
return {
skill,
supplyHours,
demandHours,
currentPrice: price,
};
}
/**
* Get curve configuration for UI display.
*/
export function getSkillCurveConfig() {
return {
basePrice: BASE_SKILL_PRICE,
elasticity: ELASTICITY,
maxPrice: MAX_PRICE,
};
}

353
modules/rtime/solver.ts Normal file
View File

@ -0,0 +1,353 @@
/**
* Mycelium Clustering Solver finds optimal collaboration matches
* from the intent pool using bipartite graph matching.
*
* Algorithm:
* 1. Build bipartite graph: offer intents need intents (edges where skill matches)
* 2. Filter by validity predicates (min reputation, max price, preferred members)
* 3. Greedy cluster formation starting from highest-demand skills
* 4. Score clusters: skillMatch×0.4 + hoursBalance×0.3 + avgReputation×0.2 + vpSatisfaction×0.1
* 5. Output top-K results, de-duplicated by member overlap
*/
import type { Skill } from './schemas';
import type {
Intent, SolverResult,
IntentsDoc, SkillCurvesDoc, ReputationDoc,
} from './schemas-intent';
import { getMemberSkillReputation, DEFAULT_REPUTATION } from './reputation';
import { getSkillPrice } from './skill-curve';
// ── Config ──
/** Maximum number of results to output */
const TOP_K = 10;
/** Maximum member overlap between results (0-1) */
const MAX_OVERLAP = 0.5;
/** Scoring weights */
const W_SKILL_MATCH = 0.4;
const W_HOURS_BALANCE = 0.3;
const W_REPUTATION = 0.2;
const W_VP_SATISFACTION = 0.1;
// ── Types ──
interface Edge {
offerId: string;
needId: string;
skill: Skill;
offerHours: number;
needHours: number;
}
interface Cluster {
intentIds: string[];
memberIds: Set<string>;
skills: Set<Skill>;
offerHours: number;
needHours: number;
edges: Edge[];
}
// ── Solver ──
/**
* Run the Mycelium Clustering solver on the current intent pool.
* Returns scored SolverResult candidates (without IDs caller assigns).
*/
export function solve(
intentsDoc: IntentsDoc,
reputationDoc: ReputationDoc,
skillCurvesDoc: SkillCurvesDoc,
): Omit<SolverResult, 'id' | 'createdAt'>[] {
const intents = Object.values(intentsDoc.intents)
.filter(i => i.status === 'active');
const offers = intents.filter(i => i.type === 'offer');
const needs = intents.filter(i => i.type === 'need');
if (offers.length === 0 || needs.length === 0) return [];
// Step 1: Build bipartite edges
const edges = buildEdges(offers, needs);
// Step 2: Filter by validity predicates
const filtered = filterByVPs(edges, offers, needs, reputationDoc, skillCurvesDoc);
if (filtered.length === 0) return [];
// Step 3: Greedy clustering
const clusters = greedyCluster(filtered, intents);
// Step 4: Score and rank
const scored = clusters
.map(c => scoreCluster(c, intents, reputationDoc))
.sort((a, b) => b.score - a.score);
// Step 5: De-duplicate by member overlap, take top-K
const results = deduplicateResults(scored, TOP_K);
return results;
}
/**
* Build bipartite edges between matching offer and need intents.
*/
function buildEdges(offers: Intent[], needs: Intent[]): Edge[] {
const edges: Edge[] = [];
for (const offer of offers) {
for (const need of needs) {
// Must match on skill
if (offer.skill !== need.skill) continue;
// Don't match same member with themselves
if (offer.memberId === need.memberId) continue;
edges.push({
offerId: offer.id,
needId: need.id,
skill: offer.skill,
offerHours: offer.hours,
needHours: need.hours,
});
}
}
return edges;
}
/**
* Filter edges by validity predicates.
*/
function filterByVPs(
edges: Edge[],
offers: Intent[],
needs: Intent[],
reputationDoc: ReputationDoc,
skillCurvesDoc: SkillCurvesDoc,
): Edge[] {
const offerMap = new Map(offers.map(o => [o.id, o]));
const needMap = new Map(needs.map(n => [n.id, n]));
return edges.filter(edge => {
const offer = offerMap.get(edge.offerId)!;
const need = needMap.get(edge.needId)!;
// Check need's minReputation against offer's reputation
if (need.minReputation != null) {
const offerRep = getMemberSkillReputation(offer.memberId, edge.skill, reputationDoc);
if (offerRep < need.minReputation) return false;
}
// Check offer's minReputation against need's reputation
if (offer.minReputation != null) {
const needRep = getMemberSkillReputation(need.memberId, edge.skill, reputationDoc);
if (needRep < offer.minReputation) return false;
}
// Check need's maxPrice against current skill price
if (need.maxPrice != null) {
const price = getSkillPrice(edge.skill, skillCurvesDoc);
if (price > need.maxPrice) return false;
}
// Check preferred members (if specified, counterparty must be in list)
if (need.preferredMembers?.length) {
if (!need.preferredMembers.includes(offer.memberId)) return false;
}
if (offer.preferredMembers?.length) {
if (!offer.preferredMembers.includes(need.memberId)) return false;
}
return true;
});
}
/**
* Greedy cluster formation starting from highest-demand skills.
*/
function greedyCluster(edges: Edge[], allIntents: Intent[]): Cluster[] {
const intentMap = new Map(allIntents.map(i => [i.id, i]));
// Count demand per skill to prioritize
const skillDemand = new Map<Skill, number>();
for (const edge of edges) {
skillDemand.set(edge.skill, (skillDemand.get(edge.skill) || 0) + edge.needHours);
}
// Sort skills by demand (highest first)
const skillOrder = Array.from(skillDemand.entries())
.sort((a, b) => b[1] - a[1])
.map(([skill]) => skill);
const clusters: Cluster[] = [];
const usedIntents = new Set<string>();
for (const skill of skillOrder) {
// Get edges for this skill, excluding already-used intents
const skillEdges = edges.filter(e =>
e.skill === skill &&
!usedIntents.has(e.offerId) &&
!usedIntents.has(e.needId)
);
if (skillEdges.length === 0) continue;
// Group by need — each need tries to find the best offer
const needIds = [...new Set(skillEdges.map(e => e.needId))];
for (const needId of needIds) {
if (usedIntents.has(needId)) continue;
const need = intentMap.get(needId)!;
const candidateEdges = skillEdges.filter(e =>
e.needId === needId && !usedIntents.has(e.offerId)
);
if (candidateEdges.length === 0) continue;
// Greedy: pick the offer with the closest hour match
const bestEdge = candidateEdges.reduce((best, e) => {
const bestDiff = Math.abs(best.offerHours - best.needHours);
const eDiff = Math.abs(e.offerHours - e.needHours);
return eDiff < bestDiff ? e : best;
});
const offer = intentMap.get(bestEdge.offerId)!;
const cluster: Cluster = {
intentIds: [bestEdge.offerId, bestEdge.needId],
memberIds: new Set([offer.memberId, need.memberId]),
skills: new Set([skill]),
offerHours: bestEdge.offerHours,
needHours: bestEdge.needHours,
edges: [bestEdge],
};
usedIntents.add(bestEdge.offerId);
usedIntents.add(bestEdge.needId);
clusters.push(cluster);
}
}
// Second pass: try to merge clusters that share members (multi-skill collaborations)
return mergeClusters(clusters);
}
/**
* Merge clusters that share members into multi-skill collaborations.
*/
function mergeClusters(clusters: Cluster[]): Cluster[] {
const merged: Cluster[] = [];
const consumed = new Set<number>();
for (let i = 0; i < clusters.length; i++) {
if (consumed.has(i)) continue;
const current = { ...clusters[i], memberIds: new Set(clusters[i].memberIds), skills: new Set(clusters[i].skills) };
for (let j = i + 1; j < clusters.length; j++) {
if (consumed.has(j)) continue;
// Check if clusters share any members
const overlap = [...clusters[j].memberIds].some(m => current.memberIds.has(m));
if (!overlap) continue;
// Merge
for (const id of clusters[j].intentIds) current.intentIds.push(id);
for (const m of clusters[j].memberIds) current.memberIds.add(m);
for (const s of clusters[j].skills) current.skills.add(s);
current.offerHours += clusters[j].offerHours;
current.needHours += clusters[j].needHours;
current.edges.push(...clusters[j].edges);
consumed.add(j);
}
merged.push(current);
}
return merged;
}
/**
* Score a cluster and convert to SolverResult shape.
*/
function scoreCluster(
cluster: Cluster,
allIntents: Intent[],
reputationDoc: ReputationDoc,
): Omit<SolverResult, 'id' | 'createdAt'> {
const intentMap = new Map(allIntents.map(i => [i.id, i]));
const clusterIntents = cluster.intentIds.map(id => intentMap.get(id)!);
// 1. Skill match score (1.0 = all intents match on skill)
const skillMatchScore = 1.0; // edges only exist where skills match
// 2. Hours balance (1.0 = perfect offer/need balance, 0 = severe imbalance)
const hoursBalance = cluster.offerHours > 0 && cluster.needHours > 0
? 1 - Math.abs(cluster.offerHours - cluster.needHours) / Math.max(cluster.offerHours, cluster.needHours)
: 0;
// 3. Average reputation of participants
const memberIds = [...cluster.memberIds];
const repScores = memberIds.map(memberId => {
const memberIntents = clusterIntents.filter(i => i.memberId === memberId);
const skills = [...new Set(memberIntents.map(i => i.skill))];
if (skills.length === 0) return DEFAULT_REPUTATION;
const avg = skills.reduce((sum, skill) =>
sum + getMemberSkillReputation(memberId, skill, reputationDoc), 0) / skills.length;
return avg;
});
const avgReputation = repScores.length > 0
? repScores.reduce((a, b) => a + b, 0) / repScores.length / 100
: 0.5;
// 4. VP satisfaction (what fraction of VPs are satisfied — already pre-filtered, so mostly 1.0)
const vpSatisfaction = 1.0;
const score = Number((
W_SKILL_MATCH * skillMatchScore +
W_HOURS_BALANCE * hoursBalance +
W_REPUTATION * avgReputation +
W_VP_SATISFACTION * vpSatisfaction
).toFixed(4));
return {
intents: cluster.intentIds,
members: memberIds,
skills: [...cluster.skills],
totalHours: cluster.offerHours + cluster.needHours,
score,
status: 'proposed',
acceptances: Object.fromEntries(memberIds.map(m => [m, false])),
};
}
/**
* De-duplicate results by member overlap.
* Two results overlap if they share > MAX_OVERLAP fraction of members.
*/
function deduplicateResults(
results: Omit<SolverResult, 'id' | 'createdAt'>[],
limit: number,
): Omit<SolverResult, 'id' | 'createdAt'>[] {
const kept: Omit<SolverResult, 'id' | 'createdAt'>[] = [];
for (const result of results) {
if (kept.length >= limit) break;
const resultMembers = new Set(result.members);
const overlapping = kept.some(existing => {
const existingMembers = new Set(existing.members);
const overlap = [...resultMembers].filter(m => existingMembers.has(m)).length;
const minSize = Math.min(resultMembers.size, existingMembers.size);
return minSize > 0 && overlap / minSize > MAX_OVERLAP;
});
if (!overlapping) kept.push(result);
}
return kept;
}