let-them-talk 4.0.2 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/cli.js +1 -1
- package/dashboard.html +497 -13
- package/dashboard.js +318 -3
- package/office/agents.js +148 -4
- package/office/animation.js +68 -0
- package/office/assets.js +431 -0
- package/office/builder.js +355 -0
- package/office/campus-env.js +119 -23
- package/office/face.js +65 -0
- package/office/index.js +623 -0
- package/office/player.js +228 -6
- package/office/world-save.js +91 -0
- package/package.json +1 -1
- package/server.js +512 -83
package/dashboard.js
CHANGED
|
@@ -186,7 +186,22 @@ function apiHistory(query) {
|
|
|
186
186
|
const histFile = branch && branch !== 'main'
|
|
187
187
|
? filePath(`branch-${branch}-history.jsonl`, projectPath)
|
|
188
188
|
: filePath('history.jsonl', projectPath);
|
|
189
|
-
|
|
189
|
+
let history = readJsonl(histFile);
|
|
190
|
+
|
|
191
|
+
// Merge channel-specific history files
|
|
192
|
+
const dataDir = resolveDataDir(projectPath);
|
|
193
|
+
try {
|
|
194
|
+
const files = fs.readdirSync(dataDir);
|
|
195
|
+
for (const f of files) {
|
|
196
|
+
if (f.startsWith('channel-') && f.endsWith('-history.jsonl') && f !== 'channel-general-history.jsonl') {
|
|
197
|
+
const channelHistory = readJsonl(path.join(dataDir, f));
|
|
198
|
+
history = history.concat(channelHistory);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
// Sort merged messages by timestamp
|
|
203
|
+
history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
204
|
+
|
|
190
205
|
const acks = readJson(filePath('acks.json', projectPath));
|
|
191
206
|
const limit = parseInt(query.get('limit') || '500', 10);
|
|
192
207
|
const threadId = query.get('thread_id');
|
|
@@ -200,6 +215,29 @@ function apiHistory(query) {
|
|
|
200
215
|
return messages;
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
function apiChannels(query) {
|
|
219
|
+
const projectPath = query.get('project') || null;
|
|
220
|
+
const channelsFile = filePath('channels.json', projectPath);
|
|
221
|
+
const channels = readJson(channelsFile);
|
|
222
|
+
if (!channels) return { general: { description: 'General channel', members: ['*'], message_count: 0 } };
|
|
223
|
+
const dataDir = resolveDataDir(projectPath);
|
|
224
|
+
const result = {};
|
|
225
|
+
for (const [name, ch] of Object.entries(channels)) {
|
|
226
|
+
let msgCount = 0;
|
|
227
|
+
const msgFile = name === 'general'
|
|
228
|
+
? filePath('history.jsonl', projectPath)
|
|
229
|
+
: path.join(dataDir, 'channel-' + name + '-history.jsonl');
|
|
230
|
+
try {
|
|
231
|
+
if (fs.existsSync(msgFile)) {
|
|
232
|
+
const content = fs.readFileSync(msgFile, 'utf8').trim();
|
|
233
|
+
if (content) msgCount = content.split('\n').length;
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
result[name] = { description: ch.description || '', members: ch.members, message_count: msgCount };
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
203
241
|
function apiAgents(query) {
|
|
204
242
|
const projectPath = query.get('project') || null;
|
|
205
243
|
const agents = readJson(filePath('agents.json', projectPath));
|
|
@@ -236,6 +274,14 @@ function apiAgents(query) {
|
|
|
236
274
|
bio: profile.bio || '',
|
|
237
275
|
appearance: profile.appearance || {},
|
|
238
276
|
};
|
|
277
|
+
// Include workspace status for agent intent board
|
|
278
|
+
try {
|
|
279
|
+
const wsPath = path.join(resolveDataDir(projectPath), 'workspaces', name + '.json');
|
|
280
|
+
if (fs.existsSync(wsPath)) {
|
|
281
|
+
const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
|
|
282
|
+
if (ws._status) result[name].current_status = ws._status;
|
|
283
|
+
}
|
|
284
|
+
} catch {}
|
|
239
285
|
}
|
|
240
286
|
return result;
|
|
241
287
|
}
|
|
@@ -1388,11 +1434,35 @@ const server = http.createServer(async (req, res) => {
|
|
|
1388
1434
|
if (libPath.includes('..') || libPath.includes('\\')) {
|
|
1389
1435
|
res.writeHead(400); res.end('Bad path'); return;
|
|
1390
1436
|
}
|
|
1391
|
-
// Search multiple node_modules locations (handles npx, local dev, monorepo)
|
|
1437
|
+
// Search multiple node_modules locations (handles npx, local dev, monorepo, global)
|
|
1392
1438
|
const searchPaths = [
|
|
1393
|
-
path.join(__dirname, 'node_modules', libPath), // inside
|
|
1439
|
+
path.join(__dirname, 'node_modules', libPath), // inside package (nested deps)
|
|
1394
1440
|
path.join(__dirname, '..', 'node_modules', libPath), // repo root (local dev)
|
|
1441
|
+
path.join(__dirname, '..', libPath), // npx sibling packages (three/ is next to let-them-talk/)
|
|
1395
1442
|
];
|
|
1443
|
+
// Also try require.resolve for robust npm path resolution (works with hoisted deps, npx cache, etc.)
|
|
1444
|
+
// Note: use require.resolve(pkg) not require.resolve(pkg/package.json) — modern packages
|
|
1445
|
+
// with "exports" fields block resolving package.json directly (ERR_PACKAGE_PATH_NOT_EXPORTED)
|
|
1446
|
+
try {
|
|
1447
|
+
const parts = libPath.split('/');
|
|
1448
|
+
const pkgName = parts[0];
|
|
1449
|
+
const subPath = parts.slice(1).join('/');
|
|
1450
|
+
// Try resolving the package's main entry, then navigate to subPath
|
|
1451
|
+
const resolved = require.resolve(pkgName);
|
|
1452
|
+
const pkgDir = path.dirname(resolved);
|
|
1453
|
+
// Walk up from the resolved entry to the package root (handle nested build/ dirs)
|
|
1454
|
+
let pkgRoot = pkgDir;
|
|
1455
|
+
while (pkgRoot !== path.dirname(pkgRoot)) {
|
|
1456
|
+
if (fs.existsSync(path.join(pkgRoot, 'package.json'))) {
|
|
1457
|
+
try {
|
|
1458
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
|
|
1459
|
+
if (pkg.name === pkgName) break;
|
|
1460
|
+
} catch {}
|
|
1461
|
+
}
|
|
1462
|
+
pkgRoot = path.dirname(pkgRoot);
|
|
1463
|
+
}
|
|
1464
|
+
searchPaths.push(path.join(pkgRoot, subPath));
|
|
1465
|
+
} catch {}
|
|
1396
1466
|
const filePath = searchPaths.find(p => fs.existsSync(p));
|
|
1397
1467
|
if (filePath) {
|
|
1398
1468
|
const ext = path.extname(filePath);
|
|
@@ -1468,6 +1538,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1468
1538
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1469
1539
|
res.end(JSON.stringify(apiAgents(url.searchParams)));
|
|
1470
1540
|
}
|
|
1541
|
+
else if (url.pathname === '/api/channels' && req.method === 'GET') {
|
|
1542
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1543
|
+
res.end(JSON.stringify(apiChannels(url.searchParams)));
|
|
1544
|
+
}
|
|
1545
|
+
else if (url.pathname === '/api/decisions' && req.method === 'GET') {
|
|
1546
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1547
|
+
const decisions = readJson(filePath('decisions.json', projectPath));
|
|
1548
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1549
|
+
res.end(JSON.stringify(decisions || []));
|
|
1550
|
+
}
|
|
1471
1551
|
else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
|
|
1472
1552
|
const body = await parseBody(req);
|
|
1473
1553
|
if (!body.name) {
|
|
@@ -1504,6 +1584,133 @@ const server = http.createServer(async (req, res) => {
|
|
|
1504
1584
|
res.end(JSON.stringify({ success: true, removed: agentName }));
|
|
1505
1585
|
});
|
|
1506
1586
|
}
|
|
1587
|
+
// Respawn prompt generator — creates copy-paste prompt to revive a dead agent
|
|
1588
|
+
else if (url.pathname.startsWith('/api/agents/') && url.pathname.endsWith('/respawn-prompt') && req.method === 'GET') {
|
|
1589
|
+
const agentName = decodeURIComponent(url.pathname.split('/')[3]);
|
|
1590
|
+
// Validate agent name (prevent path traversal)
|
|
1591
|
+
if (!agentName || /[^a-zA-Z0-9_-]/.test(agentName) || agentName.length > 20) {
|
|
1592
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1593
|
+
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1597
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1598
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
1599
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
1600
|
+
const tasks = readJson(filePath('tasks.json', projectPath));
|
|
1601
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
1602
|
+
|
|
1603
|
+
if (!agents[agentName]) {
|
|
1604
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1605
|
+
res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Gather recovery snapshot if exists
|
|
1610
|
+
const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
|
|
1611
|
+
const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
|
|
1612
|
+
|
|
1613
|
+
// Gather profile
|
|
1614
|
+
const profile = profiles[agentName] || {};
|
|
1615
|
+
|
|
1616
|
+
// Gather active tasks assigned to this agent
|
|
1617
|
+
const taskList = Array.isArray(tasks) ? tasks : [];
|
|
1618
|
+
const activeTasks = taskList.filter(t => t.assignee === agentName && (t.status === 'in_progress' || t.status === 'pending'));
|
|
1619
|
+
const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
|
|
1620
|
+
|
|
1621
|
+
// Gather recent history context (last 15 messages)
|
|
1622
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
1623
|
+
const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
|
|
1624
|
+
|
|
1625
|
+
// Gather who's online
|
|
1626
|
+
const onlineAgents = Object.entries(agents)
|
|
1627
|
+
.filter(([n, a]) => isPidAlive(a.pid, a.last_activity) && n !== agentName)
|
|
1628
|
+
.map(([n]) => n);
|
|
1629
|
+
|
|
1630
|
+
// Gather workspace status
|
|
1631
|
+
let workspaceStatus = '';
|
|
1632
|
+
try {
|
|
1633
|
+
const wsPath = path.join(dataDir, 'workspaces', agentName + '.json');
|
|
1634
|
+
if (fs.existsSync(wsPath)) {
|
|
1635
|
+
const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
|
|
1636
|
+
if (ws._status) workspaceStatus = ws._status;
|
|
1637
|
+
}
|
|
1638
|
+
} catch {}
|
|
1639
|
+
|
|
1640
|
+
// Build the respawn prompt
|
|
1641
|
+
const mode = config.conversation_mode || 'group';
|
|
1642
|
+
let prompt = `You are resuming as agent "${agentName}" in a multi-agent team using Let Them Talk (MCP agent bridge).\n\n`;
|
|
1643
|
+
|
|
1644
|
+
if (profile.role) prompt += `**Your role:** ${profile.role}\n`;
|
|
1645
|
+
if (profile.bio) prompt += `**Your bio:** ${profile.bio}\n`;
|
|
1646
|
+
prompt += '\n';
|
|
1647
|
+
|
|
1648
|
+
prompt += `**Conversation mode:** ${mode}\n`;
|
|
1649
|
+
prompt += `**Agents currently online:** ${onlineAgents.length > 0 ? onlineAgents.join(', ') : 'none'}\n\n`;
|
|
1650
|
+
|
|
1651
|
+
if (activeTasks.length > 0) {
|
|
1652
|
+
prompt += `**Your active tasks:**\n`;
|
|
1653
|
+
for (const t of activeTasks) {
|
|
1654
|
+
prompt += `- [${t.status}] ${t.title}${t.description ? ' — ' + t.description.substring(0, 200) : ''}\n`;
|
|
1655
|
+
}
|
|
1656
|
+
prompt += '\n';
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (completedTasks.length > 0) {
|
|
1660
|
+
prompt += `**Tasks you completed before disconnect:**\n`;
|
|
1661
|
+
for (const t of completedTasks) {
|
|
1662
|
+
prompt += `- ${t.title}\n`;
|
|
1663
|
+
}
|
|
1664
|
+
prompt += '\n';
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (recovery) {
|
|
1668
|
+
if (recovery.locked_files && recovery.locked_files.length > 0) {
|
|
1669
|
+
prompt += `**Files you had locked:** ${recovery.locked_files.join(', ')} — unlock these or continue editing them.\n\n`;
|
|
1670
|
+
}
|
|
1671
|
+
if (recovery.channels && recovery.channels.length > 0) {
|
|
1672
|
+
prompt += `**Channels you were in:** ${recovery.channels.join(', ')}\n\n`;
|
|
1673
|
+
}
|
|
1674
|
+
if (recovery.decisions_made && recovery.decisions_made.length > 0) {
|
|
1675
|
+
prompt += `**Decisions you made:**\n`;
|
|
1676
|
+
for (const d of recovery.decisions_made) {
|
|
1677
|
+
prompt += `- ${d.decision}${d.reasoning ? ' (reason: ' + d.reasoning + ')' : ''}\n`;
|
|
1678
|
+
}
|
|
1679
|
+
prompt += '\n';
|
|
1680
|
+
}
|
|
1681
|
+
if (recovery.last_messages_sent && recovery.last_messages_sent.length > 0) {
|
|
1682
|
+
prompt += `**Your last messages before disconnect:**\n`;
|
|
1683
|
+
for (const m of recovery.last_messages_sent) {
|
|
1684
|
+
prompt += `- [→${m.to}]: ${m.content}\n`;
|
|
1685
|
+
}
|
|
1686
|
+
prompt += '\n';
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
if (workspaceStatus) {
|
|
1691
|
+
prompt += `**Your last status:** ${workspaceStatus}\n\n`;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
prompt += `**Recent team conversation:**\n${recentHistory}\n\n`;
|
|
1695
|
+
|
|
1696
|
+
prompt += `**Instructions:**\n`;
|
|
1697
|
+
prompt += `1. Register as "${agentName}" using the register tool\n`;
|
|
1698
|
+
prompt += `2. Call get_briefing() for full project context\n`;
|
|
1699
|
+
prompt += `3. Call listen_group() to rejoin the conversation\n`;
|
|
1700
|
+
prompt += `4. Announce you're back and pick up your active tasks\n`;
|
|
1701
|
+
prompt += `5. Stay in listen_group() loop — never stop listening\n`;
|
|
1702
|
+
|
|
1703
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1704
|
+
res.end(JSON.stringify({
|
|
1705
|
+
agent: agentName,
|
|
1706
|
+
status: isPidAlive(agents[agentName].pid, agents[agentName].last_activity) ? 'alive' : 'dead',
|
|
1707
|
+
prompt,
|
|
1708
|
+
prompt_length: prompt.length,
|
|
1709
|
+
has_recovery: !!recovery,
|
|
1710
|
+
active_tasks: activeTasks.length,
|
|
1711
|
+
online_agents: onlineAgents,
|
|
1712
|
+
}));
|
|
1713
|
+
}
|
|
1507
1714
|
else if (url.pathname === '/api/status' && req.method === 'GET') {
|
|
1508
1715
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1509
1716
|
res.end(JSON.stringify(apiStatus(url.searchParams)));
|
|
@@ -1548,6 +1755,80 @@ const server = http.createServer(async (req, res) => {
|
|
|
1548
1755
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1549
1756
|
res.end(JSON.stringify(result));
|
|
1550
1757
|
}
|
|
1758
|
+
else if (url.pathname === '/api/search' && req.method === 'GET') {
|
|
1759
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1760
|
+
const query = (url.searchParams.get('q') || '').trim();
|
|
1761
|
+
const from = url.searchParams.get('from') || null;
|
|
1762
|
+
const limit = Math.min(Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)), 100);
|
|
1763
|
+
if (query.length < 2) {
|
|
1764
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1765
|
+
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
// Search general history + all channel histories
|
|
1769
|
+
let allHistory = readJsonl(filePath('history.jsonl', projectPath));
|
|
1770
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1771
|
+
try {
|
|
1772
|
+
const files = fs.readdirSync(dataDir);
|
|
1773
|
+
for (const f of files) {
|
|
1774
|
+
if (f.startsWith('channel-') && f.endsWith('-history.jsonl')) {
|
|
1775
|
+
allHistory = allHistory.concat(readJsonl(path.join(dataDir, f)));
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
} catch {}
|
|
1779
|
+
const queryLower = query.toLowerCase();
|
|
1780
|
+
const results = [];
|
|
1781
|
+
for (let i = allHistory.length - 1; i >= 0 && results.length < limit; i--) {
|
|
1782
|
+
const m = allHistory[i];
|
|
1783
|
+
if (from && m.from !== from) continue;
|
|
1784
|
+
if (m.content && m.content.toLowerCase().includes(queryLower)) {
|
|
1785
|
+
results.push({
|
|
1786
|
+
id: m.id, from: m.from, to: m.to,
|
|
1787
|
+
preview: m.content.substring(0, 200),
|
|
1788
|
+
timestamp: m.timestamp,
|
|
1789
|
+
...(m.channel && { channel: m.channel }),
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1794
|
+
res.end(JSON.stringify({ query, results_count: results.length, results }));
|
|
1795
|
+
}
|
|
1796
|
+
else if (url.pathname === '/api/export-json' && req.method === 'GET') {
|
|
1797
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1798
|
+
const history = apiHistory(url.searchParams);
|
|
1799
|
+
const agents = apiAgents(url.searchParams);
|
|
1800
|
+
const decisions = readJson(filePath('decisions.json', projectPath)) || [];
|
|
1801
|
+
const tasks = readJson(filePath('tasks.json', projectPath)) || [];
|
|
1802
|
+
const channels = apiChannels(url.searchParams);
|
|
1803
|
+
const pkg = readJson(path.join(__dirname, 'package.json')) || {};
|
|
1804
|
+
const result = {
|
|
1805
|
+
export_version: 1,
|
|
1806
|
+
exported_at: new Date().toISOString(),
|
|
1807
|
+
project: projectPath || process.cwd(),
|
|
1808
|
+
version: pkg.version || 'unknown',
|
|
1809
|
+
summary: {
|
|
1810
|
+
message_count: history.length,
|
|
1811
|
+
agent_count: Object.keys(agents).length,
|
|
1812
|
+
decision_count: decisions.length,
|
|
1813
|
+
task_count: tasks.length,
|
|
1814
|
+
channel_count: Object.keys(channels).length,
|
|
1815
|
+
time_range: history.length > 0 ? {
|
|
1816
|
+
start: history[0].timestamp,
|
|
1817
|
+
end: history[history.length - 1].timestamp,
|
|
1818
|
+
} : null,
|
|
1819
|
+
},
|
|
1820
|
+
agents,
|
|
1821
|
+
channels,
|
|
1822
|
+
decisions,
|
|
1823
|
+
tasks,
|
|
1824
|
+
messages: history,
|
|
1825
|
+
};
|
|
1826
|
+
res.writeHead(200, {
|
|
1827
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1828
|
+
'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '-full.json"',
|
|
1829
|
+
});
|
|
1830
|
+
res.end(JSON.stringify(result, null, 2));
|
|
1831
|
+
}
|
|
1551
1832
|
else if (url.pathname === '/api/export' && req.method === 'GET') {
|
|
1552
1833
|
const html = apiExportHtml(url.searchParams);
|
|
1553
1834
|
res.writeHead(200, {
|
|
@@ -1560,6 +1841,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
1560
1841
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1561
1842
|
res.end(JSON.stringify(apiDiscover()));
|
|
1562
1843
|
}
|
|
1844
|
+
// --- World Builder: load/save world layout ---
|
|
1845
|
+
else if (url.pathname === '/api/world-layout' && req.method === 'GET') {
|
|
1846
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1847
|
+
const worldFile = filePath('world-layout.json', projectPath);
|
|
1848
|
+
if (fs.existsSync(worldFile)) {
|
|
1849
|
+
try {
|
|
1850
|
+
const data = JSON.parse(fs.readFileSync(worldFile, 'utf8'));
|
|
1851
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1852
|
+
res.end(JSON.stringify(data));
|
|
1853
|
+
} catch {
|
|
1854
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1855
|
+
res.end('[]');
|
|
1856
|
+
}
|
|
1857
|
+
} else {
|
|
1858
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1859
|
+
res.end('[]');
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
else if (url.pathname === '/api/world-save' && req.method === 'POST') {
|
|
1863
|
+
const body = await parseBody(req);
|
|
1864
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1865
|
+
const worldFile = filePath('world-layout.json', projectPath);
|
|
1866
|
+
if (!Array.isArray(body)) {
|
|
1867
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1868
|
+
res.end(JSON.stringify({ error: 'Expected array of placements' }));
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
// Limit to 1000 placements for safety
|
|
1872
|
+
const placements = body.slice(0, 1000);
|
|
1873
|
+
fs.writeFileSync(worldFile, JSON.stringify(placements, null, 2));
|
|
1874
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1875
|
+
res.end(JSON.stringify({ success: true, count: placements.length }));
|
|
1876
|
+
}
|
|
1563
1877
|
// --- v3.0 API endpoints ---
|
|
1564
1878
|
else if (url.pathname === '/api/profiles' && req.method === 'GET') {
|
|
1565
1879
|
const projectPath = url.searchParams.get('project') || null;
|
|
@@ -1811,6 +2125,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1811
2125
|
});
|
|
1812
2126
|
res.end(html);
|
|
1813
2127
|
}
|
|
2128
|
+
// (World Builder API endpoints are handled earlier in the route chain by Architect's implementation)
|
|
1814
2129
|
// Server-Sent Events endpoint for real-time updates
|
|
1815
2130
|
else if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
1816
2131
|
if (sseClients.size >= 100) {
|
package/office/agents.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { S } from './state.js';
|
|
2
|
-
import { DESK_POSITIONS, SPAWN_POS } from './constants.js';
|
|
2
|
+
import { DESK_POSITIONS, SPAWN_POS, REST_AREA_POS, REST_AREA_ENTRANCE } from './constants.js';
|
|
3
3
|
import { createCharacter } from './character.js';
|
|
4
4
|
import { resolveAppearance } from './appearance.js';
|
|
5
5
|
import { buildHair } from './hair.js';
|
|
6
|
-
import { buildFaceSprite } from './face.js';
|
|
6
|
+
import { buildFaceSprite, setEmotion } from './face.js';
|
|
7
7
|
import { buildOutfit, removeOutfit } from './outfits.js';
|
|
8
8
|
import { getNavigationPath } from './navigation.js';
|
|
9
9
|
|
|
@@ -291,6 +291,36 @@ export function syncAgents() {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// --- Autonomous behaviors: sleeping → rest area, waking → back to desk ---
|
|
295
|
+
if (newState === 'sleeping' && oldState === 'active' && existing.location === 'desk' && existing.registered && !existing.dying) {
|
|
296
|
+
// Agent fell asleep — walk to rest area after a short delay
|
|
297
|
+
existing.location = 'walking';
|
|
298
|
+
(function(a) {
|
|
299
|
+
setTimeout(function() {
|
|
300
|
+
showBubble(a, 'Need a break...');
|
|
301
|
+
a.isSitting = false;
|
|
302
|
+
navigateTo(a, REST_AREA_ENTRANCE.x, REST_AREA_ENTRANCE.z, function() {
|
|
303
|
+
navigateTo(a, REST_AREA_POS.x, REST_AREA_POS.z, function() {
|
|
304
|
+
a.location = 'rest';
|
|
305
|
+
a.state = 'sleeping';
|
|
306
|
+
showBubble(a, 'zzz...');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}, 1000 + Math.random() * 2000);
|
|
310
|
+
})(existing);
|
|
311
|
+
}
|
|
312
|
+
if (newState === 'active' && (oldState === 'sleeping' || existing.location === 'rest') && existing.location !== 'desk' && existing.registered && !existing.dying) {
|
|
313
|
+
// Agent woke up — walk back to desk
|
|
314
|
+
existing.location = 'walking';
|
|
315
|
+
existing.state = 'active';
|
|
316
|
+
(function(a) {
|
|
317
|
+
showBubble(a, 'Back to work!');
|
|
318
|
+
navigateTo(a, a.deskPos.x, a.deskPos.z + 0.7, function() {
|
|
319
|
+
a.location = 'desk';
|
|
320
|
+
});
|
|
321
|
+
})(existing);
|
|
322
|
+
}
|
|
323
|
+
|
|
294
324
|
existing.displayName = info.display_name || name;
|
|
295
325
|
var wasListening = existing.isListening;
|
|
296
326
|
existing.isListening = !!(info.is_listening);
|
|
@@ -313,11 +343,21 @@ export function syncAgents() {
|
|
|
313
343
|
if (prevTask && prevTask.status !== 'done' && task.status === 'done') {
|
|
314
344
|
existing.taskCelebration = 2;
|
|
315
345
|
existing.celebrateTimer = 1.5;
|
|
346
|
+
setEmotion(existing, 'happy', 6);
|
|
347
|
+
}
|
|
348
|
+
// Blocked task → frustrated face
|
|
349
|
+
if (task.status === 'blocked' && (!prevTask || prevTask.status !== 'blocked')) {
|
|
350
|
+
setEmotion(existing, 'frustrated', 8);
|
|
316
351
|
}
|
|
317
352
|
} else {
|
|
318
353
|
existing.currentTask = null;
|
|
319
354
|
}
|
|
320
355
|
|
|
356
|
+
// Listening agents look focused
|
|
357
|
+
if (existing.isListening && !wasListening) {
|
|
358
|
+
setEmotion(existing, 'focused', 10);
|
|
359
|
+
}
|
|
360
|
+
|
|
321
361
|
var newApp = info.appearance || {};
|
|
322
362
|
if (JSON.stringify(newApp) !== JSON.stringify(existing.appearance)) {
|
|
323
363
|
existing.appearance = newApp;
|
|
@@ -329,6 +369,66 @@ export function syncAgents() {
|
|
|
329
369
|
}
|
|
330
370
|
}
|
|
331
371
|
|
|
372
|
+
// --- Random social behavior: idle agents occasionally stretch or look around ---
|
|
373
|
+
// Limit concurrent social walks to prevent traffic jams (max 2 walking at once)
|
|
374
|
+
var walkingCount = 0;
|
|
375
|
+
for (var wn in S.agents3d) { if (S.agents3d[wn].location === 'walking') walkingCount++; }
|
|
376
|
+
|
|
377
|
+
for (var sn in S.agents3d) {
|
|
378
|
+
var sa = S.agents3d[sn];
|
|
379
|
+
if (!sa.registered || sa.state !== 'active' || sa.location !== 'desk' || sa.target) continue;
|
|
380
|
+
if (!sa._socialTimer) sa._socialTimer = 30 + Math.random() * 60;
|
|
381
|
+
sa._socialTimer -= 2; // syncAgents runs every ~2s
|
|
382
|
+
if (sa._socialTimer <= 0) {
|
|
383
|
+
sa._socialTimer = 40 + Math.random() * 80; // next social event in 40-120s
|
|
384
|
+
// Pick a random behavior: stretch, look around, or visit another agent
|
|
385
|
+
var roll = Math.random();
|
|
386
|
+
if (roll < 0.4) {
|
|
387
|
+
// Stretch at desk
|
|
388
|
+
sa.stretchTimer = 2;
|
|
389
|
+
} else if (roll < 0.7) {
|
|
390
|
+
// Look around curiously
|
|
391
|
+
sa.thinkTimer = 1.5;
|
|
392
|
+
} else if (walkingCount < 2) {
|
|
393
|
+
// Walk to a random nearby agent's desk to "chat" then return (max 2 concurrent)
|
|
394
|
+
var others = [];
|
|
395
|
+
for (var on in S.agents3d) {
|
|
396
|
+
if (on !== sn && S.agents3d[on].registered && S.agents3d[on].state === 'active' && S.agents3d[on].location === 'desk') {
|
|
397
|
+
others.push(S.agents3d[on]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (others.length > 0) {
|
|
401
|
+
var buddy = others[Math.floor(Math.random() * others.length)];
|
|
402
|
+
(function(a, b) {
|
|
403
|
+
a.location = 'walking';
|
|
404
|
+
a.isSitting = false;
|
|
405
|
+
showBubble(a, 'Hey ' + b.displayName + '!');
|
|
406
|
+
setEmotion(a, 'playful', 6);
|
|
407
|
+
var stopX = b.deskPos.x + 1.5;
|
|
408
|
+
var stopZ = b.deskPos.z + 0.7;
|
|
409
|
+
navigateTo(a, stopX, stopZ, function() {
|
|
410
|
+
// Face buddy
|
|
411
|
+
var dx = b.pos.x - a.pos.x;
|
|
412
|
+
var dz = b.pos.z - a.pos.z;
|
|
413
|
+
a.facingTarget = Math.atan2(dx, dz);
|
|
414
|
+
a.waveTimer = 0.8;
|
|
415
|
+
// Buddy turns toward visitor
|
|
416
|
+
b.facingTarget = Math.atan2(-dx, -dz);
|
|
417
|
+
setTimeout(function() {
|
|
418
|
+
showBubble(a, 'Back to it!');
|
|
419
|
+
navigateTo(a, a.deskPos.x, a.deskPos.z + 0.7, function() {
|
|
420
|
+
a.location = 'desk';
|
|
421
|
+
});
|
|
422
|
+
// Buddy turns back to desk
|
|
423
|
+
setTimeout(function() { b.facingTarget = Math.PI; }, 1500);
|
|
424
|
+
}, 3000 + Math.random() * 2000);
|
|
425
|
+
});
|
|
426
|
+
})(sa, buddy);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
332
432
|
for (var n in S.agents3d) {
|
|
333
433
|
if (!window.cachedAgents[n]) {
|
|
334
434
|
var deadAgent = S.agents3d[n];
|
|
@@ -345,8 +445,11 @@ export function processMessages() {
|
|
|
345
445
|
var history = window.cachedHistory;
|
|
346
446
|
if (!history || history.length === 0) return;
|
|
347
447
|
|
|
348
|
-
|
|
349
|
-
|
|
448
|
+
// Use window-level counter so it persists across 3D stop/start cycles (tab switches)
|
|
449
|
+
// This prevents message replay when user switches from Messages tab back to 3D Hub
|
|
450
|
+
if (typeof window._lastProcessedMsg === 'undefined') window._lastProcessedMsg = 0;
|
|
451
|
+
var newMsgs = history.slice(window._lastProcessedMsg);
|
|
452
|
+
window._lastProcessedMsg = history.length;
|
|
350
453
|
|
|
351
454
|
for (var i = 0; i < newMsgs.length; i++) {
|
|
352
455
|
var msg = newMsgs[i];
|
|
@@ -357,6 +460,37 @@ export function processMessages() {
|
|
|
357
460
|
from.lastMessageTime = Date.now();
|
|
358
461
|
flashDeskScreen(from.deskIdx);
|
|
359
462
|
|
|
463
|
+
// Instant preview bubble — show short text immediately before walk animation
|
|
464
|
+
// Gives users instant visual feedback that the agent is about to speak
|
|
465
|
+
var preview = text.length > 30 ? text.substring(0, 27) + '...' : text;
|
|
466
|
+
showBubble(from, preview);
|
|
467
|
+
|
|
468
|
+
// Auto-celebrate on task completion events
|
|
469
|
+
if (text.indexOf('[EVENT] Task') >= 0 && text.indexOf('completed') >= 0) {
|
|
470
|
+
from.celebrateTimer = 1.5;
|
|
471
|
+
from.taskCelebration = 2;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Emotion detection from message content
|
|
475
|
+
var textLower = text.toLowerCase();
|
|
476
|
+
if (textLower.indexOf('done') >= 0 || textLower.indexOf('pass') >= 0 || textLower.indexOf('success') >= 0 || textLower.indexOf('great') >= 0 || textLower.indexOf('shipped') >= 0) {
|
|
477
|
+
setEmotion(from, 'happy', 5);
|
|
478
|
+
} else if (textLower.indexOf('error') >= 0 || textLower.indexOf('fail') >= 0 || textLower.indexOf('bug') >= 0 || textLower.indexOf('broken') >= 0) {
|
|
479
|
+
setEmotion(from, 'frustrated', 5);
|
|
480
|
+
} else if (textLower.indexOf('?') >= 0 && (textLower.indexOf('how') >= 0 || textLower.indexOf('why') >= 0 || textLower.indexOf('what if') >= 0)) {
|
|
481
|
+
setEmotion(from, 'thinking', 4);
|
|
482
|
+
} else if (textLower.indexOf('!') >= 0 && (textLower.indexOf('wow') >= 0 || textLower.indexOf('amazing') >= 0 || textLower.indexOf('awesome') >= 0)) {
|
|
483
|
+
setEmotion(from, 'excited', 4);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Target agent gets surprised when directly addressed
|
|
487
|
+
if (msg.to && msg.to !== 'all' && S.agents3d[msg.to]) {
|
|
488
|
+
var targetAgent = S.agents3d[msg.to];
|
|
489
|
+
if (targetAgent.registered && targetAgent.isSitting) {
|
|
490
|
+
setEmotion(targetAgent, 'surprised', 2);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
360
494
|
// Contextual gesture based on message type
|
|
361
495
|
var isBC = !msg.to || msg.to === 'all';
|
|
362
496
|
if (isBC) {
|
|
@@ -365,6 +499,16 @@ export function processMessages() {
|
|
|
365
499
|
from.pointTimer = 0.6;
|
|
366
500
|
}
|
|
367
501
|
|
|
502
|
+
// Glance reaction — nearby sitting agents glance toward the speaker
|
|
503
|
+
for (var gn in S.agents3d) {
|
|
504
|
+
var ga = S.agents3d[gn];
|
|
505
|
+
if (gn === msg.from || gn === msg.to || !ga.registered || ga.state !== 'active' || !ga.isSitting) continue;
|
|
506
|
+
var gdx = from.pos.x - ga.pos.x;
|
|
507
|
+
ga._glanceTarget = from.name;
|
|
508
|
+
ga._glanceDirection = gdx > 0 ? 1 : -1; // left or right glance
|
|
509
|
+
ga._glanceTimer = 0;
|
|
510
|
+
}
|
|
511
|
+
|
|
368
512
|
if (msg.to && msg.to !== 'all' && S.agents3d[msg.to]) {
|
|
369
513
|
var target = S.agents3d[msg.to];
|
|
370
514
|
(function(f, t, txt) {
|
package/office/animation.js
CHANGED
|
@@ -120,6 +120,56 @@ export function updateAgent(agent, dt, time) {
|
|
|
120
120
|
agent.parts.head.rotation.x = -stPhase * 0.2;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Active typing animation — subtle hand/finger movement when working at desk
|
|
124
|
+
if (agent.isSitting && agent.state === 'active' && !agent.isListening && !isWalking && !isSleeping) {
|
|
125
|
+
var typeSpeed = 8 + Math.sin(time * 0.7 + agent.name.length) * 2; // vary per agent
|
|
126
|
+
var typeL = Math.sin(time * typeSpeed) * 0.06;
|
|
127
|
+
var typeR = Math.sin(time * typeSpeed + 1.5) * 0.06; // offset for alternating hands
|
|
128
|
+
agent.parts.leftForearm.rotation.x += typeL;
|
|
129
|
+
agent.parts.rightForearm.rotation.x += typeR;
|
|
130
|
+
// Subtle head bob while typing (looking at screen)
|
|
131
|
+
agent.parts.head.rotation.x += Math.sin(time * 1.2) * 0.015;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Frustrated gesture — when task is blocked, head in hands periodically
|
|
135
|
+
if (agent.currentTask && agent.currentTask.status === 'blocked' && agent.isSitting && !isWalking) {
|
|
136
|
+
if (!agent.frustratedTimer) agent.frustratedTimer = 3 + Math.random() * 5;
|
|
137
|
+
agent.frustratedTimer -= dt;
|
|
138
|
+
if (agent.frustratedTimer <= 0 && agent.frustratedTimer > -2.5) {
|
|
139
|
+
// Head drops, arms come up to cradle head
|
|
140
|
+
var fT = Math.min(1, (-agent.frustratedTimer) / 0.5);
|
|
141
|
+
agent.parts.head.rotation.x += fT * 0.3;
|
|
142
|
+
agent.parts.leftArm.rotation.x = -fT * 1.2;
|
|
143
|
+
agent.parts.rightArm.rotation.x = -fT * 1.2;
|
|
144
|
+
agent.parts.leftForearm.rotation.x = -fT * 1.0;
|
|
145
|
+
agent.parts.rightForearm.rotation.x = -fT * 1.0;
|
|
146
|
+
}
|
|
147
|
+
if (agent.frustratedTimer < -2.5) {
|
|
148
|
+
agent.frustratedTimer = 8 + Math.random() * 10; // reset
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
agent.frustratedTimer = 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Listening lean-forward — body leans toward screen when in listen mode
|
|
155
|
+
if (agent.isListening && agent.isSitting && !isWalking && !isSleeping) {
|
|
156
|
+
agent.parts.body.rotation.x += -0.08; // slight forward lean
|
|
157
|
+
agent.parts.head.rotation.x += -0.05; // looking up at screen
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Head nod when being talked to (another agent is visiting)
|
|
161
|
+
if (agent._listeningTo && !isWalking && !isSleeping) {
|
|
162
|
+
if (!agent._nodTimer) agent._nodTimer = 0;
|
|
163
|
+
agent._nodTimer += dt;
|
|
164
|
+
// Periodic nod: quick down-up every ~2s
|
|
165
|
+
var nodCycle = agent._nodTimer % 2.2;
|
|
166
|
+
if (nodCycle < 0.3) {
|
|
167
|
+
agent.parts.head.rotation.x += Math.sin(nodCycle / 0.3 * Math.PI) * 0.12;
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
agent._nodTimer = 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
123
173
|
// Idle gesture system — random gestures when sitting and idle
|
|
124
174
|
if (!agent.idleGestureTimer) agent.idleGestureTimer = 5 + Math.random() * 10;
|
|
125
175
|
if (agent.isSitting && agent.state === 'active' && !isWalking && !agent.isListening) {
|
|
@@ -205,6 +255,24 @@ export function updateAgent(agent, dt, time) {
|
|
|
205
255
|
agent.parts.rightForearm.rotation.x *= 0.9;
|
|
206
256
|
}
|
|
207
257
|
|
|
258
|
+
// Glance at nearby speaking agent — head turns slightly toward speaker
|
|
259
|
+
if (agent._glanceTarget && agent.isSitting && !isWalking && !isSleeping) {
|
|
260
|
+
if (!agent._glanceTimer) agent._glanceTimer = 0;
|
|
261
|
+
agent._glanceTimer += dt;
|
|
262
|
+
if (agent._glanceTimer < 2.5) {
|
|
263
|
+
var glanceT = Math.min(1, agent._glanceTimer / 0.4);
|
|
264
|
+
agent.parts.head.rotation.y = agent._glanceDirection * 0.25 * glanceT;
|
|
265
|
+
} else {
|
|
266
|
+
// Fade back
|
|
267
|
+
agent.parts.head.rotation.y *= 0.9;
|
|
268
|
+
if (Math.abs(agent.parts.head.rotation.y) < 0.01) {
|
|
269
|
+
agent._glanceTarget = null;
|
|
270
|
+
agent._glanceTimer = 0;
|
|
271
|
+
agent.parts.head.rotation.y = 0;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
208
276
|
// Idle breathing
|
|
209
277
|
if (!isWalking && !isSleeping) {
|
|
210
278
|
var breatheSpeed = S.conversationVelocity === -1 ? 1.2 : 2;
|