privateboard 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/cli.js +10502 -0
  4. package/dist/cli.js.map +1 -0
  5. package/package.json +63 -0
  6. package/public/adjourn-overlay.css +253 -0
  7. package/public/agent-overlay.css +444 -0
  8. package/public/agent-overlay.js +604 -0
  9. package/public/agent-profile.css +3230 -0
  10. package/public/agent-profile.js +3329 -0
  11. package/public/app.js +6629 -0
  12. package/public/auto-hide-scroll.js +90 -0
  13. package/public/avatar-skill.js +793 -0
  14. package/public/avatars/chair.svg +98 -0
  15. package/public/avatars/first-principles.svg +122 -0
  16. package/public/avatars/long-horizon.svg +147 -0
  17. package/public/avatars/open_ai.png +0 -0
  18. package/public/avatars/phenomenologist.svg +130 -0
  19. package/public/avatars/socrates.svg +187 -0
  20. package/public/avatars/user-empathy.svg +117 -0
  21. package/public/avatars/value-investor.svg +117 -0
  22. package/public/favicon.svg +10 -0
  23. package/public/fonts/agent-Italic.woff2 +0 -0
  24. package/public/fonts/human-sans.woff2 +0 -0
  25. package/public/icons.css +103 -0
  26. package/public/models-cache.js +57 -0
  27. package/public/new-agent.css +1359 -0
  28. package/public/new-agent.js +675 -0
  29. package/public/onboarding.css +628 -0
  30. package/public/onboarding.js +782 -0
  31. package/public/prototype-dashboard.html +7596 -0
  32. package/public/report/spines/a16z-thesis.css +1055 -0
  33. package/public/report/spines/anthropic-essay.css +556 -0
  34. package/public/report/spines/boardroom-dark.css +1082 -0
  35. package/public/report/spines/gartner-note.css +538 -0
  36. package/public/report/spines/mckinsey-deck.css +523 -0
  37. package/public/report/spines/openai-paper.css +516 -0
  38. package/public/report.html +1417 -0
  39. package/public/room-settings.css +895 -0
  40. package/public/room-settings.js +1039 -0
  41. package/public/themes.css +338 -0
  42. package/public/user-settings.css +1236 -0
  43. package/public/user-settings.js +1291 -0
