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:
parent
16fe8f7626
commit
08cae267fe
|
|
@ -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> 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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {},
|
||||
}),
|
||||
};
|
||||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue