let-them-talk 5.2.5 → 5.4.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +158 -592
  3. package/SECURITY.md +3 -3
  4. package/USAGE.md +151 -0
  5. package/agent-contracts.js +447 -0
  6. package/api-agents.js +760 -0
  7. package/autonomy/decision-v2.js +380 -0
  8. package/autonomy/watchdog-policy.js +572 -0
  9. package/cli.js +454 -298
  10. package/conversation-templates/autonomous-feature.json +83 -22
  11. package/conversation-templates/code-review.json +69 -21
  12. package/conversation-templates/debug-squad.json +69 -21
  13. package/conversation-templates/feature-build.json +69 -21
  14. package/conversation-templates/research-write.json +69 -21
  15. package/dashboard.html +3148 -174
  16. package/dashboard.js +823 -786
  17. package/data-dir.js +58 -0
  18. package/docs/architecture/branch-semantics.md +157 -0
  19. package/docs/architecture/canonical-event-schema.md +88 -0
  20. package/docs/architecture/markdown-workspace.md +183 -0
  21. package/docs/architecture/runtime-contract.md +459 -0
  22. package/docs/architecture/runtime-migration-hardening.md +64 -0
  23. package/events/hooks.js +154 -0
  24. package/events/log.js +457 -0
  25. package/events/replay.js +33 -0
  26. package/events/schema.js +432 -0
  27. package/managed-team-integration.js +261 -0
  28. package/office/agents.js +704 -597
  29. package/office/animation.js +1 -1
  30. package/office/assets/arcade-cabinet.js +141 -0
  31. package/office/assets/archway.js +77 -0
  32. package/office/assets/bar-counter.js +91 -0
  33. package/office/assets/bar-stool.js +71 -0
  34. package/office/assets/beanbag.js +64 -0
  35. package/office/assets/bench.js +99 -0
  36. package/office/assets/bollard.js +87 -0
  37. package/office/assets/cactus.js +100 -0
  38. package/office/assets/carpet-tile.js +46 -0
  39. package/office/assets/chair.js +123 -0
  40. package/office/assets/chandelier.js +107 -0
  41. package/office/assets/coffee-machine.js +95 -0
  42. package/office/assets/coffee-table.js +81 -0
  43. package/office/assets/column.js +95 -0
  44. package/office/assets/desk-lamp.js +102 -0
  45. package/office/assets/desk.js +76 -0
  46. package/office/assets/dining-table.js +105 -0
  47. package/office/assets/door.js +70 -0
  48. package/office/assets/dual-monitor.js +72 -0
  49. package/office/assets/fence.js +76 -0
  50. package/office/assets/filing-cabinet.js +111 -0
  51. package/office/assets/floor-lamp.js +69 -0
  52. package/office/assets/floor-tile.js +54 -0
  53. package/office/assets/flower-pot.js +76 -0
  54. package/office/assets/foosball.js +95 -0
  55. package/office/assets/fridge.js +99 -0
  56. package/office/assets/gaming-chair.js +154 -0
  57. package/office/assets/gaming-desk.js +105 -0
  58. package/office/assets/glass-door.js +72 -0
  59. package/office/assets/glass-wall.js +64 -0
  60. package/office/assets/half-wall.js +49 -0
  61. package/office/assets/hanging-plant.js +112 -0
  62. package/office/assets/index.js +151 -0
  63. package/office/assets/indoor-tree.js +90 -0
  64. package/office/assets/l-sofa.js +153 -0
  65. package/office/assets/marble-floor.js +64 -0
  66. package/office/assets/materials.js +40 -0
  67. package/office/assets/meeting-table.js +88 -0
  68. package/office/assets/microwave.js +94 -0
  69. package/office/assets/monitor.js +67 -0
  70. package/office/assets/neon-strip.js +73 -0
  71. package/office/assets/painting.js +84 -0
  72. package/office/assets/palm-tree.js +108 -0
  73. package/office/assets/pc-tower.js +91 -0
  74. package/office/assets/pendant-light.js +67 -0
  75. package/office/assets/ping-pong.js +114 -0
  76. package/office/assets/plant.js +72 -0
  77. package/office/assets/planter-box.js +95 -0
  78. package/office/assets/pool-table.js +94 -0
  79. package/office/assets/printer.js +113 -0
  80. package/office/assets/reception-desk.js +133 -0
  81. package/office/assets/rug.js +78 -0
  82. package/office/assets/sculpture.js +85 -0
  83. package/office/assets/server-rack.js +98 -0
  84. package/office/assets/sink.js +109 -0
  85. package/office/assets/sofa.js +106 -0
  86. package/office/assets/speaker.js +83 -0
  87. package/office/assets/spotlight.js +83 -0
  88. package/office/assets/street-lamp.js +97 -0
  89. package/office/assets/trash-can.js +83 -0
  90. package/office/assets/treadmill.js +126 -0
  91. package/office/assets/trophy.js +89 -0
  92. package/office/assets/tv-screen.js +79 -0
  93. package/office/assets/vase.js +84 -0
  94. package/office/assets/wall-clock.js +84 -0
  95. package/office/assets/wall.js +53 -0
  96. package/office/assets/water-cooler.js +146 -0
  97. package/office/assets/whiteboard.js +115 -0
  98. package/office/assets.js +3 -431
  99. package/office/builder.js +791 -355
  100. package/office/campus-env.js +1012 -1119
  101. package/office/environment.js +2 -0
  102. package/office/gallery.js +997 -0
  103. package/office/index.js +165 -61
  104. package/office/navigation.js +173 -152
  105. package/office/player.js +178 -68
  106. package/office/robot-character.js +272 -0
  107. package/office/spectator-camera.js +33 -10
  108. package/office/state.js +2 -0
  109. package/office/world-save.js +35 -4
  110. package/package.json +57 -3
  111. package/providers/comfyui.js +383 -0
  112. package/providers/dalle.js +79 -0
  113. package/providers/gemini.js +181 -0
  114. package/providers/ollama.js +184 -0
  115. package/providers/replicate.js +115 -0
  116. package/providers/zai.js +183 -0
  117. package/runtime-descriptor.js +270 -0
  118. package/scripts/check-agent-contract-advisory.js +132 -0
  119. package/scripts/check-api-agent-parity.js +277 -0
  120. package/scripts/check-autonomy-v2-decision.js +207 -0
  121. package/scripts/check-autonomy-v2-execution.js +588 -0
  122. package/scripts/check-autonomy-v2-watchdog.js +224 -0
  123. package/scripts/check-branch-fork-snapshot.js +337 -0
  124. package/scripts/check-branch-isolation.js +787 -0
  125. package/scripts/check-branch-semantics.js +139 -0
  126. package/scripts/check-dashboard-control-plane.js +1304 -0
  127. package/scripts/check-docs-onboarding.js +490 -0
  128. package/scripts/check-event-schema.js +276 -0
  129. package/scripts/check-evidence-completion.js +239 -0
  130. package/scripts/check-invariants.js +992 -0
  131. package/scripts/check-lifecycle-hooks.js +525 -0
  132. package/scripts/check-managed-team-integration.js +166 -0
  133. package/scripts/check-markdown-workspace-export.js +548 -0
  134. package/scripts/check-markdown-workspace-safety.js +347 -0
  135. package/scripts/check-markdown-workspace.js +136 -0
  136. package/scripts/check-message-replay.js +429 -0
  137. package/scripts/check-migration-hardening.js +300 -0
  138. package/scripts/check-performance-indexing.js +272 -0
  139. package/scripts/check-provider-capabilities.js +316 -0
  140. package/scripts/check-runtime-contract.js +109 -0
  141. package/scripts/check-session-aware-context.js +172 -0
  142. package/scripts/check-session-lifecycle.js +210 -0
  143. package/scripts/export-markdown-workspace.js +84 -0
  144. package/scripts/fixtures/message-replay/clean.jsonl +2 -0
  145. package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
  146. package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
  147. package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
  148. package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
  149. package/scripts/migrate-legacy-to-canonical.js +201 -0
  150. package/scripts/run-verification-suite.js +242 -0
  151. package/scripts/sync-packaged-docs.js +69 -0
  152. package/server.js +9546 -7214
  153. package/state/agents.js +161 -0
  154. package/state/canonical.js +3068 -0
  155. package/state/dashboard-queries.js +441 -0
  156. package/state/evidence.js +56 -0
  157. package/state/io.js +69 -0
  158. package/state/markdown-workspace.js +951 -0
  159. package/state/messages.js +669 -0
  160. package/state/sessions.js +683 -0
  161. package/state/tasks-workflows.js +92 -0
  162. package/templates/debate.json +2 -2
  163. package/templates/managed.json +4 -4
  164. package/templates/pair.json +2 -2
  165. package/templates/review.json +2 -2
  166. package/templates/team.json +3 -3