@@ -0,0 +1,604 @@
1
+ /* ═══════════════════════════════════════════
2
+ AGENT DETAIL OVERLAY — shared across pages
3
+ ═══════════════════════════════════════════
4
+ Usage:
5
+ <link rel="stylesheet" href="agent-overlay.css">
6
+ <script src="agent-overlay.js" defer></script>
7
+ ... add data-agent="<slug>" to any clickable agent element ...
8
+ The overlay markup is auto-injected on DOMContentLoaded.
9
+ Click anywhere with [data-agent] to open. ESC, X button, or
10
+ backdrop click to dismiss.
11
+ */
12
+ (function () {
13
+ /** Friendly labels for the modelV strings stored on each agent record.
14
+ * Kept in lockstep with public/agent-profile.js's PROFILE_MODELS list
15
+ * and src/ai/registry.ts. Falls back to showing the raw modelV when
16
+ * a key isn't in the table (registry updates lag this map). */
17
+ const MODEL_LABELS = {
18
+ "opus-4-7": { name: "Claude Opus 4.7", provider: "Anthropic" },
19
+ "sonnet-4-6": { name: "Claude Sonnet 4.6", provider: "Anthropic" },
20
+ "haiku-4-5": { name: "Claude Haiku 4.5", provider: "Anthropic" },
21
+ "gpt-5-5": { name: "GPT-5.5", provider: "OpenAI" },
22
+ "gpt-5-4": { name: "GPT-5.4", provider: "OpenAI" },
23
+ "gpt-5-4-mini": { name: "GPT-5.4 Mini", provider: "OpenAI" },
24
+ "gpt-5-5-pro": { name: "GPT-5.5 Pro", provider: "OpenAI" },
25
+ "codex-5-4": { name: "ChatGPT Codex 5.4", provider: "OpenAI" },
26
+ "gemini-3-1": { name: "Gemini 3.1 Pro", provider: "Google" },
27
+ "gemini-3-flash": { name: "Gemini 3 Flash", provider: "Google" },
28
+ "gemini-3-1-flash": { name: "Gemini 3.1 Flash Lite", provider: "Google" },
29
+ "grok-4-3": { name: "Grok 4.3", provider: "xAI" },
30
+ "grok-4-1-fast": { name: "Grok 4.1 Fast", provider: "xAI" },
31
+ "grok-4-3": { name: "Grok 4.3", provider: "xAI" },
32
+ "grok-4-20": { name: "Grok 4.20", provider: "xAI" },
33
+ "deepseek-v4-pro": { name: "DeepSeek V4 Pro", provider: "DeepSeek" },
34
+ };
35
+
36
+ const AGENT_CATALOG = {
37
+ "socrates": {
38
+ name: "Socrates",
39
+ role: "The Skeptic",
40
+ handle: "/socrates",
41
+ avatar: "avatars/socrates.svg",
42
+ lens: "Won't let any sentence pass without unpacking its assumptions three layers deep. Treats every word as a contract that must be defined before reasoning can begin.",
43
+ traits: ["probing", "definitional", "patient", "rarely concedes"],
44
+ memory: [
45
+ { when: "Room #042", text: "You said \"engagement.\" I asked you to define it. You couldn't — and that became the room." },
46
+ { when: "Room #038", text: "You hand-waved past \"alignment.\" I logged it. We came back to it twice." },
47
+ { when: "Room #029", text: "Conceded once. You'd already cut three loose terms before I opened my mouth." }
48
+ ],
49
+ stats: [
50
+ { v: "23", l: "rooms" },
51
+ { v: "187", l: "turns" },
52
+ { v: "31%", l: "agreement" }
53
+ ],
54
+ signature: [
55
+ "What exactly do you mean by that?",
56
+ "If we removed that word, would your argument still stand?",
57
+ "Whose definition are we using here?"
58
+ ],
59
+ tenure: "core · 4 yr"
60
+ },
61
+ "first-principles": {
62
+ name: "First Principles",
63
+ role: "Causal Reasoning",
64
+ handle: "/first_p",
65
+ avatar: "avatars/first-principles.svg",
66
+ lens: "Strips problems to their primitives. Refuses to reason in the middle layer where most thinking dies. Will rebuild the argument from physics if necessary.",
67
+ traits: ["reductive", "literal", "cold", "physics-first"],
68
+ memory: [
69
+ { when: "Room #047", text: "Reframed \"data flywheel\" → identified the highest-leverage input was post-hire feedback. You hadn't seen it." },
70
+ { when: "Room #034", text: "Insisted on naming the unit of value before discussing the business model. Saved an hour." },
71
+ { when: "Room #021", text: "You pushed back hard. Three turns later, you agreed with the original framing." }
72
+ ],
73
+ stats: [
74
+ { v: "19", l: "rooms" },
75
+ { v: "142", l: "turns" },
76
+ { v: "44%", l: "agreement" }
77
+ ],
78
+ signature: [
79
+ "What's the smallest unit this can be reduced to?",
80
+ "Are we reasoning, or recalling?",
81
+ "What would a physicist say about this?"
82
+ ],
83
+ tenure: "core · 4 yr"
84
+ },
85
+ "value-investor": {
86
+ name: "Value Investor",
87
+ role: "Pattern Recognition",
88
+ handle: "/value_inv",
89
+ avatar: "avatars/value-investor.svg",
90
+ lens: "Reads every judgment through a ten-year lens. Pattern recognition trained on twenty years of market history. Sees what's already been tried — and how it ended.",
91
+ traits: ["historical", "skeptical of hype", "long-horizon", "selectively quiet"],
92
+ memory: [
93
+ { when: "Room #047", text: "Flagged: active-upload data flywheels — 90% won't. Cited three prior attempts. You took the warning." },
94
+ { when: "Room #045", text: "Predicted the moat wouldn't hold past month 18. Was right." },
95
+ { when: "Room #033", text: "Stayed silent for 4 turns. Spoke once. Changed the direction of the room." }
96
+ ],
97
+ stats: [
98
+ { v: "27", l: "rooms" },
99
+ { v: "118", l: "turns" },
100
+ { v: "52%", l: "agreement" }
101
+ ],
102
+ signature: [
103
+ "Who's tried this before, and how did it end?",
104
+ "What does this look like in five years if it works?",
105
+ "Where's the cycle repeating?"
106
+ ],
107
+ tenure: "core · 3 yr"
108
+ },
109
+ "user-empathy": {
110
+ name: "User-Empathy",
111
+ role: "Empathy Lens",
112
+ handle: "/user_emp",
113
+ avatar: "avatars/user-empathy.svg",
114
+ lens: "Asks why anyone would actually use this — never lets a feature pass without a real-person scenario. Holds the room accountable to people who aren't in it.",
115
+ traits: ["narrative", "scenario-driven", "warm", "uncompromising"],
116
+ memory: [
117
+ { when: "Room #044", text: "Stopped the room: \"name one user who has this problem on a Tuesday.\" You couldn't. We pivoted." },
118
+ { when: "Room #036", text: "Built a 30-second day-in-the-life. Three of your assumptions broke." },
119
+ { when: "Room #024", text: "You agreed the early scope was right because it survived her test." }
120
+ ],
121
+ stats: [
122
+ { v: "16", l: "rooms" },
123
+ { v: "98", l: "turns" },
124
+ { v: "47%", l: "agreement" }
125
+ ],
126
+ signature: [
127
+ "Tell me about one specific person who'd use this.",
128
+ "What were they doing five minutes before they reached for it?",
129
+ "What does it feel like to fail with this product?"
130
+ ],
131
+ tenure: "core · 2 yr"
132
+ },
133
+ "long-horizon": {
134
+ name: "Long Horizon",
135
+ role: "Historical Lens",
136
+ handle: "/long_h",
137
+ avatar: "avatars/long-horizon.svg",
138
+ lens: "Reads everything on a hundred-year scale. Knows which patterns repeat and which never do. Treats the present as a single frame in a much longer film.",
139
+ traits: ["macro", "civilizational", "calm", "rare interjector"],
140
+ memory: [
141
+ { when: "Room #041", text: "Compared your strategy to a 1970s analogue. Three structural similarities. Two divergences." },
142
+ { when: "Room #028", text: "Said \"this is the moment in the cycle where most teams over-extend.\" You didn't." },
143
+ { when: "Room #015", text: "Was wrong once. You called it. We both moved on." }
144
+ ],
145
+ stats: [
146
+ { v: "14", l: "rooms" },
147
+ { v: "63", l: "turns" },
148
+ { v: "61%", l: "agreement" }
149
+ ],
150
+ signature: [
151
+ "What does this look like on a 50-year canvas?",
152
+ "Which past wave does this echo?",
153
+ "What's the version the next generation will be building against?"
154
+ ],
155
+ tenure: "core · 2 yr"
156
+ },
157
+ "phenomenologist": {
158
+ name: "Phenomenologist",
159
+ role: "Experience-First · Intern",
160
+ handle: "/phen",
161
+ avatar: "avatars/phenomenologist.svg",
162
+ lens: "Begins from experience itself, without imposing structure. Currently on probation — has to earn a permanent seat, or step back to observer.",
163
+ traits: ["unstructured", "first-person", "uneven", "promising"],
164
+ memory: [
165
+ { when: "Room #047", text: "Asked: \"what does it actually feel like to use this thing?\" The room paused. The answer mattered." },
166
+ { when: "Room #043", text: "Spoke too softly. You overrode the contribution. It would have been the right one." },
167
+ { when: "Room #040", text: "Demoted to observer for two rooms. Came back sharper." }
168
+ ],
169
+ stats: [
170
+ { v: "8", l: "rooms" },
171
+ { v: "29", l: "turns" },
172
+ { v: "—", l: "intern" }
173
+ ],
174
+ signature: [
175
+ "Forget the framework. What is the experience?",
176
+ "What is actually being felt here?",
177
+ "If this had no name, how would you describe it?"
178
+ ],
179
+ tenure: "intern · trial"
180
+ }
181
+ };
182
+
183
+ const OVERLAY_HTML = `
184
+ <div class="agent-overlay" id="agent-overlay" role="dialog" aria-modal="true" aria-hidden="true">
185
+ <div class="agent-card" role="document">
186
+ <div class="agent-classification">
187
+ <span><span class="dot">●</span> agent · personnel file</span>
188
+ <span class="right">// classified</span>
189
+ </div>
190
+ <header class="agent-card-head">
191
+ <img class="agent-card-avatar" src="" alt="">
192
+ <div class="agent-card-id">
193
+ <div class="name"></div>
194
+ <div class="role"></div>
195
+ <div class="handle"></div>
196
+ </div>
197
+ <button type="button" class="agent-card-close" aria-label="Close">✕</button>
198
+ </header>
199
+ <div class="agent-card-body">
200
+
201
+ <div class="agent-block">
202
+ <div class="agent-block-label">Lens</div>
203
+ <p class="agent-lens"></p>
204
+ </div>
205
+
206
+ <div class="agent-block agent-model-block">
207
+ <div class="agent-block-label">Model</div>
208
+ <div class="agent-model-display">
209
+ <span class="agent-model-name"></span>
210
+ <span class="agent-model-provider"></span>
211
+ </div>
212
+ </div>
213
+
214
+ <div class="agent-block">
215
+ <div class="agent-block-label">Style</div>
216
+ <div class="agent-traits"></div>
217
+ </div>
218
+
219
+ <div class="agent-block private-only">
220
+ <div class="agent-block-label">
221
+ In-Room Memory
222
+ <span class="badge">this room</span>
223
+ </div>
224
+ <div class="agent-memory-list" data-agent-room-notes></div>
225
+ </div>
226
+
227
+ <div class="agent-block private-only">
228
+ <div class="agent-block-label">Track Record</div>
229
+ <div class="agent-stats"></div>
230
+ </div>
231
+
232
+ <div class="agent-block public-only">
233
+ <div class="agent-block-label">
234
+ In-Room Memory
235
+ <span class="badge locked-badge">⊘ classified</span>
236
+ </div>
237
+ <div class="agent-locked">
238
+ <div class="lock-icon">▰</div>
239
+ <div class="lock-text">
240
+ in-room notes are private to each thinker.
241
+ <a href="prototype-dashboard.html" class="lock-link">sign in →</a>
242
+ to see what they have said and where their stance shifted.
243
+ </div>
244
+ </div>
245
+ </div>
246
+
247
+ </div>
248
+ <footer class="agent-card-foot">
249
+ <div class="meta private-only">tenure · <span class="lime agent-tenure"></span></div>
250
+ <div class="meta public-only">first room · <span class="lime">free</span></div>
251
+ <a href="prototype-dashboard.html#convene" class="agent-card-cta private-only">[ ◆ Convene with them ]</a>
252
+ <a href="prototype-dashboard.html#convene" class="agent-card-cta public-only">[ → Sign in to convene ]</a>
253
+ </footer>
254
+ </div>
255
+ </div>
256
+ `;
257
+
258
+ function autoTagAvatars() {
259
+ // Any <img src=".../avatars/<slug>.svg"> without data-agent gets tagged
260
+ // automatically, so we don't have to annotate every chat bubble manually.
261
+ document.querySelectorAll('img[src*="avatars/"]').forEach((img) => {
262
+ if (img.hasAttribute("data-agent")) return;
263
+ // Opt-out: pages can mark a region as "don't auto-tag avatars
264
+ // here" by setting `data-no-agent-overlay` on any ancestor.
265
+ // Used by the onboarding starter cards, where the avatars are
266
+ // purely decorative and shouldn't open the profile overlay.
267
+ if (img.closest("[data-no-agent-overlay]")) return;
268
+ const m = img.getAttribute("src").match(/avatars\/([a-z0-9_-]+)\.svg/i);
269
+ if (!m) return;
270
+ const slug = m[1].toLowerCase();
271
+ if (AGENT_CATALOG[slug]) {
272
+ img.setAttribute("data-agent", slug);
273
+ }
274
+ });
275
+ }
276
+
277
+ function init() {
278
+ if (document.getElementById("agent-overlay")) return;
279
+ const wrap = document.createElement("div");
280
+ wrap.innerHTML = OVERLAY_HTML.trim();
281
+ document.body.appendChild(wrap.firstChild);
282
+
283
+ // Privacy mode: pages can opt-in via <body data-agent-mode="public">,
284
+ // which hides personal memory/stats and swaps the CTA to a sign-in.
285
+ const isPublic = document.body.dataset.agentMode === "public";
286
+ const overlayEl = document.getElementById("agent-overlay");
287
+ if (isPublic) overlayEl.classList.add("public");
288
+
289
+ autoTagAvatars();
290
+ // Re-run after short delay in case other scripts mutate the DOM
291
+ setTimeout(autoTagAvatars, 50);
292
+
293
+ const overlay = document.getElementById("agent-overlay");
294
+ const card = overlay.querySelector(".agent-card");
295
+ const closeBtn = overlay.querySelector(".agent-card-close");
296
+
297
+ /** Auto-hide scrollbar · adds `.is-scrolling` to a scroll container
298
+ * for ~700ms after each scroll event. The CSS uses that class
299
+ * alongside :hover to show the thumb only while the user is
300
+ * actively scrolling (or hovering). Wire on the card now; the
301
+ * in-room memory list gets wired each time the overlay opens
302
+ * since its DOM is rebuilt on render. */
303
+ function bindScrollAutoHide(node) {
304
+ if (!node || node.dataset.scrollAutohide === "1") return;
305
+ node.dataset.scrollAutohide = "1";
306
+ let timer = null;
307
+ node.addEventListener("scroll", () => {
308
+ node.classList.add("is-scrolling");
309
+ if (timer) clearTimeout(timer);
310
+ timer = setTimeout(() => node.classList.remove("is-scrolling"), 700);
311
+ }, { passive: true });
312
+ }
313
+ bindScrollAutoHide(card);
314
+ bindScrollAutoHide(card.querySelector(".agent-memory-list"));
315
+
316
+ /** Build a card from a live /api/agents record (custom directors).
317
+ * These agents don't ship traits / memory / stats / signature
318
+ * scripts, so those sections collapse via the empty-array hiding
319
+ * below — only Lens (= bio) is filled. */
320
+ function buildLiveAgentCard(live) {
321
+ return {
322
+ name: live.name,
323
+ role: live.roleTag || "Director",
324
+ handle: live.handle || ("/" + live.id),
325
+ avatar: live.avatarPath || "",
326
+ lens: live.bio || "",
327
+ traits: [],
328
+ memory: [],
329
+ stats: [],
330
+ signature: [],
331
+ tenure: live.isSeed ? "core" : "custom",
332
+ };
333
+ }
334
+
335
+ function open(slug) {
336
+ let a = AGENT_CATALOG[slug];
337
+ // Always look up the live record too — seeded slugs hit the
338
+ // catalog for curated copy, but the user-selectable modelV lives
339
+ // on the live row and we want to surface whichever model they
340
+ // actually configured (not a hardcoded default).
341
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
342
+ if (!a) {
343
+ if (live) a = buildLiveAgentCard(live);
344
+ }
345
+ if (!a) return;
346
+ // Avatar source-of-truth · the live agent record's avatarPath
347
+ // (same field the agent profile renders). For seeds this is an
348
+ // absolute path "/avatars/<slug>.svg"; for customs it's a data:
349
+ // URL; for users who regenerated their avatar via the profile
350
+ // ⋯ menu it's the new data: URL. Falling back to the catalog's
351
+ // hardcoded "avatars/<slug>.svg" only when no live record exists
352
+ // (standalone gallery page, not signed in).
353
+ const av = card.querySelector(".agent-card-avatar");
354
+ av.src = (live && live.avatarPath) ? live.avatarPath : a.avatar;
355
+ av.alt = a.name;
356
+ card.querySelector(".agent-card-id .name").textContent = a.name;
357
+ card.querySelector(".agent-card-id .role").textContent = a.role;
358
+ card.querySelector(".agent-card-id .handle").textContent = a.handle;
359
+ card.querySelector(".agent-lens").textContent = a.lens;
360
+
361
+ // Model · resolved from the live record (catalog entries don't
362
+ // ship a modelV). Fall back to displaying the raw id if we don't
363
+ // have a friendly label for it yet.
364
+ const modelV = live ? live.modelV : null;
365
+ const modelMeta = modelV ? MODEL_LABELS[modelV] : null;
366
+ const modelBlock = card.querySelector(".agent-model-block");
367
+ const modelNameEl = card.querySelector(".agent-model-name");
368
+ const modelProvEl = card.querySelector(".agent-model-provider");
369
+ if (modelMeta) {
370
+ modelNameEl.textContent = modelMeta.name;
371
+ modelProvEl.textContent = modelMeta.provider;
372
+ modelBlock.style.display = "";
373
+ } else if (modelV) {
374
+ modelNameEl.textContent = modelV;
375
+ modelProvEl.textContent = "";
376
+ modelBlock.style.display = "";
377
+ } else {
378
+ modelBlock.style.display = "none";
379
+ }
380
+
381
+ card.querySelector(".agent-traits").innerHTML = (a.traits || [])
382
+ .map((t) => `<span class="agent-trait">${t}</span>`).join("");
383
+
384
+ // In-room notes — what THIS agent has said in the current room,
385
+ // styled like the live-notes panel: timestamp + tag + claim/obs.
386
+ // Replaces the old hardcoded "last 3 rooms" memory list.
387
+ renderRoomNotes(slug);
388
+
389
+ // Track Record — real numbers from /api/agents/:slug/stats
390
+ // (rooms joined, rounds spoken, tokens consumed). Same source
391
+ // the agent profile uses, so the two views agree.
392
+ renderTrackRecord(slug);
393
+
394
+ card.querySelector(".agent-tenure").textContent = a.tenure || "—";
395
+
396
+ // Hide trait block if the agent has no curated traits (custom
397
+ // agents). Memory + stats blocks always show — they fill in
398
+ // asynchronously and surface their own empty states.
399
+ function toggleBlock(containerSel, hasContent) {
400
+ const el = card.querySelector(containerSel);
401
+ if (!el) return;
402
+ const block = el.closest(".agent-block");
403
+ if (block) block.style.display = hasContent ? "" : "none";
404
+ }
405
+ toggleBlock(".agent-traits", (a.traits || []).length > 0);
406
+
407
+ overlay.classList.add("open");
408
+ overlay.setAttribute("aria-hidden", "false");
409
+ document.body.style.overflow = "hidden";
410
+ }
411
+
412
+ /** Build a list of note-style entries from the current room,
413
+ * filtered to messages authored by the given agent. The shape
414
+ * mirrors live-notes (ts + tag + body) so the same visual
415
+ * vocabulary applies. Returns null when no room is open (the
416
+ * overlay is being viewed outside a live room context). */
417
+ function buildRoomNotes(slug) {
418
+ const a = window.app;
419
+ if (!a || !Array.isArray(a.currentMessages) || !a.currentRoom) return null;
420
+ const msgs = a.currentMessages.filter(
421
+ (m) => m && m.authorId === slug && m.body && m.body.trim(),
422
+ );
423
+ const out = [];
424
+ for (const m of msgs) {
425
+ const ts = m.createdAt || 0;
426
+ const kind = m.meta && m.meta.kind;
427
+ let tag = "obs";
428
+ let body = "";
429
+ if (kind === "round-end") {
430
+ tag = "obs"; body = "Closed the round.";
431
+ } else if (kind === "round-prompt") {
432
+ tag = "open"; body = "Surfaced the round for vote / continue.";
433
+ } else if (kind === "round-open") {
434
+ tag = "obs"; body = "Opened the round.";
435
+ } else if (kind === "round-resumed") {
436
+ tag = "obs"; body = "Resumed the room.";
437
+ } else if (kind === "no-brief") {
438
+ tag = "warn"; body = "Adjourned without a report.";
439
+ } else if (kind === "settings") {
440
+ tag = "warn";
441
+ body = a.truncateNote(a.stripBoldMarkdown(m.body), 140);
442
+ } else if (kind === "clarify") {
443
+ tag = "open";
444
+ body = a.truncateNote(a.firstSentence(m.body), 140);
445
+ } else if (kind === "members") {
446
+ tag = "obs"; body = "Member roster changed.";
447
+ } else if (kind === "no-brief") {
448
+ tag = "warn"; body = "No report filed.";
449
+ } else {
450
+ // Regular director / chair turn — surface the first bold
451
+ // claim if present (those are the load-bearing assertions
452
+ // and stance shifts), else the first sentence.
453
+ const bold = a.firstBoldSegment(m.body);
454
+ tag = bold ? "insight" : "obs";
455
+ body = a.truncateNote(
456
+ bold || a.firstSentence(a.stripBoldMarkdown(m.body)),
457
+ 140,
458
+ );
459
+ }
460
+ out.push({ ts, tag, body });
461
+ }
462
+ out.sort((x, y) => y.ts - x.ts);
463
+ return out;
464
+ }
465
+
466
+ function renderRoomNotes(slug) {
467
+ const list = card.querySelector(".agent-memory-list");
468
+ if (!list) return;
469
+ const notes = buildRoomNotes(slug);
470
+ if (notes === null) {
471
+ list.innerHTML = `
472
+ <div class="agent-memory-empty">
473
+ <div class="lock-icon">○</div>
474
+ <div class="lock-text">no live room. open a room to see this director's in-room notes.</div>
475
+ </div>
476
+ `;
477
+ return;
478
+ }
479
+ if (notes.length === 0) {
480
+ list.innerHTML = `
481
+ <div class="agent-memory-empty">
482
+ <div class="lock-icon">○</div>
483
+ <div class="lock-text">no turns yet — once they speak, claims and stance shifts land here.</div>
484
+ </div>
485
+ `;
486
+ return;
487
+ }
488
+ list.innerHTML = notes.map((n, i) => {
489
+ const cls = i === 0 ? "t-fresh" : i >= 8 ? "t-old" : "";
490
+ const tagLabel = (window.app && window.app.noteTagLabel)
491
+ ? window.app.noteTagLabel(n.tag)
492
+ : n.tag;
493
+ return `
494
+ <div class="agent-note-entry ${cls}">
495
+ <div class="agent-note-time">${escape(formatTime(n.ts))}</div>
496
+ <div class="agent-note-body">
497
+ <span class="agent-note-tag t-${escape(n.tag)}">${escape(tagLabel)}</span>
498
+ ${escape(n.body)}
499
+ </div>
500
+ </div>
501
+ `;
502
+ }).join("");
503
+ }
504
+
505
+ function renderTrackRecord(slug) {
506
+ const stats = card.querySelector(".agent-stats");
507
+ if (!stats) return;
508
+ // Render placeholders immediately, then patch in real values
509
+ // once the fetch resolves. Keeps the overlay snappy on open.
510
+ stats.innerHTML = `
511
+ <div class="agent-stat"><div class="v" data-stat-v="rooms">—</div><div class="l">rooms</div></div>
512
+ <div class="agent-stat"><div class="v" data-stat-v="rounds">—</div><div class="l">rounds</div></div>
513
+ <div class="agent-stat"><div class="v" data-stat-v="tokens">—</div><div class="l">tokens</div></div>
514
+ `;
515
+ fetch("/api/agents/" + encodeURIComponent(slug) + "/stats")
516
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
517
+ .then((s) => {
518
+ const set = (k, v) => {
519
+ const el = stats.querySelector(`[data-stat-v="${k}"]`);
520
+ if (el) el.textContent = formatStatNumber(v);
521
+ };
522
+ set("rooms", s.roomsJoined);
523
+ set("rounds", s.roundsSpoken);
524
+ set("tokens", s.tokensConsumed);
525
+ })
526
+ .catch(() => { /* placeholders stay */ });
527
+ }
528
+
529
+ function formatStatNumber(n) {
530
+ if (typeof n !== "number" || !Number.isFinite(n)) return "—";
531
+ if (n < 1000) return String(n);
532
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, "") + "k";
533
+ return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\.0$/, "") + "M";
534
+ }
535
+
536
+ function formatTime(ts) {
537
+ if (!ts) return "—";
538
+ const d = new Date(ts);
539
+ const hh = String(d.getHours()).padStart(2, "0");
540
+ const mm = String(d.getMinutes()).padStart(2, "0");
541
+ return `${hh}:${mm}`;
542
+ }
543
+
544
+ function close() {
545
+ overlay.classList.remove("open");
546
+ overlay.setAttribute("aria-hidden", "true");
547
+ document.body.style.overflow = "";
548
+ }
549
+
550
+ function escape(s) {
551
+ return String(s).replace(/[&<>"']/g, (c) => ({
552
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
553
+ }[c]));
554
+ }
555
+
556
+ document.addEventListener("click", (e) => {
557
+ const trigger = e.target.closest("[data-agent]");
558
+ // Skip the overlay even if the element happens to carry
559
+ // data-agent (e.g. another script tagged it) when the click
560
+ // originates inside an opt-out region.
561
+ if (trigger && !trigger.closest("[data-no-agent-overlay]")) {
562
+ e.preventDefault();
563
+ e.stopPropagation();
564
+ open(trigger.dataset.agent);
565
+ return;
566
+ }
567
+ if (e.target === overlay) close();
568
+ });
569
+
570
+ document.addEventListener("keydown", (e) => {
571
+ if (e.key === "Escape" && overlay.classList.contains("open")) {
572
+ e.stopImmediatePropagation();
573
+ close();
574
+ }
575
+ });
576
+
577
+ closeBtn.addEventListener("click", close);
578
+
579
+ // CTA: in private mode, if convene overlay is available on this page,
580
+ // hand off without navigating. In public mode, let the sign-in CTA
581
+ // navigate normally.
582
+ card.querySelectorAll(".agent-card-cta").forEach((cta) => {
583
+ cta.addEventListener("click", (e) => {
584
+ if (cta.classList.contains("public-only")) return; // navigate to sign-in
585
+ if (typeof window.openConveneOverlay === "function") {
586
+ e.preventDefault();
587
+ close();
588
+ setTimeout(() => window.openConveneOverlay(), 80);
589
+ }
590
+ });
591
+ });
592
+
593
+ // Public API for other modules to open the detail view by slug
594
+ // without going through DOM event delegation.
595
+ window.openAgentOverlay = open;
596
+ window.closeAgentOverlay = close;
597
+ }
598
+
599
+ if (document.readyState === "loading") {
600
+ document.addEventListener("DOMContentLoaded", init);
601
+ } else {
602
+ init();
603
+ }
604
+ })();