Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d604f6f
feat(gateway): Anthropic API proxy for Claude Code tool_use visibility
claude Jun 5, 2026
3daaf3c
feat(gateway): add OpenAI proxy + standalone PoC test; fix Express 5 …
claude Jun 5, 2026
5fe79ab
docs: gateway strategy — collection, dispatch, gamification pipeline
claude Jun 5, 2026
da216e3
feat(gateway): Phase A — per-task model switching with small/mid/larg…
claude Jun 5, 2026
404520e
feat(gateway): Phase B — telemetry spine with SSE streaming
claude Jun 5, 2026
75fa478
feat(dispatch): Phase C — telemetry-driven dispatch engine
claude Jun 5, 2026
b4eb379
feat(game): Phase D — gamification core driven by real telemetry
claude Jun 5, 2026
c5d1ff4
feat(dashboard): Phase E — live activity timeline + game state (the f…
claude Jun 5, 2026
12fc76b
chore: finalize — deprecate Cursor, vitest coverage, docs + config
claude Jun 5, 2026
af1f3af
feat(native): Phase F — self-hosted agent loop, no vendor SDK
claude Jun 6, 2026
9513dea
feat(native): give throngs VibeSync session-history tools
claude Jun 6, 2026
a1680fc
chore: gitignore scratch/ analysis dir
claude Jun 6, 2026
9718dca
feat(atlas): ArtifactEngine — files-as-loot from tool-call telemetry
claude Jun 6, 2026
e398b8d
feat(atlas): dashboard Artifact Atlas view
claude Jun 6, 2026
8966398
fix(cors): allow ngrok origins and static assets
Jun 6, 2026
0322031
fix(fleet+chill): loot in the habitat, unbreak hatch, readable activity
claude Jun 6, 2026
47d9a78
feat(dashboard): let the dispatcher's model be changed from the UI
claude Jun 7, 2026
77eb167
feat(gateway): real token gateway — virtual keys, budgets, routing
claude Jun 7, 2026
4ebb82a
fix(gateway): price gpt-5/o-series so USD budgets actually accrue
claude Jun 7, 2026
2eced0a
fix(dispatcher): act on hatch requests instead of interrogating
claude Jun 7, 2026
79ec40f
fix(dispatcher): task freshly hatched throngs instead of leaving them…
claude Jun 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ test-*.ts
.claude/
AGENTS.override.md
.agents-override-hash
scratch/
60 changes: 57 additions & 3 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,29 @@ telegram:
allowed_chats:
- "your-chat-id" # Get via: send /start to @userinfobot on Telegram

# Runtimes: prefer `codex` (OpenAI) or `claude-code` (Anthropic) — their model
# traffic flows through the Thronglets gateway, which unlocks tool-call visibility,
# per-task model switching, telemetry-driven dispatch, and gamification.
# `cursor` is DEPRECATED: it runs in Cursor's cloud and bypasses the gateway.
agents:
- name: default
runtime: cursor
api_key: ${CURSOR_API_KEY} # Get from: https://cursor.com/settings
model: claude-opus-4-6
runtime: codex
api_key: ${OPENAI_API_KEY} # Get from: https://platform.openai.com/api-keys
model: gpt-4o-mini

# - name: claude
# runtime: claude-code
# api_key: ${ANTHROPIC_API_KEY}
# model: claude-haiku-4-5-20251001

# `native` (Phase F): Thronglets runs the agent loop itself — no vendor SDK.
# Talks to the OpenAI/Anthropic API directly, executes tools in-process, and
# emits telemetry straight to the fleet bus (dispatch + gamification for free).
# Provider is inferred from the model id (claude* → anthropic, else openai).
# - name: nova
# runtime: native
# api_key: ${OPENAI_API_KEY}
# model: gpt-4o-mini

# Dispatcher: AI-powered message router that manages the fleet
dispatcher:
Expand Down Expand Up @@ -41,6 +59,42 @@ fleet:
# tool_calls: show fleet tool execution logs
tool_calls: true

# ─── Gateway-powered dispatch (Phase A–E) ───
# Per-task model tiers. Dispatch picks small/mid/large per task and the gateway
# rewrites the model on the fly. Override the defaults here if you like:
# models:
# openai: { small: gpt-4o-mini, mid: gpt-4o, large: gpt-4.1 }
# anthropic: { small: claude-haiku-4-5-20251001, mid: claude-sonnet-4-6, large: claude-opus-4-8 }

# Per-agent USD budget (0 = unlimited). The dispatch engine flags over-budget throngs.
# budget_usd_per_agent: 0

# File-ownership lock window (ms). Stops two throngs editing the same file at once.
# lock_ttl_ms: 300000

# ─── Token gateway (governance) ───
# A real LLM gateway (Bifrost-inspired): the gateway holds the upstream provider
# keys, and every agent reaches the model through a *virtual key* (`vk-<name>`)
# — so no throng ever holds an `sk-…`. Per-VK budgets/rate-limits are metered and
# enforced; provider keys load-balance and fail over. Stats at GET /gateway/stats.
#
# When `gateway.enabled: true`, native agents are automatically routed through it.
# Omit the block entirely to keep today's behavior (direct provider calls).
# gateway:
# enabled: true
# providers:
# openai: { keys: ["${OPENAI_API_KEY}"] } # one or more — extra keys = failover
# anthropic: { keys: ["${ANTHROPIC_API_KEY}"] }
# virtual_keys:
# # The dispatcher gets a generous budget and downgrades (not blocks) when spent.
# _dispatcher: { providers: [openai], budget: { usd: 5, window: daily }, on_exceed: downgrade }
# # Default for every other throng: hard daily cap + rate limit.
# "*": { budget: { usd: 2, window: daily }, on_exceed: block, rpm: 60 }
# # budget windows: daily | monthly | total. on_exceed: block | downgrade.

# Gateway: set THRONGLETS_GATEWAY_ENABLED=false to disable the API proxy entirely
# (falls back to plain SDK calls — no telemetry, dispatch, or gamification).

# Optional: local conversation logs
session:
log_dir: ~/.thronglets/logs
302 changes: 302 additions & 0 deletions docs/gateway-strategy.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 155 additions & 3 deletions packages/dashboard/public/chill/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
<span class="stat"><span class="dot dot-sleep"></span><b id="ss">0</b></span>
<span class="stat">🍖 <b id="f">0</b></span>
<span class="stat">💬 <b id="c">0</b></span>
<span class="stat" title="discovered artifacts (loot)">🎁 <b id="lt">0</b></span>
<span class="stat" id="legendary-stat" title="legendary relics" style="opacity:.85;display:none">⭐ <b id="lg" style="color:#f5b942">0</b></span>
<span class="stat" id="time-indicator" style="opacity:.7">☀️ <b id="tod">day</b></span>
</div>
</div>
Expand Down Expand Up @@ -806,7 +808,11 @@

function act(x, y) {
if (tool === 'spawn') { const b = new Bot(x, y); bots.push(b); toast(`🥚 ${b.name} hatched!`, '#b0e0a0'); return; }
if (tool === 'inspect') { const b = getBot(x, y); if (b) showTip(x, y, b); return; }
if (tool === 'inspect') {
const l = getLoot(x, y); if (l) { showLootTip(x, y, l); return; }
const b = getBot(x, y); if (b) showTip(x, y, b); return;
}
const lh = getLoot(x, y); if (lh) { showLootTip(x, y, lh); lh.pulse = 1; emits(lh.x, lh.y - 8, '✨'); return; }
const b = getBot(x, y); if (!b) return;
if (tool === 'feed') b.feed();
else if (tool === 'pet') b.pet();
Expand All @@ -824,7 +830,7 @@

cv.onmousedown = e => { md = true; act(e.clientX, e.clientY); };
cv.onmouseup = () => md = false;
cv.onmousemove = e => { mx = e.clientX; my = e.clientY; if (md && tool === 'pet') { const b = getBot(mx, my); if (b && b.mood !== 1) b.pet(); } cv.style.cursor = getBot(mx, my) ? (tool === 'inspect' ? 'help' : 'pointer') : 'crosshair'; };
cv.onmousemove = e => { mx = e.clientX; my = e.clientY; if (md && tool === 'pet') { const b = getBot(mx, my); if (b && b.mood !== 1) b.pet(); } cv.style.cursor = (getBot(mx, my) || getLoot(mx, my)) ? (tool === 'inspect' ? 'help' : 'pointer') : 'crosshair'; };
cv.oncontextmenu = e => { e.preventDefault(); const old = tool; tool = 'poke'; act(e.clientX, e.clientY); tool = old; };
cv.ontouchstart = e => { e.preventDefault(); const t = e.touches[0]; mx = t.clientX; my = t.clientY; act(mx, my); };
cv.ontouchmove = e => { const t = e.touches[0]; mx = t.clientX; my = t.clientY; };
Expand Down Expand Up @@ -859,6 +865,148 @@
document.getElementById('time-indicator').firstChild.textContent = todIcon + ' ';
}

// ============================================================
// ARTIFACT LOOT — files-as-loot, dropped into the habitat world.
// The parent dashboard posts the atlas (/api/atlas) in; each artifact
// becomes a collectible relic on the ground, ranked by how widely it's
// used across sessions. This is the gamification *inside* the world,
// not a separate modal.
// ============================================================
const RARITY_COLOR = { common:'#9ca3af', uncommon:'#22c55e', rare:'#3b82f6', epic:'#a855f7', legendary:'#f5b942' };
const RARITY_RANK = { common:0, uncommon:1, rare:2, epic:3, legendary:4 };
const CLASS_GLYPH = { tome:'📖', rune:'⚙️', crystal:'💎', tool:'🛠️', relic:'🗿' };
const MAX_LOOT = 42; // keep the meadow readable

let loot = [];
const lootPos = new Map(); // id -> {x,y} stable placement across refreshes

function lootBasename(id) { const p = String(id).split('/'); return p[p.length - 1]; }

function placeLoot(id) {
if (lootPos.has(id)) return lootPos.get(id);
// deterministic-ish scatter inside the fenced meadow, away from the border
const h = hash32(id);
const m = 96;
const x = m + (h % 1000) / 1000 * (W - m * 2);
const y = m + ((h >>> 10) % 1000) / 1000 * (H - m * 2);
const pos = { x, y };
lootPos.set(id, pos);
return pos;
}

function ingestAtlas(items) {
if (!Array.isArray(items)) return;
// strongest relics first, capped so the world stays legible
const top = [...items]
.sort((a, b) => (RARITY_RANK[b.rarity] - RARITY_RANK[a.rarity]) || (b.level - a.level))
.slice(0, MAX_LOOT);
loot = top.map((it) => {
const pos = placeLoot(it.id);
return {
id: it.id,
name: lootBasename(it.id),
path: it.path || it.id,
rarity: it.rarity || 'common',
klass: it.klass || 'relic',
level: it.level || 1,
sessions: it.sessionCount || 0,
discoverers: (it.discoverers || []).length,
by: it.firstDiscoveredBy || '?',
live: !!it.live,
x: pos.x, y: pos.y,
phase: (hash32(it.id) % 628) / 100,
pulse: 0,
};
});
const legendary = loot.filter((l) => l.rarity === 'legendary').length;
document.getElementById('lt').textContent = loot.length;
const lgStat = document.getElementById('legendary-stat');
if (legendary > 0) { lgStat.style.display = ''; document.getElementById('lg').textContent = legendary; }
else { lgStat.style.display = 'none'; }
}

function drawLoot(dt) {
for (const l of loot) {
l.phase += dt * 2;
const col = RARITY_COLOR[l.rarity] || '#9ca3af';
const rank = RARITY_RANK[l.rarity] || 0;
const float = rank >= 3 ? Math.sin(l.phase) * 3 : Math.sin(l.phase * 0.5) * 1;
const gx = l.x, gy = l.y + float;

// ground shadow
ctx.fillStyle = 'rgba(0,0,0,.18)';
ctx.beginPath(); ctx.ellipse(l.x, l.y + 13, 9, 3, 0, 0, Math.PI * 2); ctx.fill();

// rarity aura — stronger for epic/legendary, throb when freshly touched
const glow = (rank >= 3 ? 0.45 : 0.22) + (l.pulse > 0 ? 0.4 * l.pulse : 0) + (rank >= 3 ? Math.sin(l.phase) * 0.08 : 0);
const grad = ctx.createRadialGradient(gx, gy, 1, gx, gy, 22);
grad.addColorStop(0, col + Math.round(Math.max(0, Math.min(1, glow)) * 255).toString(16).padStart(2, '0'));
grad.addColorStop(1, col + '00');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(gx, gy, 22, 0, Math.PI * 2); ctx.fill();

// gem pedestal — a small diamond plate
ctx.save();
ctx.translate(gx, gy);
ctx.fillStyle = col;
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.moveTo(0, -8); ctx.lineTo(9, 0); ctx.lineTo(0, 8); ctx.lineTo(-9, 0); ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();

// class glyph
ctx.font = '13px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(CLASS_GLYPH[l.klass] || '🗿', gx, gy - 0.5);
ctx.textBaseline = 'alphabetic';

// level badge for the notable relics
if (rank >= 2) {
ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center';
ctx.fillStyle = col;
roundRect(ctx, gx + 5, gy - 13, 16, 9, 2); ctx.fill();
ctx.fillStyle = '#0b0b0d';
ctx.fillText('L' + l.level, gx + 13, gy - 6);
}

if (l.pulse > 0) l.pulse = Math.max(0, l.pulse - dt * 1.4);
}
}

function getLoot(x, y) {
for (let i = loot.length - 1; i >= 0; i--) {
if (Math.hypot(x - loot[i].x, y - loot[i].y) < 16) return loot[i];
}
return null;
}

function showLootTip(x, y, l) {
tip.style.display = 'block';
tip.style.left = Math.min(x + 15, W - 220) + 'px';
tip.style.top = Math.min(y - 50, H - 90) + 'px';
const col = RARITY_COLOR[l.rarity];
tip.innerHTML = `<b>${CLASS_GLYPH[l.klass] || ''} ${l.name}</b> <span style="color:${col}">L${l.level}</span><br>` +
`<span style="color:${col};text-transform:uppercase;font-size:9px;letter-spacing:.05em">${l.rarity}</span> · ${l.klass}<br>` +
`🧩 ${l.sessions} sessions · 👾 ${l.discoverers} throngs<br>⛏ first found by ${l.by}`;
clearTimeout(tip._t);
tip._t = setTimeout(() => tip.style.display = 'none', 3500);
}

// Working throngs "discover" nearby relics — a little sparkle of life.
function lootProximity() {
for (const b of bots) {
if (b.status !== 'working') continue;
for (const l of loot) {
if (l.pulse > 0.2) continue;
if (Math.hypot(b.x - l.x, b.y - l.y) < 26) {
l.pulse = 1;
emits(l.x, l.y - 8, l.rarity === 'legendary' ? '✨' : '·');
}
}
}
}

// --- Game loop ---
let last = performance.now();
function loop() {
Expand All @@ -881,6 +1029,8 @@
updateToasts(dt);

drawConnections(); drawButterflies();
drawLoot(dt);
lootProximity();
bots.sort((a, b) => a.y - b.y);
for (const b of bots) { b.update(dt); b.draw(); }
drawPollen();
Expand All @@ -905,7 +1055,9 @@

// --- Listen for external notifications (from parent dashboard) ---
window.addEventListener('message', (evt) => {
if (!evt.data || evt.data.type !== 'thronglet_notification') return;
if (!evt.data) return;
if (evt.data.type === 'thronglet_atlas') { ingestAtlas(evt.data.items); return; }
if (evt.data.type !== 'thronglet_notification') return;
const { agentName } = evt.data;
const bot = bots.find(b => b.name === agentName);
if (bot) {
Expand Down
4 changes: 4 additions & 0 deletions packages/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { MobileDispatcher } from "./components/MobileDispatcher";
import { ChatBar } from "./components/ChatBar";
import { CommandBar } from "./components/CommandBar";
import { SpawnDialog } from "./components/SpawnDialog";
import { Atlas } from "./components/Atlas";
import { ChillMode } from "./components/ChillMode";
import { ActivityTimeline } from "./components/ActivityTimeline";
import { useKeyboard } from "./lib/useKeyboard";

const mobileQuery = typeof window !== "undefined" ? window.matchMedia("(max-width: 768px)") : null;
Expand Down Expand Up @@ -67,8 +69,10 @@ export function App() {
)}
</div>
{isMobile && <MobileDispatcher />}
{!isMobile && mode === "work" && <ActivityTimeline />}
<CommandBar />
<SpawnDialog />
<Atlas />
</>
);
}
Loading
Loading