package/api-agents.js ADDED
@@ -0,0 +1,760 @@
1
+ // API Agent Engine — registration, heartbeat, canonical message polling, provider dispatch
2
+ // API agents run inside the dashboard process and poll canonical branch/channel projections for requests
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ const { OllamaProvider } = require('./providers/ollama');
9
+ const { DalleProvider } = require('./providers/dalle');
10
+ const { ReplicateProvider } = require('./providers/replicate');
11
+ const { GeminiProvider } = require('./providers/gemini');
12
+ const { ComfyUIProvider } = require('./providers/comfyui');
13
+ const { ZaiProvider } = require('./providers/zai');
14
+ const {
15
+ PROVIDER_COLORS,
16
+ createApiAgentRuntimeDescriptor,
17
+ resolveAgentRuntimeMetadata,
18
+ validateExplicitRuntimeDescriptor,
19
+ } = require('./runtime-descriptor');
20
+ const { createCanonicalState } = require('./state/canonical');
21
+
22
+ const PROVIDERS = {
23
+ ollama: OllamaProvider,
24
+ dalle: DalleProvider,
25
+ replicate: ReplicateProvider,
26
+ gemini: GeminiProvider,
27
+ comfyui: ComfyUIProvider,
28
+ zai: ZaiProvider,
29
+ };
30
+
31
+ class ApiAgentEngine {
32
+ constructor(dataDir) {
33
+ this.dataDir = dataDir;
34
+ this.agents = {}; // name -> { config, provider, pollInterval, running, stats }
35
+ this._configFile = path.join(dataDir, 'api-agents.json');
36
+ this._mediaFile = path.join(dataDir, 'media.jsonl');
37
+ this._mediaDir = path.join(dataDir, 'media');
38
+ this._canonicalState = createCanonicalState({ dataDir, processPid: process.pid });
39
+ this._loadConfigs();
40
+ }
41
+
42
+ _loadConfigs() {
43
+ try {
44
+ if (fs.existsSync(this._configFile)) {
45
+ const configs = JSON.parse(fs.readFileSync(this._configFile, 'utf8'));
46
+ for (const cfg of configs) {
47
+ const normalizedConfig = this._normalizeAgentConfig(cfg.name, cfg);
48
+ this.agents[cfg.name] = this._createAgentState(normalizedConfig);
49
+ this._registerInAgentsJson(cfg.name, normalizedConfig.provider);
50
+ }
51
+ }
52
+ } catch {}
53
+ }
54
+
55
+ _saveConfigs() {
56
+ const configs = Object.values(this.agents).map(a => a.config);
57
+ fs.writeFileSync(this._configFile, JSON.stringify(configs, null, 2));
58
+ }
59
+
60
+ _createProvider(config) {
61
+ const ProviderClass = PROVIDERS[config.provider];
62
+ if (!ProviderClass) return null;
63
+ return new ProviderClass({
64
+ endpoint: config.endpoint,
65
+ model: config.model,
66
+ apiKey: config.apiKey,
67
+ ...config.options,
68
+ });
69
+ }
70
+
71
+ _createAgentState(config) {
72
+ return {
73
+ config,
74
+ provider: this._createProvider(config),
75
+ pollInterval: null,
76
+ heartbeatInterval: null,
77
+ running: false,
78
+ stats: { requests: 0, completed: 0, errors: 0, lastActivity: null },
79
+ lastReadOffset: 0,
80
+ seenMessageIds: new Set(),
81
+ branchSessions: {},
82
+ activeBranches: new Set(),
83
+ };
84
+ }
85
+
86
+ _normalizeAgentConfig(name, config = {}) {
87
+ const normalized = {
88
+ ...config,
89
+ options: config.options || {},
90
+ };
91
+ const descriptor = createApiAgentRuntimeDescriptor({
92
+ name,
93
+ provider_id: normalized.provider,
94
+ model_id: normalized.model,
95
+ capabilities: normalized.capabilities,
96
+ });
97
+ const validation = validateExplicitRuntimeDescriptor(descriptor);
98
+
99
+ if (!validation.valid) return normalized;
100
+
101
+ normalized.provider = descriptor.provider_id;
102
+ normalized.model = descriptor.model_id;
103
+ normalized.capabilities = descriptor.capabilities;
104
+ return normalized;
105
+ }
106
+
107
+ _getAgentRuntimeMetadata(name) {
108
+ const agent = this.agents[name];
109
+ const config = agent ? this._normalizeAgentConfig(name, agent.config) : null;
110
+ if (agent && config !== agent.config) agent.config = config;
111
+
112
+ return resolveAgentRuntimeMetadata({
113
+ name,
114
+ is_api_agent: true,
115
+ runtime_type: 'api',
116
+ provider_id: config ? config.provider : null,
117
+ model_id: config ? config.model : null,
118
+ capabilities: config ? config.capabilities : null,
119
+ });
120
+ }
121
+
122
+ // Register a new API agent
123
+ create(name, provider, options = {}) {
124
+ if (!name || !/^[a-zA-Z0-9_-]{1,20}$/.test(name)) {
125
+ return { error: 'Invalid name (1-20 alphanumeric/underscore/dash)' };
126
+ }
127
+ if (this.agents[name]) {
128
+ return { error: 'API agent already exists: ' + name };
129
+ }
130
+ if (!PROVIDERS[provider]) {
131
+ return { error: 'Unknown provider: ' + provider + '. Available: ' + Object.keys(PROVIDERS).join(', ') };
132
+ }
133
+
134
+ const config = this._normalizeAgentConfig(name, {
135
+ name,
136
+ provider,
137
+ model: options.model || 'sdxl',
138
+ capabilities: options.capabilities,
139
+ endpoint: options.endpoint || 'http://localhost:11434',
140
+ apiKey: options.apiKey || '',
141
+ options: options.providerOptions || {},
142
+ created: new Date().toISOString(),
143
+ });
144
+
145
+ const descriptor = createApiAgentRuntimeDescriptor({
146
+ name,
147
+ provider_id: config.provider,
148
+ model_id: config.model,
149
+ capabilities: config.capabilities,
150
+ });
151
+ const validation = validateExplicitRuntimeDescriptor(descriptor);
152
+ if (!validation.valid) {
153
+ return { error: 'Invalid API agent runtime descriptor: ' + validation.errors.join('; ') };
154
+ }
155
+
156
+ this.agents[name] = this._createAgentState(config);
157
+
158
+ this._saveConfigs();
159
+ this._registerInAgentsJson(name, provider);
160
+ return { ok: true, name, provider };
161
+ }
162
+
163
+ // Register API agent in agents.json so it appears in the dashboard + 3D Hub
164
+ _registerInAgentsJson(name, provider) {
165
+ const runtimeMetadata = this._getAgentRuntimeMetadata(name);
166
+ this._canonicalState.registerApiAgent({
167
+ name,
168
+ agent: {
169
+ pid: process.pid,
170
+ last_activity: new Date().toISOString(),
171
+ status: 'sleeping',
172
+ role: 'api-agent',
173
+ is_api_agent: true,
174
+ runtime_type: runtimeMetadata.runtime_type,
175
+ provider_id: runtimeMetadata.provider_id,
176
+ model_id: runtimeMetadata.model_id,
177
+ capabilities: runtimeMetadata.capabilities,
178
+ provider: runtimeMetadata.provider || provider,
179
+ provider_color: runtimeMetadata.provider_color || PROVIDER_COLORS[provider] || '#666',
180
+ bot_capability: runtimeMetadata.bot_capability,
181
+ },
182
+ profile: {
183
+ display_name: name,
184
+ role: 'api-agent',
185
+ bio: `${provider} API agent — generates media on request`,
186
+ avatar: 'robot',
187
+ is_api_agent: true,
188
+ provider: runtimeMetadata.provider || provider,
189
+ },
190
+ createProfileIfMissing: true,
191
+ });
192
+ }
193
+
194
+ // Remove API agent from agents.json
195
+ _unregisterFromAgentsJson(name) {
196
+ this._canonicalState.unregisterApiAgent(name);
197
+ }
198
+
199
+ // Delete an API agent
200
+ remove(name) {
201
+ if (!this.agents[name]) return { error: 'API agent not found: ' + name };
202
+ this.stop(name);
203
+ this._unregisterFromAgentsJson(name);
204
+ delete this.agents[name];
205
+ this._saveConfigs();
206
+ return { ok: true };
207
+ }
208
+
209
+ // Start polling for messages
210
+ start(name) {
211
+ const agent = this.agents[name];
212
+ if (!agent) return { error: 'API agent not found: ' + name };
213
+ if (agent.running) return { ok: true, message: 'Already running' };
214
+
215
+ agent.running = true;
216
+ this._primeSeenMessages(name);
217
+ this._updateAgentStatus(name, 'active');
218
+
219
+ // Poll every 2 seconds
220
+ agent.pollInterval = setInterval(() => {
221
+ this._pollMessages(name);
222
+ }, 2000);
223
+
224
+ // Heartbeat every 10 seconds
225
+ agent.heartbeatInterval = setInterval(() => {
226
+ this._updateHeartbeat(name);
227
+ }, 10000);
228
+
229
+ return { ok: true };
230
+ }
231
+
232
+ // Stop polling
233
+ stop(name) {
234
+ const agent = this.agents[name];
235
+ if (!agent) return { error: 'API agent not found: ' + name };
236
+
237
+ agent.running = false;
238
+ if (agent.pollInterval) { clearInterval(agent.pollInterval); agent.pollInterval = null; }
239
+ if (agent.heartbeatInterval) { clearInterval(agent.heartbeatInterval); agent.heartbeatInterval = null; }
240
+ this._interruptAgentSessions(name);
241
+ this._updateAgentStatus(name, 'sleeping');
242
+ return { ok: true };
243
+ }
244
+
245
+ // List all API agents with status
246
+ list() {
247
+ return Object.values(this.agents).map(a => {
248
+ const runtimeMetadata = this._getAgentRuntimeMetadata(a.config.name);
249
+ return {
250
+ ...runtimeMetadata,
251
+ name: a.config.name,
252
+ provider: a.config.provider,
253
+ model: a.config.model,
254
+ endpoint: a.config.endpoint,
255
+ hasApiKey: !!a.config.apiKey,
256
+ running: a.running,
257
+ stats: a.stats,
258
+ color: runtimeMetadata.provider_color || PROVIDER_COLORS[a.config.provider] || '#666',
259
+ created: a.config.created,
260
+ };
261
+ });
262
+ }
263
+
264
+ // Poll branch-scoped conversation history for new messages addressed to this API agent
265
+ _pollMessages(name) {
266
+ const agent = this.agents[name];
267
+ if (!agent || !agent.running) return;
268
+
269
+ try {
270
+ const pendingMessages = this._collectPendingMessages(name);
271
+ for (const entry of pendingMessages) {
272
+ Promise.resolve(this._processMessage(name, entry.message, { branch: entry.branch }))
273
+ .then((handled) => {
274
+ if (handled) {
275
+ this._markConsumedMessage(name, entry.branch, entry.message.id);
276
+ return;
277
+ }
278
+
279
+ agent.seenMessageIds.delete(entry.message.id);
280
+ })
281
+ .catch(() => {
282
+ agent.seenMessageIds.delete(entry.message.id);
283
+ });
284
+ }
285
+ } catch {}
286
+ }
287
+
288
+ _listKnownBranches() {
289
+ try {
290
+ const branches = this._canonicalState.listMarkdownBranches();
291
+ const names = Array.isArray(branches)
292
+ ? branches.map((entry) => entry && entry.branch).filter(Boolean)
293
+ : [];
294
+ return names.length > 0 ? names : ['main'];
295
+ } catch {
296
+ return ['main'];
297
+ }
298
+ }
299
+
300
+ _getConversationMessages(branch) {
301
+ try {
302
+ return this._canonicalState.getConversationMessages({ branch });
303
+ } catch {
304
+ return [];
305
+ }
306
+ }
307
+
308
+ _primeSeenMessages(name) {
309
+ const agent = this.agents[name];
310
+ if (!agent) return;
311
+
312
+ const seenMessageIds = new Set();
313
+ for (const branch of this._listKnownBranches()) {
314
+ const consumedIds = this._canonicalState.readConsumedMessageIds(name, { branch });
315
+ for (const messageId of consumedIds) seenMessageIds.add(messageId);
316
+ }
317
+
318
+ agent.seenMessageIds = seenMessageIds;
319
+ agent.lastReadOffset = seenMessageIds.size;
320
+ }
321
+
322
+ _readConsumedMessages(name, branch) {
323
+ return this._canonicalState.readConsumedMessageIds(name, { branch });
324
+ }
325
+
326
+ _markConsumedMessage(name, branch, messageId) {
327
+ const agent = this.agents[name];
328
+ if (!agent || !messageId) return;
329
+
330
+ const consumedIds = this._readConsumedMessages(name, branch);
331
+ consumedIds.add(messageId);
332
+ this._canonicalState.writeConsumedMessageIds(name, consumedIds, { branch });
333
+ this._markSeenMessage(agent, messageId);
334
+ }
335
+
336
+ _markSeenMessage(agent, messageId) {
337
+ if (!agent || !messageId) return;
338
+ agent.seenMessageIds.add(messageId);
339
+ agent.lastReadOffset = agent.seenMessageIds.size;
340
+ }
341
+
342
+ _collectPendingMessages(name) {
343
+ const agent = this.agents[name];
344
+ if (!agent) return [];
345
+
346
+ const pending = [];
347
+ for (const branch of this._listKnownBranches()) {
348
+ const consumedIds = this._readConsumedMessages(name, branch);
349
+ for (const message of this._getConversationMessages(branch)) {
350
+ if (!message || !message.id || agent.seenMessageIds.has(message.id) || consumedIds.has(message.id)) continue;
351
+ if (message.to !== name || !message.content) continue;
352
+ agent.seenMessageIds.add(message.id);
353
+ pending.push({ branch, message });
354
+ }
355
+ }
356
+
357
+ pending.sort((left, right) => {
358
+ const leftTime = Date.parse(left.message.timestamp || '') || 0;
359
+ const rightTime = Date.parse(right.message.timestamp || '') || 0;
360
+ if (leftTime !== rightTime) return leftTime - rightTime;
361
+ return String(left.message.id).localeCompare(String(right.message.id));
362
+ });
363
+
364
+ return pending;
365
+ }
366
+
367
+ _ensureAgentBranchSession(name, branch) {
368
+ const agent = this.agents[name];
369
+ if (!agent) return null;
370
+
371
+ try {
372
+ const activation = this._canonicalState.ensureAgentSession({
373
+ agentName: name,
374
+ branchName: branch,
375
+ sessionId: agent.branchSessions[branch] || null,
376
+ provider: agent.config.provider,
377
+ reason: 'api_agent_message',
378
+ });
379
+ const session = activation && activation.session ? activation.session : null;
380
+ if (session && session.session_id) {
381
+ agent.branchSessions[branch] = session.session_id;
382
+ agent.activeBranches.add(branch);
383
+ }
384
+ try { this._canonicalState.updateAgentBranch(name, branch); } catch {}
385
+ return session;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ _interruptAgentSessions(name) {
392
+ const agent = this.agents[name];
393
+ if (!agent) return;
394
+
395
+ for (const branch of agent.activeBranches) {
396
+ try {
397
+ this._canonicalState.transitionLatestSessionForAgent({
398
+ agentName: name,
399
+ branchName: branch,
400
+ state: 'interrupted',
401
+ reason: 'api_agent_stop',
402
+ });
403
+ } catch {}
404
+ }
405
+
406
+ agent.activeBranches = new Set();
407
+ agent.branchSessions = {};
408
+ }
409
+
410
+ _buildReplyContext(msg, options = {}) {
411
+ const branch = options.branch || 'main';
412
+ const channel = msg && msg.channel && msg.channel !== 'general' ? msg.channel : null;
413
+
414
+ return {
415
+ branch,
416
+ channel,
417
+ replyTo: msg && msg.id ? msg.id : null,
418
+ threadId: msg && (msg.thread_id || msg.id) ? (msg.thread_id || msg.id) : null,
419
+ sessionId: options.sessionId || null,
420
+ commandId: msg && msg.command_id ? msg.command_id : null,
421
+ causationId: msg && msg.id ? msg.id : null,
422
+ correlationId: msg && (msg.correlation_id || msg.command_id || msg.thread_id || msg.id)
423
+ ? (msg.correlation_id || msg.command_id || msg.thread_id || msg.id)
424
+ : null,
425
+ };
426
+ }
427
+
428
+ // Process an incoming message
429
+ async _processMessage(name, msg, context = {}) {
430
+ const agent = this.agents[name];
431
+ if (!agent || !agent.provider) return false;
432
+
433
+ const branch = context.branch || 'main';
434
+ const session = this._ensureAgentBranchSession(name, branch);
435
+ const replyContext = this._buildReplyContext(msg, {
436
+ branch,
437
+ sessionId: session && session.session_id ? session.session_id : null,
438
+ });
439
+
440
+ agent.stats.requests++;
441
+ agent.stats.lastActivity = new Date().toISOString();
442
+
443
+ const content = msg.content || '';
444
+ // Extract prompt — support "Generate: <prompt>" or just raw text
445
+ let prompt = content;
446
+ const genMatch = content.match(/^(?:Generate|Create|Make|Draw|Render):\s*(.+)/i);
447
+ if (genMatch) prompt = genMatch[1].trim();
448
+
449
+ // Detect media category from prompt keywords
450
+ // texture: seamless patterns, materials, surfaces for 3D/game use
451
+ // video: animations, motion, mp4 requests
452
+ // image: everything else (concept art, photos, illustrations)
453
+ let mediaCategory = 'image';
454
+ const lowerPrompt = prompt.toLowerCase();
455
+ if (/\b(texture|tileable|seamless|material|pbr|normal.?map|roughness.?map|diffuse|bump.?map|2d.?texture|surface.?pattern)\b/.test(lowerPrompt)) {
456
+ mediaCategory = 'texture';
457
+ } else if (/\b(video|animation|animate|mp4|motion|clip|footage)\b/.test(lowerPrompt)) {
458
+ mediaCategory = 'video';
459
+ }
460
+
461
+ // Extract image attachments (base64)
462
+ var imageAttachments = [];
463
+ if (msg.attachments && Array.isArray(msg.attachments)) {
464
+ for (const att of msg.attachments) {
465
+ if (att.base64 && att.mimeType && att.mimeType.startsWith('image/')) {
466
+ imageAttachments.push({ mimeType: att.mimeType, base64: att.base64 });
467
+ }
468
+ }
469
+ }
470
+
471
+ try {
472
+ // Send "processing" response
473
+ var attachNote = imageAttachments.length > 0 ? ' [+' + imageAttachments.length + ' image(s)]' : '';
474
+ this._sendMessage(
475
+ name,
476
+ msg.from,
477
+ `Processing: "${prompt.substring(0, 60)}${prompt.length > 60 ? '...' : ''}"${attachNote}`,
478
+ msg.id,
479
+ replyContext
480
+ );
481
+
482
+ const result = await agent.provider.generate(prompt, { images: imageAttachments });
483
+ agent.stats.completed++;
484
+
485
+ if (result.type === 'image' && result.data) {
486
+ // Save media file
487
+ const mediaId = crypto.randomUUID();
488
+ const ext = result.format === 'url' ? 'png' : (result.format || 'png');
489
+ const filename = `${mediaId}.${ext}`;
490
+
491
+ // Ensure media directory exists
492
+ if (!fs.existsSync(this._mediaDir)) fs.mkdirSync(this._mediaDir, { recursive: true });
493
+
494
+ if (result.format === 'url') {
495
+ // Download from URL
496
+ await this._downloadFile(result.data, path.join(this._mediaDir, filename));
497
+ } else {
498
+ // Save base64 data
499
+ const buffer = Buffer.from(result.data, 'base64');
500
+ fs.writeFileSync(path.join(this._mediaDir, filename), buffer);
501
+ }
502
+
503
+ // Also save a named copy to generated-images/ in the project folder
504
+ // so other agents can reference them by readable name
505
+ const projectImgDir = path.join(this.dataDir, '..', 'generated-images');
506
+ try {
507
+ if (!fs.existsSync(projectImgDir)) fs.mkdirSync(projectImgDir, { recursive: true });
508
+ const safeName = prompt.substring(0, 60).replace(/[^a-zA-Z0-9 _-]/g, '').trim().replace(/\s+/g, '_') || 'image';
509
+ const namedFile = safeName + '_' + mediaId.substring(0, 8) + '.' + ext;
510
+ fs.copyFileSync(path.join(this._mediaDir, filename), path.join(projectImgDir, namedFile));
511
+ } catch (e) { /* non-critical — media dir copy is the source of truth */ }
512
+
513
+ // Log media metadata
514
+ const mediaEntry = {
515
+ id: mediaId,
516
+ type: mediaCategory,
517
+ prompt: prompt,
518
+ agent: name,
519
+ provider: agent.config.provider,
520
+ model: agent.config.model,
521
+ filename: filename,
522
+ timestamp: new Date().toISOString(),
523
+ requestedBy: msg.from,
524
+ };
525
+ fs.appendFileSync(this._mediaFile, JSON.stringify(mediaEntry) + '\n');
526
+
527
+ // Send response with media reference
528
+ this._sendMessage(name, msg.from,
529
+ `Generated image: "${prompt.substring(0, 80)}"\nMedia ID: ${mediaId}\nModel: ${result.model || agent.config.model}${result.revised_prompt ? '\nRevised: ' + result.revised_prompt : ''}`,
530
+ msg.id,
531
+ replyContext
532
+ );
533
+ } else if (result.type === 'text') {
534
+ this._sendMessage(name, msg.from, result.data, msg.id, replyContext);
535
+ }
536
+ } catch (err) {
537
+ agent.stats.errors++;
538
+ this._sendMessage(name, msg.from, `Error: ${err.message}`, msg.id, replyContext);
539
+ }
540
+
541
+ return true;
542
+ }
543
+
544
+ // Send a message from the API agent
545
+ _sendMessage(from, to, content, replyTo) {
546
+ const context = arguments[4] || {};
547
+ const channel = context.channel && context.channel !== 'general' ? context.channel : null;
548
+ const msg = {
549
+ id: crypto.randomUUID(),
550
+ from,
551
+ to,
552
+ content,
553
+ timestamp: new Date().toISOString(),
554
+ reply_to: replyTo || null,
555
+ system: false,
556
+ };
557
+
558
+ if (channel) msg.channel = channel;
559
+ if (context.threadId) msg.thread_id = context.threadId;
560
+ if (context.sessionId) msg.session_id = context.sessionId;
561
+ if (context.commandId) msg.command_id = context.commandId;
562
+ if (context.causationId) msg.causation_id = context.causationId;
563
+ if (context.correlationId) msg.correlation_id = context.correlationId;
564
+
565
+ if (!context.branch && !channel && !context.sessionId && !context.commandId && !context.causationId && !context.correlationId) {
566
+ this._canonicalState.appendMessage(msg);
567
+ return msg;
568
+ }
569
+
570
+ this._canonicalState.appendScopedMessage(msg, {
571
+ branch: context.branch,
572
+ channel,
573
+ actorAgent: from,
574
+ sessionId: context.sessionId || null,
575
+ commandId: context.commandId || null,
576
+ causationId: context.causationId || null,
577
+ correlationId: context.correlationId || null,
578
+ });
579
+ return msg;
580
+ }
581
+
582
+ // Update heartbeat in agents.json
583
+ _updateHeartbeat(name) {
584
+ this._canonicalState.updateAgentHeartbeat(name);
585
+ }
586
+
587
+ _updateAgentStatus(name, status) {
588
+ this._canonicalState.updateAgentStatus(name, status);
589
+ }
590
+
591
+ _getMessageCount() {
592
+ return Object.values(this.agents).reduce((total, agent) => total + (agent.lastReadOffset || 0), 0);
593
+ }
594
+
595
+ _downloadFile(url, dest) {
596
+ const transport = url.startsWith('https') ? require('https') : require('http');
597
+ return new Promise((resolve, reject) => {
598
+ const file = fs.createWriteStream(dest);
599
+ transport.get(url, { timeout: 60000 }, (response) => {
600
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
601
+ file.close();
602
+ fs.unlinkSync(dest);
603
+ return this._downloadFile(response.headers.location, dest).then(resolve).catch(reject);
604
+ }
605
+ response.pipe(file);
606
+ file.on('finish', () => { file.close(); resolve(); });
607
+ }).on('error', (e) => {
608
+ file.close();
609
+ try { fs.unlinkSync(dest); } catch {}
610
+ reject(e);
611
+ });
612
+ });
613
+ }
614
+
615
+ // Get media list (paginated, filterable)
616
+ // Scans generated-images/ folder as the single source of truth
617
+ getMedia(options = {}) {
618
+ let items = [];
619
+ const imageExts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
620
+ const genImgDir = path.join(this.dataDir, '..', 'generated-images');
621
+
622
+ // Build a lookup from media.jsonl for metadata (prompt, agent, provider, model)
623
+ const metaByShortId = {};
624
+ if (fs.existsSync(this._mediaFile)) {
625
+ try {
626
+ const content = fs.readFileSync(this._mediaFile, 'utf8').trim();
627
+ if (content) {
628
+ const parsed = content.split(/\r?\n/).map(line => {
629
+ try { return JSON.parse(line); } catch { return null; }
630
+ }).filter(Boolean);
631
+ for (const item of parsed) {
632
+ // Match by short ID suffix in filename (e.g. "happy_pitbull_40e85ec7.png" matches id starting with "40e85ec7")
633
+ const shortId = (item.id || '').substring(0, 8);
634
+ if (shortId) metaByShortId[shortId] = item;
635
+ }
636
+ }
637
+ } catch {}
638
+ }
639
+
640
+ // Scan generated-images/ folder
641
+ if (fs.existsSync(genImgDir)) {
642
+ try {
643
+ const files = fs.readdirSync(genImgDir);
644
+ for (const file of files) {
645
+ const ext = path.extname(file).toLowerCase();
646
+ if (!imageExts.has(ext)) continue;
647
+ const filePath = path.join(genImgDir, file);
648
+ const stat = fs.statSync(filePath);
649
+ const baseName = path.basename(file, ext);
650
+ // Extract short ID from end of filename (e.g. "happy_pitbull_40e85ec7" -> "40e85ec7")
651
+ const idMatch = baseName.match(/_([a-f0-9]{8})$/);
652
+ const shortId = idMatch ? idMatch[1] : null;
653
+ const meta = shortId ? metaByShortId[shortId] : null;
654
+ const name = baseName.replace(/_/g, ' ').replace(/\s+[a-f0-9]{8}$/, '');
655
+
656
+ items.push({
657
+ id: meta ? meta.id : ('file:gen:' + file),
658
+ type: meta ? meta.type : 'image',
659
+ prompt: meta ? meta.prompt : name,
660
+ agent: meta ? meta.agent : 'imported',
661
+ provider: meta ? meta.provider : 'file',
662
+ model: meta ? meta.model : '',
663
+ filename: file,
664
+ timestamp: meta ? meta.timestamp : stat.mtime.toISOString(),
665
+ _source: 'generated-images',
666
+ });
667
+ }
668
+ } catch {}
669
+ }
670
+
671
+ // Filter by type
672
+ if (options.type) items = items.filter(i => i.type === options.type);
673
+ // Filter by agent
674
+ if (options.agent) items = items.filter(i => i.agent === options.agent);
675
+
676
+ // Sort newest first
677
+ items.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
678
+
679
+ // Paginate
680
+ const page = options.page || 1;
681
+ const limit = options.limit || 20;
682
+ const start = (page - 1) * limit;
683
+ return items.slice(start, start + limit);
684
+ }
685
+
686
+ // Get media file path — serves from generated-images/ first, falls back to .agent-bridge/media/
687
+ getMediaFilePath(id) {
688
+ const genImgDir = path.join(this.dataDir, '..', 'generated-images');
689
+
690
+ // Handle virtual file IDs from folder scan
691
+ if (id.startsWith('file:gen:')) {
692
+ const filename = id.slice('file:gen:'.length);
693
+ const filePath = path.join(genImgDir, filename);
694
+ return fs.existsSync(filePath) ? filePath : null;
695
+ }
696
+
697
+ // Look up in media.jsonl — find the named copy in generated-images/
698
+ if (!fs.existsSync(this._mediaFile)) return null;
699
+ try {
700
+ const content = fs.readFileSync(this._mediaFile, 'utf8').trim();
701
+ const lines = content.split(/\r?\n/);
702
+ for (const line of lines) {
703
+ try {
704
+ const item = JSON.parse(line);
705
+ if (item.id === id) {
706
+ const shortId = item.id.substring(0, 8);
707
+ // Look for the named copy in generated-images/
708
+ if (fs.existsSync(genImgDir)) {
709
+ const files = fs.readdirSync(genImgDir);
710
+ const match = files.find(f => f.includes(shortId));
711
+ if (match) return path.join(genImgDir, match);
712
+ }
713
+ // Fallback to .agent-bridge/media/
714
+ const filePath = path.join(this._mediaDir, item.filename);
715
+ return fs.existsSync(filePath) ? filePath : null;
716
+ }
717
+ } catch {}
718
+ }
719
+ } catch {}
720
+ return null;
721
+ }
722
+
723
+ // Delete a media item
724
+ deleteMedia(id) {
725
+ if (!fs.existsSync(this._mediaFile)) return { error: 'No media found' };
726
+ try {
727
+ const content = fs.readFileSync(this._mediaFile, 'utf8').trim();
728
+ const lines = content.split(/\r?\n/);
729
+ const remaining = [];
730
+ let found = false;
731
+ for (const line of lines) {
732
+ try {
733
+ const item = JSON.parse(line);
734
+ if (item.id === id) {
735
+ found = true;
736
+ // Delete actual file
737
+ const filePath = path.join(this._mediaDir, item.filename);
738
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
739
+ } else {
740
+ remaining.push(line);
741
+ }
742
+ } catch {
743
+ remaining.push(line);
744
+ }
745
+ }
746
+ if (!found) return { error: 'Media not found' };
747
+ fs.writeFileSync(this._mediaFile, remaining.join('\n') + (remaining.length ? '\n' : ''));
748
+ return { ok: true };
749
+ } catch (e) { return { error: e.message }; }
750
+ }
751
+
752
+ // Stop all agents (cleanup on shutdown)
753
+ stopAll() {
754
+ for (const name in this.agents) {
755
+ this.stop(name);
756
+ }
757
+ }
758
+ }
759
+
760
+ module.exports = { ApiAgentEngine, PROVIDER_COLORS };