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,675 @@
1
+ /* ═══════════════════════════════════════════
2
+ NEW AGENT OVERLAY
3
+ ═══════════════════════════════════════════
4
+ Public API: window.openNewAgent()
5
+ Triggered by clicks on [data-new-agent] (the sidebar's
6
+ `New agent` button in the Agents tab).
7
+ */
8
+ (function () {
9
+ const MODEL_GROUPS = [
10
+ { provider: "anthropic", models: [
11
+ { v: "sonnet-4-6", name: "Sonnet 4.6", deck: "balanced · default" },
12
+ { v: "opus-4-7", name: "Opus 4.7", deck: "deep reasoning" },
13
+ { v: "haiku-4-5", name: "Haiku 4.5", deck: "fast · low-cost" }
14
+ ]},
15
+ { provider: "openai", models: [
16
+ { v: "gpt-5-5", name: "GPT-5.5", deck: "flagship · 1M ctx" },
17
+ { v: "gpt-5-4", name: "GPT-5.4", deck: "general · 1M ctx" },
18
+ { v: "gpt-5-4-mini", name: "GPT-5.4 Mini", deck: "fast · 400k ctx" }
19
+ ]},
20
+ { provider: "google", models: [
21
+ { v: "gemini-3-1", name: "Gemini 3.1 Pro", deck: "flagship · 1M ctx" },
22
+ { v: "gemini-3-flash", name: "Gemini 3 Flash", deck: "frontier flash · 1M ctx" },
23
+ { v: "gemini-3-1-flash", name: "Gemini 3.1 Flash Lite", deck: "fast · 1M ctx" }
24
+ ]},
25
+ { provider: "xai", models: [
26
+ { v: "grok-4-3", name: "Grok 4.3", deck: "flagship · 1M ctx" },
27
+ { v: "grok-4-1-fast", name: "Grok 4.1 Fast", deck: "fast · 256k ctx" }
28
+ ]}
29
+ ];
30
+ const ALL_MODELS = MODEL_GROUPS.flatMap((g) => g.models);
31
+
32
+ /* ─── Skill catalog ─────────────────────────────────
33
+ Installable abilities — slot-grid analog of an RPG
34
+ equipment ring. v1 is visual only (the LLM adapter
35
+ doesn't yet wire tool-use), but the UI shows the
36
+ vocabulary so users can shape the director's
37
+ intended capability surface. */
38
+ const SKILL_CATALOG = [
39
+ { v: "search", icon: "⌕", name: "Web Search", deck: "real-time fetch" },
40
+ { v: "pdf", icon: "▤", name: "PDF Parse", deck: "extract from PDFs" },
41
+ { v: "shell", icon: "⌨", name: "Shell", deck: "execute commands" },
42
+ { v: "browser", icon: "◍", name: "Browser", deck: "navigate the web" },
43
+ { v: "code", icon: "▶", name: "Code Exec", deck: "run python / node" },
44
+ { v: "tables", icon: "▦", name: "Tables", deck: "csv · xlsx" },
45
+ { v: "memory", icon: "✎", name: "Memory", deck: "long-term notes" },
46
+ { v: "urls", icon: "↗", name: "URL Fetch", deck: "grab pages" },
47
+ ];
48
+ const SKILL_SLOTS = 8;
49
+
50
+ function escape(s) {
51
+ return String(s).replace(/[&<>"']/g, (c) => ({
52
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
53
+ }[c]));
54
+ }
55
+
56
+ /* ───── 8-bit avatar generator ─────
57
+ Pure function: same seed → same avatar.
58
+ placeholder=true returns a neutral grey silhouette.
59
+ */
60
+ const PALETTES = {
61
+ hair: ["#7B4F2A","#3A2418","#D4A347","#5A3D8F","#B53D3D","#1F1F1F","#8B7355","#A05A2C","#2D5532","#C46A2C"],
62
+ skin: ["#F4C99B","#E8B589","#D9A077","#C68863","#A86B47","#8B5A3C"],
63
+ shirt: ["#6FB572","#6A9B97","#B5706A","#B59E6A","#9B7BB5","#5470A8","#7B5A8A","#C46A2C"],
64
+ eye: ["#1A1A1A","#3D2817","#1F3A5E","#2D2618"]
65
+ };
66
+ const FACE_MASK = [
67
+ "........XXXXXXXX........",
68
+ "......XXXXXXXXXXXX......",
69
+ ".....XXXXXXXXXXXXXX.....",
70
+ "....XXXXXXXXXXXXXXXX....",
71
+ "....XXXXXXXXXXXXXXXX....",
72
+ "...XXXXXXXXXXXXXXXXXX...",
73
+ "...XXXXXXXXXXXXXXXXXX...",
74
+ "...XXXXXXXXXXXXXXXXXX...",
75
+ "...XXXXXXXXXXXXXXXXXX...",
76
+ "...XXXXXXXXXXXXXXXXXX...",
77
+ "...XXXXXXXXXXXXXXXXXX...",
78
+ "...XXXXXXXXXXXXXXXXXX...",
79
+ "...XXXXXXXXXXXXXXXXXX...",
80
+ "...XXXXXXXXXXXXXXXXXX...",
81
+ "...XXXXXXXXXXXXXXXXXX...",
82
+ "....XXXXXXXXXXXXXXXX....",
83
+ "....XXXXXXXXXXXXXXXX....",
84
+ ".....XXXXXXXXXXXXXX.....",
85
+ "......XXXXXXXXXXXX......"
86
+ ];
87
+ function makeRng(seed) {
88
+ let h = 2166136261 >>> 0;
89
+ const s = String(seed || "agent");
90
+ for (let i = 0; i < s.length; i++) {
91
+ h = (h ^ s.charCodeAt(i)) >>> 0;
92
+ h = Math.imul(h, 16777619) >>> 0;
93
+ }
94
+ let st = h || 1;
95
+ return () => {
96
+ st = (Math.imul(st, 1664525) + 1013904223) >>> 0;
97
+ return st / 4294967296;
98
+ };
99
+ }
100
+ function pickFrom(rng, arr) { return arr[Math.floor(rng() * arr.length)]; }
101
+ function shortHash(seed) {
102
+ const rng = makeRng(seed);
103
+ return [0,0,0,0].map(() => Math.floor(rng() * 16).toString(16)).join("");
104
+ }
105
+ // Avatar generation delegates to the shared AvatarSkill
106
+ // (see public/avatar-skill.js). One source of truth for the
107
+ // 8-bit pixel-art look used here, in user settings, and anywhere
108
+ // else that wants a director-style avatar.
109
+ function generateAvatar(seed, opts) {
110
+ return window.AvatarSkill.generate(seed, opts);
111
+ }
112
+
113
+ function modalHTML() {
114
+ // Slimmed form (per user brief): only name, bio, avatar, and
115
+ // instruction. Rules/skills/model/knowledge live in the agent
116
+ // profile after creation. Chrome aligns with the global overlay
117
+ // pattern (mirrors .convene-modal): backdrop blur, corner
118
+ // brackets, classification + head + body + foot.
119
+ return `
120
+ <div class="new-agent-overlay" id="new-agent-overlay" role="dialog" aria-modal="true" aria-hidden="true">
121
+ <div class="new-agent-modal" role="document">
122
+ <div class="na-classification">
123
+ <span><span class="dot">●</span> directors · new</span>
124
+ <span class="right">// shape the role</span>
125
+ </div>
126
+
127
+ <header class="na-head">
128
+ <div>
129
+ <div class="na-step-num">// new <span class="hl">director</span> · manual setup</div>
130
+ <div class="na-step-title">shape the role</div>
131
+ </div>
132
+ <button type="button" class="na-close" aria-label="Close">✕</button>
133
+ </header>
134
+
135
+ <div class="na-body">
136
+ <div class="na-stack">
137
+
138
+ <div class="na-avatar-block">
139
+ <div class="na-portrait">
140
+ <div data-na-avatar class="na-avatar-frame"></div>
141
+ </div>
142
+ <button type="button" class="na-avatar-regen" data-na-regen>
143
+ <span class="na-avatar-regen-mark">◆</span>
144
+ <span class="na-avatar-regen-label">generate 8-bit avatar</span>
145
+ </button>
146
+ <div class="na-avatar-vibe" data-na-vibe></div>
147
+ </div>
148
+
149
+ <div class="na-fields">
150
+
151
+ <div class="na-field">
152
+ <label class="na-field-label">
153
+ <span>Name</span>
154
+ <span class="na-field-meta"><span class="na-name-count">0</span>/32</span>
155
+ </label>
156
+ <div class="na-input-wrap na-name-wrap">
157
+ <input type="text" class="na-name-input" placeholder="Aurelia · The Long-Cycle Strategist" maxlength="32">
158
+ </div>
159
+ <div class="na-field-hint">handle: <span class="na-handle-preview">/new_agent</span></div>
160
+ </div>
161
+
162
+ <div class="na-field">
163
+ <label class="na-field-label">
164
+ <span>Intro</span>
165
+ <span class="na-field-meta"><span class="na-desc-count">0</span>/280</span>
166
+ </label>
167
+ <div class="na-textarea-wrap intro">
168
+ <textarea class="na-desc-input" placeholder="One or two sentences · how this director shows up in a room. Reads everything on a hundred-year scale. Knows which patterns repeat and which never do." maxlength="280"></textarea>
169
+ </div>
170
+ <div class="na-field-hint">becomes their public bio</div>
171
+ </div>
172
+
173
+ <div class="na-field">
174
+ <label class="na-field-label">
175
+ <span>Instruction</span>
176
+ <span class="na-field-meta"><span class="na-instr-count">0</span> chars · markdown</span>
177
+ </label>
178
+ <div class="na-textarea-wrap tall">
179
+ <textarea class="na-instr-input" spellcheck="false" placeholder="### Role
180
+ You are __, the room's __. Your job is to ___.
181
+
182
+ ### Voice
183
+ Demand ___. Don't ___. Cite ___ when ___.
184
+
185
+ ### Boundaries
186
+ When the room ___, raise an objection."></textarea>
187
+ </div>
188
+ <div class="na-field-hint">applies to every room they join · skills, rules and model are configured later in the profile</div>
189
+ </div>
190
+
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <footer class="na-foot">
196
+ <div class="na-foot-meta">configure skills · rules · model after creation</div>
197
+ <div class="na-foot-actions">
198
+ <button type="button" class="na-cancel">cancel</button>
199
+ <button type="button" class="na-create" disabled>
200
+ <span class="na-create-mark">◆</span>
201
+ <span>create director</span>
202
+ </button>
203
+ </div>
204
+ </footer>
205
+
206
+ </div>
207
+ </div>
208
+ `;
209
+ }
210
+
211
+ let overlay, modal;
212
+ // Synthetic knowledge state (visual only — no real upload).
213
+ let knowState = [];
214
+ // Rules state — array of strings, max 5. Visual-only in v1; not yet
215
+ // submitted to the backend.
216
+ let rulesState = [];
217
+ const RULES_MAX = 5;
218
+ // Skills state — array of skill ids (e.g. "search","pdf"). Visual
219
+ // only in v1.
220
+ let skillsState = [];
221
+ // Avatar state: placeholder until user clicks regenerate.
222
+ let avatarState = { placeholder: true, seed: null, roll: 0 };
223
+
224
+ /* ─── Rules ───────────────────────────── */
225
+ function renderRules() {
226
+ const list = modal && modal.querySelector("[data-na-rules]");
227
+ const addBtn = modal && modal.querySelector("[data-na-rule-add]");
228
+ if (!list) return;
229
+ if (rulesState.length === 0) {
230
+ list.innerHTML = `<li class="na-rule-empty">no rules yet · directors will follow only their instruction</li>`;
231
+ } else {
232
+ list.innerHTML = rulesState.map((body, i) => `
233
+ <li class="na-rule" data-rule-idx="${i}">
234
+ <span class="na-rule-num">${i + 1}</span>
235
+ <input type="text" class="na-rule-input" placeholder="never preface · cite the load-bearing claim with **bold** · ..." maxlength="120" value="${escape(body)}">
236
+ <button type="button" class="na-rule-rm" data-na-rule-rm="${i}" title="Remove">✕</button>
237
+ </li>
238
+ `).join("");
239
+ }
240
+ if (addBtn) {
241
+ const atCap = rulesState.length >= RULES_MAX;
242
+ addBtn.disabled = atCap;
243
+ addBtn.classList.toggle("at-cap", atCap);
244
+ }
245
+ }
246
+ function addRule() {
247
+ if (rulesState.length >= RULES_MAX) return;
248
+ rulesState.push("");
249
+ renderRules();
250
+ // Focus the freshly-added input.
251
+ const inputs = modal.querySelectorAll(".na-rule-input");
252
+ const last = inputs[inputs.length - 1];
253
+ if (last) last.focus();
254
+ }
255
+ function removeRule(idx) {
256
+ if (idx < 0 || idx >= rulesState.length) return;
257
+ rulesState.splice(idx, 1);
258
+ renderRules();
259
+ }
260
+ function setRule(idx, body) {
261
+ if (idx < 0 || idx >= rulesState.length) return;
262
+ rulesState[idx] = body;
263
+ }
264
+
265
+ /* ─── Skills ──────────────────────────── */
266
+ function renderSkills() {
267
+ const grid = modal && modal.querySelector("[data-na-skill-grid]");
268
+ const countEl = modal && modal.querySelector(".na-skill-count");
269
+ if (!grid) return;
270
+ const slots = [];
271
+ for (let i = 0; i < SKILL_SLOTS; i++) {
272
+ const v = skillsState[i];
273
+ const s = v ? SKILL_CATALOG.find((x) => x.v === v) : null;
274
+ if (s) {
275
+ slots.push(`
276
+ <button type="button" class="na-skill-slot filled" data-na-skill-slot="${i}" title="${escape(s.name)} · click to remove">
277
+ <span class="na-skill-icon">${escape(s.icon)}</span>
278
+ <span class="na-skill-name">${escape(s.name)}</span>
279
+ </button>
280
+ `);
281
+ } else {
282
+ slots.push(`
283
+ <button type="button" class="na-skill-slot empty" data-na-skill-slot="${i}" title="Install ability">
284
+ <span class="na-skill-icon">+</span>
285
+ <span class="na-skill-name">empty</span>
286
+ </button>
287
+ `);
288
+ }
289
+ }
290
+ grid.innerHTML = slots.join("");
291
+ if (countEl) countEl.textContent = String(skillsState.length);
292
+ }
293
+
294
+ /** Open a tiny inline picker beneath the slot — shows abilities the
295
+ * director doesn't already have. Click one to install it into the
296
+ * given slot (or the next empty if slot is null). */
297
+ function openSkillPicker(anchor, targetSlot) {
298
+ closeSkillPicker();
299
+ const installed = new Set(skillsState);
300
+ const available = SKILL_CATALOG.filter((s) => !installed.has(s.v));
301
+ if (available.length === 0) return;
302
+
303
+ const pop = document.createElement("div");
304
+ pop.className = "na-skill-picker";
305
+ pop.id = "na-skill-picker";
306
+ pop.innerHTML = available.map((s) => `
307
+ <button type="button" class="na-skill-pick" data-skill-pick="${escape(s.v)}">
308
+ <span class="na-skill-pick-icon">${escape(s.icon)}</span>
309
+ <span class="na-skill-pick-body">
310
+ <span class="na-skill-pick-name">${escape(s.name)}</span>
311
+ <span class="na-skill-pick-deck">${escape(s.deck)}</span>
312
+ </span>
313
+ </button>
314
+ `).join("");
315
+ document.body.appendChild(pop);
316
+
317
+ // Position below the anchor button.
318
+ const r = anchor.getBoundingClientRect();
319
+ const margin = 6;
320
+ pop.style.left = Math.max(margin, Math.min(r.left, window.innerWidth - 260 - margin)) + "px";
321
+ pop.style.top = (r.bottom + 4) + "px";
322
+
323
+ // Stash target slot index for the install handler.
324
+ pop.dataset.targetSlot = String(targetSlot ?? "");
325
+
326
+ // Dismiss on outside click.
327
+ setTimeout(() => {
328
+ const off = (e) => {
329
+ if (e.target.closest("#na-skill-picker")) return;
330
+ closeSkillPicker();
331
+ document.removeEventListener("click", off, true);
332
+ };
333
+ document.addEventListener("click", off, true);
334
+ }, 0);
335
+ }
336
+ function closeSkillPicker() {
337
+ const pop = document.getElementById("na-skill-picker");
338
+ if (pop) pop.remove();
339
+ }
340
+ function installSkill(slotIdx, skillV) {
341
+ if (!SKILL_CATALOG.some((s) => s.v === skillV)) return;
342
+ if (skillsState.includes(skillV)) return;
343
+ // If slotIdx is provided and that slot is empty, place there.
344
+ // Otherwise append to first available position.
345
+ if (slotIdx !== null && slotIdx >= 0 && slotIdx < SKILL_SLOTS && !skillsState[slotIdx]) {
346
+ skillsState[slotIdx] = skillV;
347
+ } else {
348
+ for (let i = 0; i < SKILL_SLOTS; i++) {
349
+ if (!skillsState[i]) { skillsState[i] = skillV; break; }
350
+ }
351
+ }
352
+ renderSkills();
353
+ }
354
+ function uninstallSkill(slotIdx) {
355
+ if (slotIdx < 0 || slotIdx >= SKILL_SLOTS) return;
356
+ skillsState[slotIdx] = undefined;
357
+ // Compact array so empty slots are at the end visually.
358
+ skillsState = skillsState.filter(Boolean);
359
+ renderSkills();
360
+ }
361
+
362
+ function getProviderStatus(provider) {
363
+ const keys = (typeof window.boardroomKeys === "function" ? window.boardroomKeys() : {}) || {};
364
+ if (keys[provider]) return { label: "direct", cls: "direct" };
365
+ if (keys.openrouter) return { label: "via openrouter", cls: "via" };
366
+ return { label: "no key", cls: "none" };
367
+ }
368
+
369
+ function refreshProviderStatus() {
370
+ if (!modal) return;
371
+ modal.querySelectorAll(".na-model-grp[data-provider]").forEach((grp) => {
372
+ const provider = grp.dataset.provider;
373
+ const s = getProviderStatus(provider);
374
+ const badge = grp.querySelector(".na-model-grp-status");
375
+ if (!badge) return;
376
+ badge.textContent = "· " + s.label;
377
+ badge.classList.remove("direct", "via", "none");
378
+ badge.classList.add(s.cls);
379
+ });
380
+ }
381
+
382
+ function open() {
383
+ if (!overlay) return;
384
+ // Reset form to a clean slate every time.
385
+ modal.querySelector(".na-name-input").value = "";
386
+ modal.querySelector(".na-desc-input").value = "";
387
+ modal.querySelector(".na-instr-input").value = "";
388
+ avatarState = { placeholder: true, seed: null, roll: 0 };
389
+ paintAvatar();
390
+ refreshAll();
391
+
392
+ overlay.classList.add("open");
393
+ overlay.setAttribute("aria-hidden", "false");
394
+ document.body.style.overflow = "hidden";
395
+ setTimeout(() => modal.querySelector(".na-name-input").focus(), 80);
396
+ }
397
+
398
+ function close() {
399
+ if (!overlay) return;
400
+ overlay.classList.remove("open");
401
+ overlay.setAttribute("aria-hidden", "true");
402
+ document.body.style.overflow = "";
403
+ }
404
+
405
+ function slugify(s) {
406
+ return String(s)
407
+ .trim()
408
+ .toLowerCase()
409
+ .replace(/[^a-z0-9_]+/g, "_")
410
+ .replace(/^_+|_+$/g, "")
411
+ .slice(0, 14) || "new_agent";
412
+ }
413
+
414
+ function activeModelInfo() {
415
+ const c = modal.querySelector(".na-model-opt.active");
416
+ const fallback = { name: "—", provider: "—", deck: "—" };
417
+ if (!c) return fallback;
418
+ const v = c.dataset.model;
419
+ const m = ALL_MODELS.find((x) => x.v === v);
420
+ if (!m) return fallback;
421
+ let prov = "—";
422
+ for (const g of MODEL_GROUPS) {
423
+ if (g.models.some((x) => x.v === v)) { prov = g.provider; break; }
424
+ }
425
+ return { name: m.name, provider: prov, deck: m.deck };
426
+ }
427
+
428
+ function paintAvatar() {
429
+ const frame = modal.querySelector("[data-na-avatar]");
430
+ const seedEl = modal.querySelector("[data-na-seed]");
431
+ const rollEl = modal.querySelector("[data-na-roll]");
432
+ if (!frame) return;
433
+ if (avatarState.placeholder) {
434
+ frame.classList.add("placeholder");
435
+ frame.innerHTML = generateAvatar("__placeholder__", { placeholder: true });
436
+ if (seedEl) seedEl.textContent = "—";
437
+ if (rollEl) rollEl.textContent = "";
438
+ } else {
439
+ frame.classList.remove("placeholder");
440
+ const seedKey = avatarState.seed + "::" + avatarState.roll;
441
+ frame.innerHTML = generateAvatar(seedKey);
442
+ if (seedEl) seedEl.textContent = shortHash(avatarState.seed);
443
+ if (rollEl) rollEl.textContent = " · #" + avatarState.roll;
444
+ }
445
+ }
446
+
447
+ function positionDropdown() {
448
+ if (!modal) return;
449
+ const select = modal.querySelector("[data-na-model-select]");
450
+ const trigger = modal.querySelector("[data-na-trigger]");
451
+ const dropdown = modal.querySelector("[data-na-dropdown]");
452
+ if (!select || !trigger || !dropdown) return;
453
+ if (select.dataset.open !== "true") return;
454
+
455
+ const rect = trigger.getBoundingClientRect();
456
+ const vh = window.innerHeight;
457
+ const vw = window.innerWidth;
458
+ const margin = 8;
459
+ const gap = 4;
460
+ const spaceBelow = vh - rect.bottom - margin;
461
+ const spaceAbove = rect.top - margin;
462
+
463
+ // Reset previous run
464
+ dropdown.style.top = "";
465
+ dropdown.style.bottom = "";
466
+ dropdown.style.maxHeight = "";
467
+
468
+ // Width matches the trigger; clamp to viewport horizontally
469
+ const left = Math.max(margin, Math.min(rect.left, vw - rect.width - margin));
470
+ dropdown.style.left = left + "px";
471
+ dropdown.style.width = rect.width + "px";
472
+
473
+ const minPreferred = 220;
474
+ const flipUp = spaceBelow < minPreferred && spaceAbove > spaceBelow;
475
+
476
+ if (flipUp) {
477
+ dropdown.style.bottom = (vh - rect.top + gap) + "px";
478
+ dropdown.style.maxHeight = Math.max(140, spaceAbove - gap) + "px";
479
+ select.dataset.flip = "up";
480
+ } else {
481
+ dropdown.style.top = (rect.bottom + gap) + "px";
482
+ dropdown.style.maxHeight = Math.max(140, spaceBelow - gap) + "px";
483
+ select.dataset.flip = "down";
484
+ }
485
+ }
486
+
487
+ /** Regenerate the avatar by asking the LLM for a "vibe seed" derived
488
+ * from the director's name + bio, then painting the SVG via the
489
+ * shared AvatarSkill. Falls back to a local random seed if the
490
+ * endpoint errors (no key, network, etc.) so the button always
491
+ * produces a fresh face. */
492
+ async function regenerateAvatar() {
493
+ const name = modal.querySelector(".na-name-input").value.trim();
494
+ const desc = modal.querySelector(".na-desc-input").value.trim();
495
+ const btn = modal.querySelector("[data-na-regen]");
496
+ const labelEl = btn?.querySelector(".na-avatar-regen-label");
497
+ const vibeEl = modal.querySelector("[data-na-vibe]");
498
+
499
+ avatarState.placeholder = false;
500
+ avatarState.roll = (avatarState.roll || 0) + 1;
501
+
502
+ // Without a name, just produce a random seed locally — no point
503
+ // burning an LLM call on an empty form.
504
+ if (!name) {
505
+ avatarState.seed = (window.AvatarSkill?.randomSeed?.() || ("anon|" + Date.now()));
506
+ if (vibeEl) vibeEl.textContent = "";
507
+ paintAvatar();
508
+ return;
509
+ }
510
+
511
+ if (btn) btn.disabled = true;
512
+ const originalLabel = labelEl?.textContent || "generate 8-bit avatar";
513
+ if (labelEl) labelEl.textContent = "thinking…";
514
+
515
+ try {
516
+ const res = await fetch("/api/avatar/generate", {
517
+ method: "POST",
518
+ headers: { "content-type": "application/json" },
519
+ body: JSON.stringify({ name, bio: desc }),
520
+ });
521
+ if (!res.ok) throw new Error("avatar gen failed");
522
+ const j = await res.json();
523
+ avatarState.seed = j.seed + "::" + avatarState.roll;
524
+ if (vibeEl) vibeEl.textContent = j.vibe || "";
525
+ } catch (_) {
526
+ avatarState.seed = (name + "|" + desc + "|" + avatarState.roll);
527
+ if (vibeEl) vibeEl.textContent = "";
528
+ } finally {
529
+ if (btn) btn.disabled = false;
530
+ if (labelEl) labelEl.textContent = originalLabel;
531
+ paintAvatar();
532
+ }
533
+ }
534
+
535
+ function refreshAll() {
536
+ const name = modal.querySelector(".na-name-input").value;
537
+ const desc = modal.querySelector(".na-desc-input").value;
538
+ const instr = modal.querySelector(".na-instr-input").value;
539
+
540
+ const handle = "/" + slugify(name);
541
+ modal.querySelector(".na-handle-preview").textContent = handle;
542
+ modal.querySelector(".na-name-count").textContent = name.length;
543
+ modal.querySelector(".na-desc-count").textContent = desc.length;
544
+ modal.querySelector(".na-instr-count").textContent = instr.length;
545
+
546
+ // Create button enabled when name + bio are present.
547
+ const create = modal.querySelector(".na-create");
548
+ const ready = name.trim().length >= 2 && desc.trim().length >= 8;
549
+ create.disabled = !ready;
550
+ }
551
+
552
+ function init() {
553
+ if (document.getElementById("new-agent-overlay")) return;
554
+ const wrap = document.createElement("div");
555
+ wrap.innerHTML = modalHTML().trim();
556
+ document.body.appendChild(wrap.firstChild);
557
+
558
+ overlay = document.getElementById("new-agent-overlay");
559
+ modal = overlay.querySelector(".new-agent-modal");
560
+
561
+ // Close
562
+ modal.querySelector(".na-close").addEventListener("click", close);
563
+ modal.querySelector(".na-cancel").addEventListener("click", close);
564
+ overlay.addEventListener("click", (e) => {
565
+ if (e.target === overlay) close();
566
+ });
567
+ document.addEventListener("keydown", (e) => {
568
+ if (e.key === "Escape" && overlay.classList.contains("open")) {
569
+ e.stopImmediatePropagation();
570
+ close();
571
+ }
572
+ });
573
+
574
+ // Triggers anywhere
575
+ document.addEventListener("click", (e) => {
576
+ if (e.target.closest("[data-new-agent]")) {
577
+ e.preventDefault();
578
+ open();
579
+ }
580
+ });
581
+
582
+ // Live updates
583
+ modal.querySelector(".na-name-input").addEventListener("input", refreshAll);
584
+ modal.querySelector(".na-desc-input").addEventListener("input", refreshAll);
585
+ modal.querySelector(".na-instr-input").addEventListener("input", refreshAll);
586
+
587
+ // Avatar regenerate
588
+ modal.querySelector("[data-na-regen]").addEventListener("click", (e) => {
589
+ e.preventDefault();
590
+ regenerateAvatar();
591
+ });
592
+
593
+ // Initial paint of placeholder
594
+ paintAvatar();
595
+
596
+ // Create — POST to /api/agents and refresh the sidebar's agents list.
597
+ modal.querySelector(".na-create").addEventListener("click", async () => {
598
+ const create = modal.querySelector(".na-create");
599
+ if (create.disabled) return;
600
+
601
+ const name = modal.querySelector(".na-name-input").value.trim();
602
+ const bio = modal.querySelector(".na-desc-input").value.trim();
603
+ const instruction = modal.querySelector(".na-instr-input").value.trim();
604
+ // Default model · resolved from the user's current key set via
605
+ // the shared /api/models cache (`defaultModelV` field). Without
606
+ // this, new agents were always born with `opus-4-7` even when
607
+ // the user only had a direct OpenAI key — the agent would then
608
+ // hit `NoKeyError` on every turn until the user manually
609
+ // changed its model. The cache may not have loaded yet (very
610
+ // first interaction); we fall back to `opus-4-7` only as a
611
+ // last resort and leave the runtime resolver to fix it up.
612
+ let modelV = "opus-4-7";
613
+ const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
614
+ if (cache && typeof cache.defaultModelV === "string" && cache.defaultModelV) {
615
+ modelV = cache.defaultModelV;
616
+ } else if (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0) {
617
+ modelV = cache.reachable[0].modelV;
618
+ }
619
+
620
+ // Avatar → data URL. If the user never clicked "regenerate", we
621
+ // build one off the form values now so the agent has a real face.
622
+ let avatarSeed = avatarState.seed;
623
+ let avatarRoll = avatarState.roll || 1;
624
+ if (avatarState.placeholder) {
625
+ avatarSeed = (name + "|" + bio) || "anon";
626
+ avatarRoll = 1;
627
+ }
628
+ const svg = generateAvatar(avatarSeed + "::" + avatarRoll);
629
+ const avatarPath = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
630
+
631
+ // Lock the button while the request is in flight.
632
+ const orig = create.textContent;
633
+ create.disabled = true;
634
+ create.textContent = "[ creating… ]";
635
+
636
+ try {
637
+ const res = await fetch("/api/agents", {
638
+ method: "POST",
639
+ headers: { "content-type": "application/json" },
640
+ body: JSON.stringify({ name, bio, instruction, modelV, avatarPath }),
641
+ });
642
+ if (!res.ok) {
643
+ const err = await res.json().catch(() => ({}));
644
+ throw new Error(err.error || res.statusText);
645
+ }
646
+ const created = await res.json();
647
+ // Refresh app.agents so the sidebar + agentsById register the
648
+ // new director immediately. Falls back gracefully if app isn't
649
+ // booted (shouldn't happen in normal flows).
650
+ if (window.app && typeof window.app.refreshAgents === "function") {
651
+ await window.app.refreshAgents();
652
+ }
653
+ // Hand the new agent's id to anyone watching for the event.
654
+ try {
655
+ window.dispatchEvent(new CustomEvent("boardroom:agent-created", { detail: created }));
656
+ } catch (_) { /* */ }
657
+ close();
658
+ } catch (e) {
659
+ alert("Couldn't create the director: " + (e && e.message ? e.message : e));
660
+ create.disabled = false;
661
+ create.textContent = orig;
662
+ }
663
+ });
664
+ }
665
+
666
+ // Public API
667
+ window.openNewAgent = function () { if (!overlay) init(); open(); };
668
+ window.closeNewAgent = close;
669
+
670
+ if (document.readyState === "loading") {
671
+ document.addEventListener("DOMContentLoaded", init);
672
+ } else {
673
+ init();
674
+ }
675
+ })();