watercooler 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watercooler",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A beautiful 3D visualization of your mailbox messages as a village of coworkers",
5
5
  "type": "module",
6
6
  "main": "server.ts",
package/public/app.js CHANGED
@@ -2,10 +2,11 @@ import * as THREE from 'three';
2
2
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
3
 
4
4
  // State
5
- let config = { user: '', mailbox: '' };
5
+ let config = { user: '', mailbox: '', avatar: null };
6
6
  let messages = []; // Messages TO user (for main panel)
7
7
  let allMessages = []; // All messages involving user (for house dialogs)
8
8
  let recipients = [];
9
+ let avatarStates = {}; // Map of name -> {tool_name, timestamp}
9
10
  let scene, camera, renderer, controls;
10
11
  let agentMeshes = new Map();
11
12
  let connectionLines = [];
@@ -137,7 +138,7 @@ function createTrees() {
137
138
  }
138
139
  }
139
140
 
140
- function createAgentHouse(name, position) {
141
+ function createAgentHouse(name, position, toolName = null) {
141
142
  const color = getAgentColor(name);
142
143
  const group = new THREE.Group();
143
144
  group.position.copy(position);
@@ -183,30 +184,42 @@ function createAgentHouse(name, position) {
183
184
  window2.position.set(1.8, 2.5, 3.1);
184
185
  group.add(window2);
185
186
 
186
- // Name label sprite
187
+ // Name label sprite (with optional tool name)
187
188
  const canvas = document.createElement('canvas');
188
189
  const context = canvas.getContext('2d');
189
190
  // High DPI canvas for crisp text
190
191
  const scale = 2;
191
192
  canvas.width = 512;
192
- canvas.height = 128;
193
+ canvas.height = toolName ? 160 : 128; // Taller if showing tool
193
194
  context.scale(scale, scale);
195
+
196
+ // Background
194
197
  context.fillStyle = 'rgba(0, 0, 0, 0.7)';
195
- context.roundRect(0, 0, 256, 64, 16);
198
+ context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
196
199
  context.fill();
200
+
201
+ // Name text
197
202
  context.font = 'bold 24px Arial';
198
203
  context.fillStyle = 'white';
199
204
  context.textAlign = 'center';
200
205
  context.textBaseline = 'middle';
201
- context.fillText(name, 128, 32);
206
+ context.fillText(name, 128, 24);
207
+
208
+ // Tool name text (if available)
209
+ if (toolName) {
210
+ context.font = 'italic 16px Arial';
211
+ context.fillStyle = '#FFD700'; // Gold color for tool name
212
+ context.fillText(toolName, 128, 56);
213
+ }
202
214
 
203
215
  const texture = new THREE.CanvasTexture(canvas);
204
216
  texture.minFilter = THREE.LinearFilter;
205
217
  texture.magFilter = THREE.LinearFilter;
206
218
  const spriteMat = new THREE.SpriteMaterial({ map: texture });
207
219
  const sprite = new THREE.Sprite(spriteMat);
208
- sprite.position.set(0, 8, 0);
209
- sprite.scale.set(8, 2, 1);
220
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
221
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
222
+ sprite.name = 'label'; // Tag for easy updates
210
223
  group.add(sprite);
211
224
 
212
225
  // Path to house
@@ -223,6 +236,49 @@ function createAgentHouse(name, position) {
223
236
  return group;
224
237
  }
225
238
 
239
+ function updateHouseLabel(house, name, toolName = null) {
240
+ const sprite = house.getObjectByName('label');
241
+ if (!sprite) return;
242
+
243
+ // Create new canvas with updated text
244
+ const canvas = document.createElement('canvas');
245
+ const context = canvas.getContext('2d');
246
+ const scale = 2;
247
+ canvas.width = 512;
248
+ canvas.height = toolName ? 160 : 128;
249
+ context.scale(scale, scale);
250
+
251
+ // Background
252
+ context.fillStyle = 'rgba(0, 0, 0, 0.7)';
253
+ context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
254
+ context.fill();
255
+
256
+ // Name text
257
+ context.font = 'bold 24px Arial';
258
+ context.fillStyle = 'white';
259
+ context.textAlign = 'center';
260
+ context.textBaseline = 'middle';
261
+ context.fillText(name, 128, 24);
262
+
263
+ // Tool name text (if available)
264
+ if (toolName) {
265
+ context.font = 'italic 16px Arial';
266
+ context.fillStyle = '#FFD700';
267
+ context.fillText(toolName, 128, 56);
268
+ }
269
+
270
+ // Update texture
271
+ const texture = new THREE.CanvasTexture(canvas);
272
+ texture.minFilter = THREE.LinearFilter;
273
+ texture.magFilter = THREE.LinearFilter;
274
+ sprite.material.map = texture;
275
+ sprite.material.needsUpdate = true;
276
+
277
+ // Update position and scale
278
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
279
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
280
+ }
281
+
226
282
  function createMessageParticle(fromPos, toPos) {
227
283
  const particleGeo = new THREE.SphereGeometry(0.3, 8, 8);
228
284
  const particleMat = new THREE.MeshStandardMaterial({
@@ -340,12 +396,44 @@ function updateVillage() {
340
396
  const z = Math.sin(angle) * radius;
341
397
  const position = new THREE.Vector3(x, 0, z);
342
398
 
399
+ // Get tool name for this agent from avatar states
400
+ const avatarState = avatarStates[agent.toLowerCase()];
401
+ const toolName = avatarState?.tool_name || null;
402
+
343
403
  if (!agentMeshes.has(agent)) {
344
- createAgentHouse(agent, position);
404
+ createAgentHouse(agent, position, toolName);
345
405
  } else {
346
406
  // Update position if needed
347
407
  const house = agentMeshes.get(agent);
348
408
  house.position.copy(position);
409
+
410
+ // Update label with current tool name
411
+ updateHouseLabel(house, agent, toolName);
412
+ }
413
+ });
414
+
415
+ // Remove houses for coworkers that no longer exist
416
+ agentMeshes.forEach((house, name) => {
417
+ if (!allAgents.has(name)) {
418
+ // Remove from scene
419
+ scene.remove(house);
420
+
421
+ // Dispose of geometries and materials to prevent memory leaks
422
+ house.traverse((child) => {
423
+ if (child.isMesh) {
424
+ child.geometry.dispose();
425
+ if (child.material) {
426
+ if (Array.isArray(child.material)) {
427
+ child.material.forEach(m => m.dispose());
428
+ } else {
429
+ child.material.dispose();
430
+ }
431
+ }
432
+ }
433
+ });
434
+
435
+ // Remove from Map
436
+ agentMeshes.delete(name);
349
437
  }
350
438
  });
351
439
 
@@ -410,6 +498,17 @@ async function loadData() {
410
498
  recipients = Array.isArray(recipientsData) ? recipientsData : [];
411
499
  allMessages = Array.isArray(allMessagesData) ? allMessagesData : [];
412
500
 
501
+ // Load avatar states if avatar DB is configured
502
+ if (config.avatar) {
503
+ try {
504
+ const avatarsRes = await fetch('/api/avatars');
505
+ avatarStates = await avatarsRes.json();
506
+ } catch (err) {
507
+ console.error('Error loading avatar states:', err);
508
+ avatarStates = {};
509
+ }
510
+ }
511
+
413
512
  updateUI();
414
513
  updateVillage();
415
514
  } catch (err) {
@@ -525,6 +624,18 @@ async function markAsRead(id) {
525
624
  }
526
625
  }
527
626
 
627
+ window.markAllAsRead = async function() {
628
+ const unreadMessages = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase());
629
+ if (unreadMessages.length === 0) return;
630
+
631
+ try {
632
+ await Promise.all(unreadMessages.map(m => fetch(`/api/messages/${m.id}/read`, { method: 'POST' })));
633
+ loadData();
634
+ } catch (err) {
635
+ console.error('Error marking all as read:', err);
636
+ }
637
+ };
638
+
528
639
  async function sendMessage() {
529
640
  const to = document.getElementById('recipient-select').value;
530
641
  const message = document.getElementById('message-input').value.trim();
@@ -736,7 +847,7 @@ window.closeHouseDialog = function() {
736
847
  document.getElementById('house-dialog').classList.remove('active');
737
848
  };
738
849
 
739
- // Update house labels to show unread indicators
850
+ // Update house labels to show unread indicators and tool names
740
851
  function updateHouseLabels() {
741
852
  agentMeshes.forEach((group, name) => {
742
853
  // Check for unread messages SENT TO this agent (messages they haven't read)
@@ -753,6 +864,10 @@ function updateHouseLabels() {
753
864
  !m.read
754
865
  ).length;
755
866
 
867
+ // Get tool name for this agent
868
+ const avatarState = avatarStates[name.toLowerCase()];
869
+ const toolName = avatarState?.tool_name || null;
870
+
756
871
  // Find the sprite label
757
872
  const sprite = group.children.find(c => c.isSprite);
758
873
  if (sprite) {
@@ -761,7 +876,7 @@ function updateHouseLabels() {
761
876
  const context = canvas.getContext('2d');
762
877
  const scale = 2;
763
878
  canvas.width = 700;
764
- canvas.height = 128;
879
+ canvas.height = toolName ? 160 : 128;
765
880
  context.scale(scale, scale);
766
881
 
767
882
  // Background - change color if there are unread messages
@@ -775,7 +890,7 @@ function updateHouseLabels() {
775
890
  // Default black background
776
891
  context.fillStyle = 'rgba(0, 0, 0, 0.7)';
777
892
  }
778
- context.roundRect(0, 0, 350, 64, 16);
893
+ context.roundRect(0, 0, 350, toolName ? 80 : 64, 16);
779
894
  context.fill();
780
895
 
781
896
  // Name
@@ -786,12 +901,19 @@ function updateHouseLabels() {
786
901
 
787
902
  if (unreadFromAgent > 0) {
788
903
  // Show name with unread indicator from agent
789
- context.fillText(`${name} 🔴 ${unreadFromAgent}`, 175, 32);
904
+ context.fillText(`${name} 🔴 ${unreadFromAgent}`, 175, 24);
790
905
  } else if (unreadCount > 0) {
791
906
  // Show name with sent-but-unread count
792
- context.fillText(`${name} 📤 ${unreadCount}`, 175, 32);
907
+ context.fillText(`${name} 📤 ${unreadCount}`, 175, 24);
793
908
  } else {
794
- context.fillText(name, 175, 32);
909
+ context.fillText(name, 175, 24);
910
+ }
911
+
912
+ // Tool name text (if available)
913
+ if (toolName) {
914
+ context.font = 'italic 16px Arial';
915
+ context.fillStyle = '#FFD700'; // Gold color for tool name
916
+ context.fillText(toolName, 175, 56);
795
917
  }
796
918
 
797
919
  const texture = new THREE.CanvasTexture(canvas);
@@ -799,6 +921,10 @@ function updateHouseLabels() {
799
921
  texture.magFilter = THREE.LinearFilter;
800
922
  sprite.material.map = texture;
801
923
  sprite.material.needsUpdate = true;
924
+
925
+ // Update position and scale based on whether tool name is shown
926
+ sprite.position.set(0, toolName ? 8.5 : 8, 0);
927
+ sprite.scale.set(8, toolName ? 2.5 : 2, 1);
802
928
  }
803
929
  });
804
930
  }
package/public/index.html CHANGED
@@ -199,6 +199,23 @@
199
199
  opacity: 1;
200
200
  }
201
201
 
202
+ .mark-all-read-btn {
203
+ background: rgba(255, 255, 255, 0.1);
204
+ border: 1px solid rgba(255, 255, 255, 0.2);
205
+ color: rgba(255, 255, 255, 0.8);
206
+ font-size: 0.75rem;
207
+ padding: 4px 10px;
208
+ border-radius: 4px;
209
+ cursor: pointer;
210
+ transition: all 0.2s;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .mark-all-read-btn:hover {
215
+ background: rgba(255, 255, 255, 0.2);
216
+ color: white;
217
+ }
218
+
202
219
  .messages-container {
203
220
  flex: 1;
204
221
  overflow-y: auto;
@@ -303,7 +320,7 @@
303
320
  }
304
321
 
305
322
  .message-text li {
306
- margin: 4px 0;
323
+ margin: 2px 0;
307
324
  }
308
325
 
309
326
  /* Toggle Messages Button */
@@ -790,7 +807,10 @@
790
807
  <div class="messages-panel" id="messages-panel">
791
808
  <div class="messages-header">
792
809
  <h2>📨 Message History</h2>
793
- <button class="close-btn" onclick="toggleMessagesPanel()">×</button>
810
+ <div style="display: flex; gap: 8px; align-items: center;">
811
+ <button class="mark-all-read-btn" onclick="markAllAsRead()">Mark all as read</button>
812
+ <button class="close-btn" onclick="toggleMessagesPanel()">×</button>
813
+ </div>
794
814
  </div>
795
815
  <div class="messages-container" id="messages-container">
796
816
  <div class="empty-state">
package/server.ts CHANGED
@@ -13,6 +13,7 @@ const args = process.argv.slice(2);
13
13
  let user: string | null = null;
14
14
  let mailboxPath: string | null = null;
15
15
  let coworkerPath: string | null = null;
16
+ let avatarPath: string | null = null;
16
17
  let port: number = parseInt(process.env.PORT || '3000', 10);
17
18
  let host: string = process.env.HOST || '0.0.0.0';
18
19
 
@@ -23,6 +24,8 @@ for (let i = 0; i < args.length; i++) {
23
24
  mailboxPath = args[++i];
24
25
  } else if (args[i] === '--coworkers' || args[i] === '-c') {
25
26
  coworkerPath = args[++i];
27
+ } else if (args[i] === '--avatars' || args[i] === '-a') {
28
+ avatarPath = args[++i];
26
29
  } else if (args[i] === '--port' || args[i] === '-p') {
27
30
  const p = parseInt(args[++i], 10);
28
31
  if (!isNaN(p)) port = p;
@@ -32,7 +35,7 @@ for (let i = 0; i < args.length; i++) {
32
35
  }
33
36
 
34
37
  if (!user || !mailboxPath) {
35
- console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>] [--port <number>] [--host <address>]');
38
+ console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>] [--avatars <path>] [--port <number>] [--host <address>]');
36
39
  process.exit(1);
37
40
  }
38
41
 
@@ -41,11 +44,15 @@ console.log(` Mailbox: ${mailboxPath}`);
41
44
  if (coworkerPath) {
42
45
  console.log(` Coworker DB: ${coworkerPath}`);
43
46
  }
47
+ if (avatarPath) {
48
+ console.log(` Avatar DB: ${avatarPath}`);
49
+ }
44
50
  console.log(` URL: http://${host}:${port}`);
45
51
 
46
52
  // Databases
47
53
  let db: Database.Database | null = null;
48
54
  let coworkerDb: Database.Database | null = null;
55
+ let avatarDb: Database.Database | null = null;
49
56
 
50
57
  try {
51
58
  db = new Database(mailboxPath);
@@ -64,6 +71,15 @@ if (coworkerPath) {
64
71
  }
65
72
  }
66
73
 
74
+ if (avatarPath) {
75
+ try {
76
+ avatarDb = new Database(avatarPath);
77
+ console.log(' Avatar DB: connected');
78
+ } catch (err: any) {
79
+ console.warn(' Avatar DB error:', err.message);
80
+ }
81
+ }
82
+
67
83
  // Helper: Check if table exists
68
84
  function tableExists(database: Database.Database | null, tableName: string): boolean {
69
85
  if (!database) return false;
@@ -231,9 +247,55 @@ app.post('/api/messages/:id/read', (req, res) => {
231
247
  }
232
248
  });
233
249
 
250
+ // API: Get avatar states (latest tool usage per coworker)
251
+ app.get('/api/avatars', (req, res) => {
252
+ try {
253
+ if (!avatarDb) {
254
+ res.json({});
255
+ return;
256
+ }
257
+
258
+ // Check if latest_tool_usage table exists
259
+ const tableCheck = avatarDb.prepare(`
260
+ SELECT name FROM sqlite_master
261
+ WHERE type='table' AND name='latest_tool_usage'
262
+ `).get();
263
+
264
+ if (!tableCheck) {
265
+ res.json({});
266
+ return;
267
+ }
268
+
269
+ // Get latest tool usage per name
270
+ const stmt = avatarDb.prepare(`
271
+ SELECT name, tool_name, timestamp
272
+ FROM latest_tool_usage
273
+ ORDER BY timestamp DESC
274
+ `);
275
+
276
+ const rows = stmt.all() as Array<{name: string; tool_name: string; timestamp: number}>;
277
+
278
+ // Build map of name -> latest tool (first occurrence is latest due to ORDER BY)
279
+ const avatarStates: Record<string, {tool_name: string; timestamp: number}> = {};
280
+ for (const row of rows) {
281
+ if (!avatarStates[row.name]) {
282
+ avatarStates[row.name] = {
283
+ tool_name: row.tool_name,
284
+ timestamp: row.timestamp
285
+ };
286
+ }
287
+ }
288
+
289
+ res.json(avatarStates);
290
+ } catch (err: any) {
291
+ console.error('Error in /api/avatars:', err.message);
292
+ res.status(500).json({ error: err.message });
293
+ }
294
+ });
295
+
234
296
  // Config endpoint
235
297
  app.get('/api/config', (req, res) => {
236
- res.json({ user, mailbox: mailboxPath, coworker: coworkerPath });
298
+ res.json({ user, mailbox: mailboxPath, coworker: coworkerPath, avatar: avatarPath });
237
299
  });
238
300
 
239
301
  app.listen(port, host, () => {