Compare commits
3 Commits
claude/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9972eb59fe | ||
| b6569aeedc | |||
| 1e2edeee77 |
@@ -21,7 +21,11 @@ export type CostEvent =
|
||||
export type CommentaryEvent =
|
||||
| { type: "agent_commentary"; agentId: string; jobId: string; text: string };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent;
|
||||
// External agent state changes (e.g. Kimi, Perplexity picking up or completing tasks)
|
||||
export type AgentExternalEvent =
|
||||
| { type: "agent:external_state"; agentId: string; state: string; taskSummary?: string };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent | AgentExternalEvent;
|
||||
|
||||
class EventBus extends EventEmitter {
|
||||
emit(event: "bus", data: BusEvent): boolean;
|
||||
|
||||
@@ -205,6 +205,29 @@ export class TrustService {
|
||||
verifyToken(token: string): { pubkey: string; expiry: number } | null {
|
||||
return verifyToken(token);
|
||||
}
|
||||
|
||||
// TEST-ONLY: apply one decay cycle immediately, ignoring time thresholds.
|
||||
// Subtracts DECAY_PER_DAY (default 1) from the stored trust score and persists.
|
||||
async decayOnce(pubkey: string): Promise<{ previousScore: number; newScore: number; newTier: TrustTier }> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const previousScore = identity.trustScore;
|
||||
const newScore = Math.max(0, previousScore - DECAY_PER_DAY);
|
||||
const newTier = computeTier(newScore);
|
||||
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({ trustScore: newScore, tier: newTier, updatedAt: new Date() })
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
|
||||
logger.info("trust: test decay applied", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
previousScore,
|
||||
newScore,
|
||||
newTier,
|
||||
});
|
||||
|
||||
return { previousScore, newScore, newTier };
|
||||
}
|
||||
}
|
||||
|
||||
export const trustService = new TrustService();
|
||||
|
||||
@@ -16,7 +16,7 @@ const DEFAULT_TIMMY: TimmyState = {
|
||||
|
||||
const _state: WorldState = {
|
||||
timmyState: { ...DEFAULT_TIMMY },
|
||||
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" },
|
||||
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle", kimi: "idle", perplexity: "idle" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -34,8 +34,10 @@ export function setAgentStateInWorld(agentId: string, agentState: string): void
|
||||
_deriveTimmy();
|
||||
}
|
||||
|
||||
const WORKSHOP_AGENTS = ["alpha", "beta", "gamma", "delta"];
|
||||
|
||||
function _deriveTimmy(): void {
|
||||
const states = Object.values(_state.agentStates);
|
||||
const states = WORKSHOP_AGENTS.map(id => _state.agentStates[id] ?? "idle");
|
||||
if (states.includes("working")) {
|
||||
_state.timmyState.activity = "working";
|
||||
_state.timmyState.mood = "focused";
|
||||
|
||||
@@ -269,6 +269,21 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
text: ev.text,
|
||||
};
|
||||
|
||||
// ── External agent state (Kimi, Perplexity) (#11) ─────────────────────────
|
||||
case "agent:external_state": {
|
||||
updateAgentWorld(ev.agentId, ev.state);
|
||||
void logWorldEvent(
|
||||
`agent:${ev.state}`,
|
||||
`${ev.agentId} is now ${ev.state}${ev.taskSummary ? `: ${ev.taskSummary.slice(0, 80)}` : ""}`,
|
||||
ev.agentId,
|
||||
);
|
||||
const msgs: object[] = [{ type: "agent_state", agentId: ev.agentId, state: ev.state }];
|
||||
if (ev.taskSummary) {
|
||||
msgs.push({ type: "agent_task_summary", agentId: ev.agentId, summary: ev.taskSummary });
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { verifyEvent, validateEvent } from "nostr-tools";
|
||||
import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import { eq, count, desc } from "drizzle-orm";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { timmyIdentityService } from "../lib/timmy-identity.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
@@ -406,4 +406,65 @@ router.get("/identity/me", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /identity/me/decay (TEST-ONLY — disabled in production) ──────────────
|
||||
// Applies one decay cycle to the authenticated identity immediately, without
|
||||
// the normal 30-day absence threshold. Useful in test suites.
|
||||
// Returns 404 in production (NODE_ENV === "production").
|
||||
|
||||
router.post("/identity/me/decay", async (req: Request, res: Response) => {
|
||||
if (process.env["NODE_ENV"] === "production") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = req.headers["x-nostr-token"];
|
||||
const token = typeof raw === "string" ? raw.trim() : null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Missing X-Nostr-Token header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = trustService.verifyToken(token);
|
||||
if (!parsed) {
|
||||
res.status(401).json({ error: "Invalid or expired nostr_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await trustService.decayOnce(parsed.pubkey);
|
||||
res.json({
|
||||
pubkey: parsed.pubkey,
|
||||
previousScore: result.previousScore,
|
||||
newScore: result.newScore,
|
||||
newTier: result.newTier,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Decay failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /identity/leaderboard ─────────────────────────────────────────────────
|
||||
// Returns the top 20 identities sorted by trust score descending.
|
||||
// Public endpoint — no authentication required.
|
||||
|
||||
router.get("/identity/leaderboard", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
pubkey: nostrIdentities.pubkey,
|
||||
trustScore: nostrIdentities.trustScore,
|
||||
tier: nostrIdentities.tier,
|
||||
interactionCount: nostrIdentities.interactionCount,
|
||||
})
|
||||
.from(nostrIdentities)
|
||||
.orderBy(desc(nostrIdentities.trustScore))
|
||||
.limit(20);
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch leaderboard" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -29,6 +29,12 @@ const router = Router();
|
||||
* Guarded on stubMode=true; polls until state=provisioning|ready (20 s timeout).
|
||||
* - T24 ADDED: costLedger completeness after job completion — 8 fields, honest-accounting
|
||||
* invariant (actualAmountSats ≤ workAmountSats), refundState enum check.
|
||||
* - T41 ADDED: POST /api/jobs with valid Nostr token → nostrPubkey in response matches identity.
|
||||
* - T42 ADDED: POST /api/sessions with valid Nostr token → nostrPubkey in response matches identity.
|
||||
* - T43 ADDED: GET /identity/me returns full trust fields (tier, score, interactionCount).
|
||||
* - T44 ADDED: POST /identity/me/decay (test-only endpoint, 404 in prod) → score decremented.
|
||||
* - T45 ADDED: GET /identity/leaderboard → HTTP 200, array sorted by trustScore desc.
|
||||
* New endpoints identity/me/decay and identity/leaderboard added to identity.ts.
|
||||
*/
|
||||
router.get("/testkit", (req: Request, res: Response) => {
|
||||
const proto =
|
||||
@@ -1092,6 +1098,208 @@ NODESCRIPT
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# T41–T45 — Nostr identity lifecycle: token decorates jobs/sessions + trust ops
|
||||
# Requires node + nostr-tools (same guard as T36). All five tests share one
|
||||
# inline node script that performs the full lifecycle and emits a JSON blob.
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T41–T45 Preamble — ephemeral keypair → challenge → sign → verify → token
|
||||
# Then: create job, create session, GET /identity/me, decay, leaderboard.
|
||||
# ---------------------------------------------------------------------------
|
||||
NOSTR_LC_SKIP=false
|
||||
NOSTR_LC_OUT=""
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
NOSTR_LC_SKIP=true
|
||||
fi
|
||||
if [[ "\$NOSTR_LC_SKIP" == "false" ]]; then
|
||||
NOSTR_LC_TMPFILE=\$(mktemp /tmp/nostr_lc_XXXXXX.cjs)
|
||||
cat > "\$NOSTR_LC_TMPFILE" << 'NODESCRIPT'
|
||||
'use strict';
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const BASE = process.argv[2];
|
||||
let nt;
|
||||
const NOSTR_CJS = '/home/runner/workspace/artifacts/api-server/node_modules/nostr-tools/lib/cjs/index.js';
|
||||
try { nt = require('nostr-tools'); } catch (_) { try { nt = require(NOSTR_CJS); } catch (_) { process.stderr.write('nostr-tools not importable\n'); process.exit(1); } }
|
||||
const { generateSecretKey, getPublicKey, finalizeEvent } = nt;
|
||||
function request(url, opts, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const mod = u.protocol === 'https:' ? https : http;
|
||||
const req = mod.request(u, opts, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
async function main() {
|
||||
const sk = generateSecretKey();
|
||||
const pubkey = getPublicKey(sk);
|
||||
// challenge → sign → verify
|
||||
const chalRes = await request(BASE + '/api/identity/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, '{}');
|
||||
if (chalRes.status !== 200) { process.stderr.write('challenge failed: ' + chalRes.status + '\n'); process.exit(1); }
|
||||
const { nonce } = JSON.parse(chalRes.body);
|
||||
const event = finalizeEvent({ kind: 27235, content: nonce, tags: [], created_at: Math.floor(Date.now() / 1000) }, sk);
|
||||
const verRes = await request(BASE + '/api/identity/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, JSON.stringify({ event }));
|
||||
if (verRes.status !== 200) { process.stderr.write('verify failed: ' + verRes.status + ' ' + verRes.body + '\n'); process.exit(1); }
|
||||
const { nostr_token: token } = JSON.parse(verRes.body);
|
||||
// POST /jobs with Nostr token
|
||||
const jobRes = await request(BASE + '/api/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, JSON.stringify({ request: 'T41 Nostr job test' }));
|
||||
const jobBody = JSON.parse(jobRes.body);
|
||||
const jobCode = jobRes.status;
|
||||
const jobId = jobBody.jobId || null;
|
||||
const jobNpub = jobBody.nostrPubkey || null;
|
||||
// POST /sessions with Nostr token
|
||||
const sessRes = await request(BASE + '/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, JSON.stringify({ amount_sats: 200 }));
|
||||
const sessBody = JSON.parse(sessRes.body);
|
||||
const sessCode = sessRes.status;
|
||||
const sessId = sessBody.sessionId || null;
|
||||
const sessNpub = sessBody.nostrPubkey || null;
|
||||
// GET /identity/me
|
||||
const meRes = await request(BASE + '/api/identity/me', { method: 'GET', headers: { 'X-Nostr-Token': token } });
|
||||
const meBody = JSON.parse(meRes.body);
|
||||
const meScore = meBody.trust ? meBody.trust.score : null;
|
||||
const meTier = meBody.trust ? meBody.trust.tier : null;
|
||||
const meIcount = meBody.trust ? meBody.trust.interactionCount : null;
|
||||
// POST /identity/me/decay (test-only; non-200 → skip T44 gracefully)
|
||||
const decayRes = await request(BASE + '/api/identity/me/decay', { method: 'POST', headers: { 'X-Nostr-Token': token } });
|
||||
const decayBody = JSON.parse(decayRes.body);
|
||||
const decayCode = decayRes.status;
|
||||
const decayPrev = decayBody.previousScore !== undefined ? decayBody.previousScore : null;
|
||||
const decayNew = decayBody.newScore !== undefined ? decayBody.newScore : null;
|
||||
// GET /identity/leaderboard
|
||||
const lbRes = await request(BASE + '/api/identity/leaderboard', { method: 'GET', headers: {} });
|
||||
const lbCode = lbRes.status;
|
||||
let lbBody = [];
|
||||
try { lbBody = JSON.parse(lbRes.body); } catch (_) {}
|
||||
const lbIsArray = Array.isArray(lbBody);
|
||||
const lbSorted = lbIsArray && lbBody.length < 2 ? true :
|
||||
lbIsArray && lbBody.every((v, i) => i === 0 || lbBody[i - 1].trustScore >= v.trustScore);
|
||||
process.stdout.write(JSON.stringify({
|
||||
pubkey, token,
|
||||
jobCode, jobId, jobNpub,
|
||||
sessCode, sessId, sessNpub,
|
||||
meScore, meTier, meIcount,
|
||||
decayCode, decayPrev, decayNew,
|
||||
lbCode, lbIsArray, lbSorted,
|
||||
}) + '\n');
|
||||
}
|
||||
main().catch(err => { process.stderr.write(String(err) + '\n'); process.exit(1); });
|
||||
NODESCRIPT
|
||||
|
||||
NOSTR_LC_EXIT=0
|
||||
NOSTR_LC_OUT=\$(node "\$NOSTR_LC_TMPFILE" "\$BASE" 2>/dev/null) || NOSTR_LC_EXIT=\$?
|
||||
rm -f "\$NOSTR_LC_TMPFILE"
|
||||
if [[ \$NOSTR_LC_EXIT -ne 0 || -z "\$NOSTR_LC_OUT" ]]; then
|
||||
NOSTR_LC_SKIP=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: extract a field from NOSTR_LC_OUT
|
||||
_lc() { echo "\$NOSTR_LC_OUT" | jq -r ".\$1" 2>/dev/null || echo ""; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T41 — POST /jobs with valid Nostr token → nostrPubkey in response
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 41 — POST /jobs with Nostr token → nostrPubkey set"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T41"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T41_CODE=\$(_lc jobCode); T41_NPUB=\$(_lc jobNpub); T41_PK=\$(_lc pubkey)
|
||||
if [[ "\$T41_CODE" == "201" && -n "\$T41_NPUB" && "\$T41_NPUB" != "null" && "\$T41_NPUB" == "\$T41_PK" ]]; then
|
||||
note PASS "HTTP 201, nostrPubkey=\${T41_NPUB:0:8}... matches token identity"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T41_CODE nostrPubkey='\$T41_NPUB' expected='\$T41_PK'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T42 — POST /sessions with valid Nostr token → nostrPubkey in response
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 42 — POST /sessions with Nostr token → nostrPubkey set"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T42"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T42_CODE=\$(_lc sessCode); T42_NPUB=\$(_lc sessNpub); T42_PK=\$(_lc pubkey)
|
||||
if [[ "\$T42_CODE" == "201" && -n "\$T42_NPUB" && "\$T42_NPUB" != "null" && "\$T42_NPUB" == "\$T42_PK" ]]; then
|
||||
note PASS "HTTP 201, nostrPubkey=\${T42_NPUB:0:8}... matches token identity"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T42_CODE nostrPubkey='\$T42_NPUB' expected='\$T42_PK'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T43 — GET /identity/me returns full trust fields (tier, score, interactionCount)
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 43 — GET /identity/me returns tier + score + interactionCount"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T43"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T43_TIER=\$(_lc meTier); T43_SCORE=\$(_lc meScore); T43_ICOUNT=\$(_lc meIcount)
|
||||
if [[ -n "\$T43_TIER" && "\$T43_TIER" != "null" \
|
||||
&& "\$T43_SCORE" != "" && "\$T43_SCORE" != "null" \
|
||||
&& "\$T43_ICOUNT" != "" && "\$T43_ICOUNT" != "null" ]]; then
|
||||
note PASS "tier=\$T43_TIER score=\$T43_SCORE interactionCount=\$T43_ICOUNT"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "tier='\$T43_TIER' score='\$T43_SCORE' icount='\$T43_ICOUNT'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T44 — POST /identity/me/decay (test-only endpoint) → score decremented
|
||||
# Skipped gracefully if endpoint returns non-200 (e.g., production mode).
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 44 — POST /identity/me/decay (test mode) → trust_score decremented"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T44"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T44_CODE=\$(_lc decayCode); T44_PREV=\$(_lc decayPrev); T44_NEW=\$(_lc decayNew)
|
||||
if [[ "\$T44_CODE" != "200" ]]; then
|
||||
note SKIP "decay endpoint returned code=\$T44_CODE (not in test mode) — skipping T44"
|
||||
SKIP=\$((SKIP+1))
|
||||
elif [[ -n "\$T44_PREV" && -n "\$T44_NEW" && "\$T44_NEW" =~ ^[0-9]+\$ && "\$T44_PREV" =~ ^[0-9]+\$ && \$T44_NEW -le \$T44_PREV ]]; then
|
||||
note PASS "previousScore=\$T44_PREV newScore=\$T44_NEW (decremented or floored at 0)"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T44_CODE previousScore='\$T44_PREV' newScore='\$T44_NEW' (expected new ≤ prev)"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T45 — GET /identity/leaderboard → HTTP 200, array sorted by trust score
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 45 — GET /identity/leaderboard → sorted array"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T45"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T45_CODE=\$(_lc lbCode); T45_ARRAY=\$(_lc lbIsArray); T45_SORTED=\$(_lc lbSorted)
|
||||
if [[ "\$T45_CODE" == "200" && "\$T45_ARRAY" == "true" && "\$T45_SORTED" == "true" ]]; then
|
||||
note PASS "HTTP 200, array returned and sorted by trustScore desc"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T45_CODE isArray=\$T45_ARRAY sorted=\$T45_SORTED"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# FUTURE STUBS — placeholders for upcoming tasks (do not affect PASS/FAIL)
|
||||
# ===========================================================================
|
||||
|
||||
@@ -5,18 +5,27 @@
|
||||
* unused (x, z) position. No other file needs to be edited.
|
||||
*
|
||||
* Fields:
|
||||
* id — unique string key used in WebSocket messages and state maps
|
||||
* label — display name shown in the 3D HUD and chat panel
|
||||
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
|
||||
* role — human-readable role string shown under the label sprite
|
||||
* direction — cardinal facing direction (for future mesh orientation use)
|
||||
* x, z — world-space position on the horizontal plane (y is always 0)
|
||||
* id — unique string key used in WebSocket messages and state maps
|
||||
* label — display name shown in the 3D HUD and chat panel
|
||||
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
|
||||
* role — human-readable role string shown under the label sprite
|
||||
* specialization — optional capability description shown in agent inspect card
|
||||
* direction — cardinal facing direction (for future mesh orientation use)
|
||||
* x, z — world-space position on the horizontal plane (y is always 0)
|
||||
*/
|
||||
export const AGENT_DEFS = [
|
||||
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
|
||||
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
|
||||
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
|
||||
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
|
||||
{
|
||||
id: 'kimi', label: 'KIMI', color: 0x00d4ff, role: 'analyst',
|
||||
specialization: 'Long Context Analysis', direction: 'northwest', x: -10, z: -10,
|
||||
},
|
||||
{
|
||||
id: 'perplexity', label: 'PERPLEXITY', color: 0xff6b9d, role: 'researcher',
|
||||
specialization: 'Real-time Research', direction: 'northeast', x: 10, z: -10,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,10 +7,13 @@ const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
|
||||
|
||||
const agentStates = Object.fromEntries(AGENT_DEFS.map(d => [d.id, 'idle']));
|
||||
|
||||
// Workshop agents that drive Timmy's mood (excludes external agents Kimi/Perplexity)
|
||||
const WORKSHOP_AGENT_IDS = ['alpha', 'beta', 'gamma', 'delta'];
|
||||
|
||||
function deriveTimmyState() {
|
||||
if (agentStates.gamma === 'working') return 'working';
|
||||
if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
|
||||
if (Object.values(agentStates).some(s => s !== 'idle')) return 'active';
|
||||
if (WORKSHOP_AGENT_IDS.some(id => agentStates[id] !== 'idle')) return 'active';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
@@ -97,9 +100,108 @@ function _pickMouthGeo(smileAmount) {
|
||||
|
||||
// ── Build Timmy ───────────────────────────────────────────────────────────────
|
||||
|
||||
// ── External agent bodies (Kimi, Perplexity) ──────────────────────────────────
|
||||
const _extBodies = {};
|
||||
|
||||
export function initAgents(sceneRef) {
|
||||
scene = sceneRef;
|
||||
timmy = buildTimmy(scene);
|
||||
_initKimiBody(scene);
|
||||
_initPerplexityBody(scene);
|
||||
}
|
||||
|
||||
function _initKimiBody(sc) {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(-10, 1.2, -10);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x00d4ff, emissive: 0x004466, emissiveIntensity: 0.4,
|
||||
roughness: 0.15, metalness: 0.4,
|
||||
});
|
||||
const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.38, 0), mat);
|
||||
group.add(core);
|
||||
|
||||
const ringMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x00d4ff, emissive: 0x0088aa, emissiveIntensity: 0.6,
|
||||
roughness: 0.1, metalness: 0.6, transparent: true, opacity: 0.7,
|
||||
});
|
||||
const ring1 = new THREE.Mesh(new THREE.TorusGeometry(0.60, 0.025, 6, 32), ringMat);
|
||||
ring1.rotation.x = Math.PI / 3;
|
||||
group.add(ring1);
|
||||
|
||||
const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.76, 0.018, 6, 32), ringMat.clone());
|
||||
ring2.rotation.x = Math.PI / 2;
|
||||
ring2.rotation.z = Math.PI / 4;
|
||||
group.add(ring2);
|
||||
|
||||
const light = new THREE.PointLight(0x00d4ff, 0.5, 8);
|
||||
group.add(light);
|
||||
|
||||
sc.add(group);
|
||||
_extBodies.kimi = { group, core, ring1, ring2, light, mat, pulsePhase: Math.random() * Math.PI * 2 };
|
||||
}
|
||||
|
||||
function _initPerplexityBody(sc) {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(10, 1.2, -10);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff6b9d, emissive: 0x660033, emissiveIntensity: 0.4,
|
||||
roughness: 0.2, metalness: 0.3,
|
||||
});
|
||||
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.32, 0), mat);
|
||||
group.add(core);
|
||||
|
||||
const scanMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff6b9d, emissive: 0xaa2255, emissiveIntensity: 0.7,
|
||||
roughness: 0.1, metalness: 0.5, transparent: true, opacity: 0.65,
|
||||
});
|
||||
const scanRings = [0, Math.PI / 3, -Math.PI / 3].map(angle => {
|
||||
const r = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.022, 6, 28), scanMat.clone());
|
||||
r.rotation.x = Math.PI / 2 + angle;
|
||||
r.rotation.z = angle * 0.5;
|
||||
group.add(r);
|
||||
return r;
|
||||
});
|
||||
|
||||
const light = new THREE.PointLight(0xff6b9d, 0.5, 8);
|
||||
group.add(light);
|
||||
|
||||
sc.add(group);
|
||||
_extBodies.perplexity = { group, core, scanRings, light, mat, pulsePhase: Math.random() * Math.PI * 2 };
|
||||
}
|
||||
|
||||
function _updateExtBodies(t) {
|
||||
_updateExtBody('kimi', t);
|
||||
_updateExtBody('perplexity', t);
|
||||
}
|
||||
|
||||
function _updateExtBody(id, t) {
|
||||
const body = _extBodies[id];
|
||||
if (!body) return;
|
||||
const state = agentStates[id] || 'idle';
|
||||
const isActive = state === 'working' || state === 'active';
|
||||
const isThinking = state === 'thinking';
|
||||
|
||||
const speedMult = isActive ? 2.5 : isThinking ? 1.5 : 0.6;
|
||||
const emissI = isActive ? 1.2 : isThinking ? 0.7 : 0.25;
|
||||
const lightI = isActive ? 1.2 : isThinking ? 0.6 : 0.2;
|
||||
const bobAmp = isActive ? 0.10 : 0.04;
|
||||
|
||||
body.group.position.y = 1.2 + Math.sin(t * 0.0008 + body.pulsePhase) * bobAmp;
|
||||
body.mat.emissiveIntensity = emissI;
|
||||
body.light.intensity = lightI;
|
||||
|
||||
if (id === 'kimi') {
|
||||
body.core.rotation.y += 0.008 * speedMult;
|
||||
body.core.rotation.x += 0.003 * speedMult;
|
||||
body.ring1.rotation.z += 0.012 * speedMult;
|
||||
body.ring2.rotation.x += 0.007 * speedMult;
|
||||
} else {
|
||||
body.core.rotation.y += 0.006 * speedMult;
|
||||
body.core.rotation.z += 0.009 * speedMult;
|
||||
body.scanRings.forEach((r, i) => { r.rotation.y += (0.015 + i * 0.008) * speedMult; });
|
||||
}
|
||||
}
|
||||
|
||||
function buildTimmy(sc) {
|
||||
@@ -417,6 +519,7 @@ export function updateAgents(time) {
|
||||
const t = time * 0.001;
|
||||
const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016;
|
||||
_lastFrameTime = time;
|
||||
_updateExtBodies(time);
|
||||
|
||||
const vs = deriveTimmyState();
|
||||
const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
|
||||
@@ -889,5 +992,19 @@ export function disposeAgents() {
|
||||
timmy.bubbleTex?.dispose();
|
||||
timmy.bubbleMat?.dispose();
|
||||
timmy = null;
|
||||
|
||||
// Dispose external agent bodies
|
||||
for (const body of Object.values(_extBodies)) {
|
||||
body.group.traverse(obj => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach(m => m.dispose());
|
||||
}
|
||||
});
|
||||
if (scene) scene.remove(body.group);
|
||||
}
|
||||
for (const k of Object.keys(_extBodies)) delete _extBodies[k];
|
||||
|
||||
scene = null;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { colorToCss } from './agent-defs.js';
|
||||
import { colorToCss, AGENT_DEFS } from './agent-defs.js';
|
||||
|
||||
// Specialization lookup built once from AGENT_DEFS
|
||||
const _specializations = Object.fromEntries(
|
||||
AGENT_DEFS.filter(d => d.specialization).map(d => [d.id, d.specialization])
|
||||
);
|
||||
|
||||
const _proj = new THREE.Vector3();
|
||||
let _camera = null;
|
||||
@@ -20,6 +25,7 @@ let _labels = []; // { el, worldPos: THREE.Vector3, id }
|
||||
|
||||
// ── State cache (updated from WS) ────────────────────────────────────────────
|
||||
const _states = {};
|
||||
const _lastTasks = {};
|
||||
|
||||
// ── Inspect popup ─────────────────────────────────────────────────────────────
|
||||
let _inspectEl = null;
|
||||
@@ -100,6 +106,10 @@ function _makeLabel(container, id, name, role, color, worldPos) {
|
||||
return { el, worldPos, id, color };
|
||||
}
|
||||
|
||||
export function setLabelLastTask(id, summary) {
|
||||
_lastTasks[id] = summary;
|
||||
}
|
||||
|
||||
export function setLabelState(id, state) {
|
||||
_states[id] = state;
|
||||
const entry = _labels.find(l => l.id === id);
|
||||
@@ -118,13 +128,17 @@ export function showInspectPopup(id, screenX, screenY) {
|
||||
|
||||
const state = _states[id] || 'idle';
|
||||
const uptime = Math.floor(performance.now() / 1000);
|
||||
const spec = _specializations[id];
|
||||
const lastTask = _lastTasks[id];
|
||||
_inspectEl.innerHTML = `
|
||||
<div style="color:${entry.color};font-weight:bold;letter-spacing:2px;font-size:12px;margin-bottom:6px;">
|
||||
${id.toUpperCase()}
|
||||
</div>
|
||||
${spec ? `<div style="color:${entry.color}99;margin-bottom:4px;font-size:10px;letter-spacing:1px;">⬡ ${spec}</div>` : ''}
|
||||
<div style="color:#aaa;margin-bottom:2px;">state : <span style="color:${entry.color}">${state}</span></div>
|
||||
<div style="color:#aaa;margin-bottom:2px;">uptime : ${uptime}s</div>
|
||||
<div style="color:#aaa;">network: <span style="color:#44ff88">connected</span></div>
|
||||
<div style="color:#aaa;margin-bottom:2px;">network: <span style="color:#44ff88">connected</span></div>
|
||||
${lastTask ? `<div style="color:#888;font-size:9px;margin-top:4px;border-top:1px solid #333;padding-top:4px;">last: ${lastTask.slice(0, 60)}</div>` : ''}
|
||||
`;
|
||||
_inspectEl.style.left = `${screenX}px`;
|
||||
_inspectEl.style.top = `${screenY}px`;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { scene } from './world.js'; // Import the scene
|
||||
import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js';
|
||||
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
|
||||
import { sentiment } from './edge-worker-client.js';
|
||||
import { setLabelState } from './hud-labels.js';
|
||||
import { setLabelState, setLabelLastTask } from './hud-labels.js';
|
||||
import { createJobIndicator, dissolveJobIndicator } from './effects.js';
|
||||
import { getPubkey } from './nostr-identity.js';
|
||||
|
||||
@@ -122,11 +122,19 @@ function handleMessage(msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_task_summary': {
|
||||
if (msg.agentId && msg.summary) {
|
||||
setLabelLastTask(msg.agentId, msg.summary);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_completed': {
|
||||
if (jobCount > 0) jobCount--;
|
||||
if (msg.agentId) {
|
||||
setAgentState(msg.agentId, 'idle');
|
||||
setLabelState(msg.agentId, 'idle');
|
||||
setLabelLastTask(msg.agentId, `job ${(msg.jobId || '').slice(0, 8)} completed`);
|
||||
}
|
||||
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user