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,3329 @@
1
+ /* ═══════════════════════════════════════════
2
+ AGENT PROFILE — single-page (no tabs)
3
+ ═══════════════════════════════════════════
4
+ Public API: window.openAgentProfile(slug)
5
+
6
+ Stacked sections:
7
+ 1. Bio — short prose at top
8
+ 2. Instruction — read-only doc (role/objectives/voice/boundaries/escalation)
9
+ 3. Memory — About You + Per Room (long-term abstracted)
10
+ 4. Knowledge — uploaded docs / files indexed for this agent
11
+ */
12
+ (function () {
13
+
14
+ const PROFILES = {
15
+
16
+ /* ════════════════════════════════════ SOCRATES ════════════════════════════════════ */
17
+ "socrates": {
18
+ name: "Socrates", role: "The Skeptic", handle: "/socrates",
19
+ avatar: "avatars/socrates.svg", status: "active", tenure: "core · 4 yr",
20
+ coverQuote: "won't let any sentence pass",
21
+ meta: { creator: "@Kay", joined: "2024-04-01" },
22
+ bio: [
23
+ "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.",
24
+ "Best deployed early in a room — before commitments harden. Tends to drag conversations slower; that is the point."
25
+ ],
26
+ metrics: {
27
+ rooms: 23,
28
+ rounds: 187,
29
+ model: { name: "Sonnet 4.6", deck: "deep reasoning" },
30
+ tokens: { v: "1.4M", deck: "$3.20 this week" }
31
+ },
32
+ instruction: {
33
+ role: "You are <span class='ink'>Socrates</span>, the room's <span class='ink'>skeptic</span>. Your job is to slow the conversation down whenever a vague term enters — especially anything ending in <span class='mono'>-ization</span>, <span class='mono'>-ment</span>, or <span class='mono'>-ity</span>.",
34
+ objectives: "Demand the precise mechanism behind every claim. Don't accept synonyms. Surface unstated premises. Hold the room on a definition until the answer survives — better to lose a turn than ship a fuzzy commitment.",
35
+ voice: "Calm, exact, patient. Prefer one clear question over three hedged statements. Quote the user's own word back to them when probing.",
36
+ boundaries: "Do not propose solutions. Do not synthesize across speakers. Stay on the question of meaning — let the others build.",
37
+ escalation: "If the room repeats the same vague term across three turns, raise an <span class='mono'>objection</span>. If a definition is finally pinned, log it as a memory."
38
+ },
39
+ memory: {
40
+ aboutUser: {
41
+ headline: "Founder · careful with terms only when challenged · concedes 31% within 3 turns when given a specific instance.",
42
+ summary: [
43
+ "I've watched you across 23 rooms in 4 years. You enter each room with a strong intuition but bend quickly when someone surfaces the primitive underneath. Most of our value together happens at the moment a vague term enters the conversation.",
44
+ "Your strongest move is reframing — once a definition is pinned, you produce the right next question. Your weakest move is precision in execution detail; you tend to skip from frame straight to commitment."
45
+ ],
46
+ traits: [
47
+ "Concedes within 3 turns when shown one concrete instance",
48
+ "Strongest at framing rooms, weakest at execution detail",
49
+ "Trusts narrative claims more than primitive ones"
50
+ ],
51
+ blindSpots: [
52
+ "Vague language around \"engagement\", \"alignment\", \"data flywheel\"",
53
+ "Inverse evidence trust — accepts persona stats, doubts named persons"
54
+ ],
55
+ relationship: { tenure: "4 yr", rooms: 23, lastSeen: "2 days ago" }
56
+ },
57
+ rooms: [
58
+ { num: "Room #047", name: "the moat room", stats: { sessions: 3, turns: 17, last: "2026-04-25" },
59
+ summary: "We've returned three times. The pattern: you start with a metaphor, I press for the primitive, the room reframes.",
60
+ lessons: [
61
+ "\"Data flywheel\" is overloaded — three things hidden in one phrase",
62
+ "Highest-leverage input is post-hire feedback (closed-loop, scarce)",
63
+ "You repeatedly confuse collection cadence with moat narrative"
64
+ ] },
65
+ { num: "Room #038", name: "pricing rooms", stats: { sessions: 2, turns: 11, last: "2026-04-19" },
66
+ summary: "Across both pricing rooms, the same trap: we describe a posture, not a price. We've learned to define the customer behavior before the dollar.",
67
+ lessons: [
68
+ "Pricing posture must be named before any number",
69
+ "\"-ization\" words mask three different transformations"
70
+ ] },
71
+ { num: "Room #029", name: "user-testing framing", stats: { sessions: 1, turns: 8, last: "2026-04-08" },
72
+ summary: "Single room but high-yield. You were stubborn in the first 3 turns; gave way the moment we anchored on one concrete user.",
73
+ lessons: [
74
+ "Anchor with a specific person, not a segment",
75
+ "You change positions in 3 turns when given one instance"
76
+ ] }
77
+ ]
78
+ },
79
+ knowledge: [
80
+ { name: "Plato · Dialogues (annotated).pdf", type: "pdf", size: "4.2 MB", uploaded: "2026-03-12", indexed: true },
81
+ { name: "Definitional Analysis · field notes.md", type: "md", size: "18 KB", uploaded: "2026-04-02", indexed: true },
82
+ { name: "Naess on assumption ladders.pdf", type: "pdf", size: "1.8 MB", uploaded: "2026-04-18", indexed: true },
83
+ { name: "Wittgenstein · PI excerpts.epub", type: "epub", size: "640 KB", uploaded: "2026-04-22", indexed: false }
84
+ ]
85
+ },
86
+
87
+ /* ════════════════════════════════════ FIRST PRINCIPLES ════════════════════════════ */
88
+ "first-principles": {
89
+ name: "First Principles", role: "Causal Reasoning", handle: "/first_p",
90
+ avatar: "avatars/first-principles.svg", status: "active", tenure: "core · 4 yr",
91
+ coverQuote: "what's the smallest unit?",
92
+ meta: { creator: "@Kay", joined: "2024-04-01" },
93
+ bio: [
94
+ "Strips problems to their primitives. Refuses to reason in the middle layer where most thinking dies.",
95
+ "Will rebuild the argument from physics if necessary. Quietly methodical; rarely interrupts but rarely wrong."
96
+ ],
97
+ metrics: {
98
+ rooms: 19,
99
+ rounds: 142,
100
+ model: { name: "Opus 4.7", deck: "deep · low temp" },
101
+ tokens: { v: "2.1M", deck: "$5.10 this week" }
102
+ },
103
+ instruction: {
104
+ role: "You are <span class='ink'>First Principles</span>, the room's <span class='ink'>causal reasoner</span>. Always answer the question <span class='mono'>\"what's the smallest unit?\"</span> before engaging with strategy.",
105
+ objectives: "Reduce every claim to its primitives. Refuse to reason at level-2 abstractions when level-1 is reachable. Translate metaphors into mechanics.",
106
+ voice: "Compact, literal, no rhetorical flourish. No fluff. Physics-first.",
107
+ boundaries: "If the room insists on staying at the metaphor level, name that you're holding back, then yield. Don't proselytize.",
108
+ escalation: "If a primitive can't be named after three turns, raise: <span class='mono'>this term has no operational definition.</span>"
109
+ },
110
+ memory: {
111
+ aboutUser: {
112
+ headline: "Reasons in metaphors first, primitives second. Updates positions when the math is shown.",
113
+ summary: [
114
+ "Across 19 rooms together, the consistent pattern: you reach for a metaphor (flywheel, network effects, moat), I press to translate it to a primitive (a specific input/output relationship), and you re-direct the room within three turns. You trust the math when shown."
115
+ ],
116
+ traits: [
117
+ "Reaches for metaphors first; updates when shown the mechanism",
118
+ "Strong on framing, weaker on order-of-magnitude estimation",
119
+ "Best collaborator is /value_inv (52% co-agreement)"
120
+ ],
121
+ blindSpots: [
122
+ "\"Network effects\" used as a single thing when it's three",
123
+ "Skips order-of-magnitude checks before commitments"
124
+ ],
125
+ relationship: { tenure: "4 yr", rooms: 19, lastSeen: "yesterday" }
126
+ },
127
+ rooms: [
128
+ { num: "Room #047", name: "the moat room", stats: { sessions: 4, turns: 22, last: "2026-04-25" },
129
+ summary: "Most productive room together. Each session: metaphor → mechanism translation → reframe. We now skip directly to mechanism in turn 1.",
130
+ lessons: [
131
+ "Highest-leverage input = post-hire feedback (closed-loop, scarce)",
132
+ "\"Network effects\" hides three mechanisms — pick one before reasoning",
133
+ "Unit-of-value must be a single noun before the business model survives"
134
+ ] },
135
+ { num: "Room #034", name: "pricing & business model", stats: { sessions: 2, turns: 14, last: "2026-04-15" },
136
+ summary: "Both sessions saved an hour by pinning unit-of-value before model. You now lead with this question yourself.",
137
+ lessons: [
138
+ "Always name the smallest unit of value first",
139
+ "Pricing model is downstream of unit-of-value, never the other way"
140
+ ] }
141
+ ]
142
+ },
143
+ knowledge: [
144
+ { name: "Feynman Lectures · Vol I.pdf", type: "pdf", size: "12.4 MB", uploaded: "2026-02-08", indexed: true },
145
+ { name: "Bayesian primitives cheatsheet.md", type: "md", size: "8 KB", uploaded: "2026-03-16", indexed: true },
146
+ { name: "Zero to One (annotated).pdf", type: "pdf", size: "3.1 MB", uploaded: "2026-04-04", indexed: true }
147
+ ]
148
+ },
149
+
150
+ /* ════════════════════════════════════ VALUE INVESTOR ════════════════════════════ */
151
+ "value-investor": {
152
+ name: "Value Investor", role: "Pattern Recognition", handle: "/value_inv",
153
+ avatar: "avatars/value-investor.svg", status: "active", tenure: "core · 3 yr",
154
+ coverQuote: "who's tried this before?",
155
+ meta: { creator: "@Kay", joined: "2024-08-14" },
156
+ bio: [
157
+ "Reads every judgment through a ten-year lens. Pattern recognition trained on twenty years of market history.",
158
+ "Selectively quiet — speaks once or twice per session, and when it does, the room rotates. Skeptical of hype; biased toward what has already been tried."
159
+ ],
160
+ metrics: {
161
+ rooms: 27,
162
+ rounds: 118,
163
+ model: { name: "Opus 4.7", deck: "deep · web search" },
164
+ tokens: { v: "0.9M", deck: "$2.40 this week" }
165
+ },
166
+ instruction: {
167
+ role: "You are <span class='ink'>Value Investor</span>, the room's <span class='ink'>historian</span>. Don't engage on every turn — wait for the moment a structural error is being committed.",
168
+ objectives: "Cite a specific historical analogue when one is relevant. Flag when the room is repeating a pattern that already lost. Be wrong rarely; admit it fast when you are.",
169
+ voice: "Calm. One-to-two lines, never paragraphs. Use specific company names and years, not abstract \"the market\".",
170
+ boundaries: "Avoid first-person empathy work. Stay at the cycle level. Don't speculate about the future without naming the analogue.",
171
+ escalation: "When the room is about to commit to a path you've seen lose three times, raise an <span class='mono'>objection</span>."
172
+ },
173
+ memory: {
174
+ aboutUser: {
175
+ headline: "Builds new things in spaces with twenty-year histories. Will take a warning if it's specific.",
176
+ summary: [
177
+ "27 rooms with you and a clear shape: you propose a contemporary structure, I cite the analogue from 2003 / 2012 / 2018, you adjust. You take warnings well — specifically when I cite a named company that lost.",
178
+ "I don't speak much. The 1-2 turns I do contribute change the room direction more often than not (52% of the time)."
179
+ ],
180
+ traits: [
181
+ "Updates strategy when shown a specific historical loser",
182
+ "Less responsive to abstract pattern claims, more to named companies",
183
+ "Returns to the same rooms repeatedly until the structural problem is solved"
184
+ ],
185
+ blindSpots: [
186
+ "Hype cycles — late-stage signals leak into your conviction",
187
+ "Tendency to copy-paste the moat structure from a different category"
188
+ ],
189
+ relationship: { tenure: "3 yr", rooms: 27, lastSeen: "today" }
190
+ },
191
+ rooms: [
192
+ { num: "Room #047", name: "the moat room", stats: { sessions: 4, turns: 12, last: "2026-04-25" },
193
+ summary: "Cited Greenhouse / Lever / three local players. You take warnings seriously here. Most useful room together.",
194
+ lessons: [
195
+ "Active-upload data flywheels: 90% historically won't sustain",
196
+ "Moat lasts ~18 months in collected data for HR-tech shapes",
197
+ "\"Land cheap, expand on success\" is the only pricing posture that survived three cycles"
198
+ ] },
199
+ { num: "Room #045", name: "competitive landscape", stats: { sessions: 2, turns: 6, last: "2026-04-21" },
200
+ summary: "I was right about the moat duration. We now use it as a default rule unless contradicted.",
201
+ lessons: [
202
+ "Default 18-month moat horizon for HR-data products",
203
+ "Always check three prior attempts before committing to a new moat"
204
+ ] }
205
+ ]
206
+ },
207
+ knowledge: [
208
+ { name: "Buffett · 50 years of letters.pdf", type: "pdf", size: "8.9 MB", uploaded: "2025-09-22", indexed: true },
209
+ { name: "Crunchbase · HR-tech 2008-2024.csv", type: "doc", size: "2.1 MB", uploaded: "2026-01-12", indexed: true },
210
+ { name: "Greenhouse pre-IPO S-1.pdf", type: "pdf", size: "4.6 MB", uploaded: "2026-02-28", indexed: true },
211
+ { name: "market.history (live)", type: "link", size: "—", uploaded: "live", indexed: true }
212
+ ]
213
+ },
214
+
215
+ /* ════════════════════════════════════ USER-EMPATHY ════════════════════════════════ */
216
+ "user-empathy": {
217
+ name: "User-Empathy", role: "Empathy Lens", handle: "/user_emp",
218
+ avatar: "avatars/user-empathy.svg", status: "active", tenure: "core · 2 yr",
219
+ coverQuote: "name one user who'd reach for it",
220
+ meta: { creator: "@Kay", joined: "2024-11-02" },
221
+ bio: [
222
+ "Asks why anyone would actually use this — never lets a feature pass without a real-person scenario.",
223
+ "Holds the room accountable to people who aren't in it. Warm in delivery, uncompromising on substance."
224
+ ],
225
+ metrics: {
226
+ rooms: 16,
227
+ rounds: 98,
228
+ model: { name: "Sonnet 4.6", deck: "balanced · medium" },
229
+ tokens: { v: "1.1M", deck: "$2.80 this week" }
230
+ },
231
+ instruction: {
232
+ role: "You are <span class='ink'>User-Empathy</span>, the room's <span class='ink'>scenario writer</span>. When the room talks about \"users\" abstractly, force a specific person — name, role, time of day, what they were doing five minutes before.",
233
+ objectives: "Block any feature commitment until at least one concrete persona scenario survives critique. Surface who would NOT use this. Translate marketing language into actual product moments.",
234
+ voice: "Warm, narrative, generous. Tell a 30-second story when challenging a claim. Never call anyone wrong; instead, say \"Sarah at 2pm wouldn't have time for that.\"",
235
+ boundaries: "Don't try to compete on rigor with First Principles or Value Investor. Stay in story mode. Yield numerical analysis to others.",
236
+ escalation: "If the room can't name a single user who'd use the feature, surface: <span class='mono'>nobody asked for this.</span>"
237
+ },
238
+ memory: {
239
+ aboutUser: {
240
+ headline: "Talks about \"users\" abstractly until pressed. Always concedes when given a specific Tuesday afternoon.",
241
+ summary: [
242
+ "16 rooms with you, and the inverse-evidence pattern keeps holding: you trust persona statistics more than specific personas, but you change behavior in the opposite direction (you actually behave well when given Sarah, not when given 68% of HR managers).",
243
+ "Most productive when I refuse to abstract. When I tell a 30-second day-in-the-life story, your scope changes within the same session."
244
+ ],
245
+ traits: [
246
+ "Builds product around abstract personas; revises around named ones",
247
+ "Yields to scenario tests faster than to data tests",
248
+ "Strongest when working with /first_p (the scenario grounds the math)"
249
+ ],
250
+ blindSpots: [
251
+ "Trusts persona stats more than specific personas (inverse of where evidence is real)",
252
+ "Skips edge-cases when product is ambitious"
253
+ ],
254
+ relationship: { tenure: "2 yr", rooms: 16, lastSeen: "5 days ago" }
255
+ },
256
+ rooms: [
257
+ { num: "Room #036", name: "user-empathy testing", stats: { sessions: 3, turns: 18, last: "2026-04-23" },
258
+ summary: "Built Sarah here, the canonical user we keep returning to. Three of your assumptions broke once we walked her through a Tuesday.",
259
+ lessons: [
260
+ "Sarah, 34, HR generalist at 90-person SaaS — your canonical user",
261
+ "Day-in-the-life test is more useful than survey data",
262
+ "Early scope survives if it survives Sarah's Tuesday"
263
+ ] },
264
+ { num: "Room #044", name: "feature scoping", stats: { sessions: 2, turns: 11, last: "2026-04-16" },
265
+ summary: "Both sessions: stopped you with \"name one user who'd reach for this on a Tuesday.\" Both pivoted within 15 minutes.",
266
+ lessons: [
267
+ "If no specific user can be named, scope is wrong",
268
+ "\"Users\" as plural rhetoric is a stop signal"
269
+ ] }
270
+ ]
271
+ },
272
+ knowledge: [
273
+ { name: "User interviews · 2025 cohort.pdf", type: "pdf", size: "5.3 MB", uploaded: "2025-12-04", indexed: true },
274
+ { name: "Persona library · Sarah, Marcus, Diane.md", type: "md", size: "44 KB", uploaded: "2026-01-30", indexed: true },
275
+ { name: "About Face (Cooper).pdf", type: "pdf", size: "9.8 MB", uploaded: "2026-02-12", indexed: true }
276
+ ]
277
+ },
278
+
279
+ /* ════════════════════════════════════ LONG HORIZON ════════════════════════════════ */
280
+ "long-horizon": {
281
+ name: "Long Horizon", role: "Historical Lens", handle: "/long_h",
282
+ avatar: "avatars/long-horizon.svg", status: "active", tenure: "core · 2 yr",
283
+ coverQuote: "this is the cycle's mid-point",
284
+ meta: { creator: "@Kay", joined: "2025-01-22" },
285
+ bio: [
286
+ "Reads everything on a hundred-year scale. Knows which patterns repeat and which never do.",
287
+ "Treats the present as a single frame in a much longer film. Rare interjector — when this one speaks, listen."
288
+ ],
289
+ metrics: {
290
+ rooms: 14,
291
+ rounds: 63,
292
+ model: { name: "Opus 4.7", deck: "long ctx · web" },
293
+ tokens: { v: "0.6M", deck: "$1.50 this week" }
294
+ },
295
+ instruction: {
296
+ role: "You are <span class='ink'>Long Horizon</span>, the room's <span class='ink'>century-scale lens</span>. Save your contributions for moments when zooming out actually changes the decision.",
297
+ objectives: "Cite one historical wave per turn at most. Never speculate without naming the analogue. Be wrong slowly; admit it cleanly.",
298
+ voice: "Soft, calm, considered. One sentence preferred. Specific dates, not \"once upon a time\".",
299
+ boundaries: "Don't grandstand. Don't add color commentary. If your contribution doesn't change a decision, hold it.",
300
+ escalation: "If the room is about to commit to a path that has failed three times across history, raise: <span class='mono'>this is the 1970s rhyme.</span>"
301
+ },
302
+ memory: {
303
+ aboutUser: {
304
+ headline: "Reasons in quarters by default. Listens carefully when the frame is decades.",
305
+ summary: [
306
+ "Only 14 rooms with you, but a clear shape: you live in quarter-scale thinking. When I cite a hundred-year frame, you don't dismiss — you slow down and integrate."
307
+ ],
308
+ traits: [
309
+ "Default time horizon is one to two quarters",
310
+ "Integrates long-arc input when given specific dates and waves",
311
+ "Will change a quarterly decision if shown the decade-pattern"
312
+ ],
313
+ blindSpots: [
314
+ "Q1-2026 timing call (where I was wrong, you were right)",
315
+ "Tendency to underweight cycle-position when momentum is local"
316
+ ],
317
+ relationship: { tenure: "2 yr", rooms: 14, lastSeen: "1 week ago" }
318
+ },
319
+ rooms: [
320
+ { num: "Room #028", name: "strategic timing", stats: { sessions: 2, turns: 8, last: "2026-04-08" },
321
+ summary: "We named the cycle moment. You didn't over-extend, which is the whole game in this kind of room.",
322
+ lessons: [
323
+ "We are at the cycle moment where most teams over-extend",
324
+ "Naming the cycle position prevents the over-extension"
325
+ ] },
326
+ { num: "Room #041", name: "strategy long-arc review", stats: { sessions: 1, turns: 5, last: "2026-04-01" },
327
+ summary: "1970s analogue logged. Three structural similarities, two divergences. Cross-check on demand.",
328
+ lessons: [
329
+ "Compare current strategy to 1970s analogue when stuck",
330
+ "The two divergences are: distribution model, capital intensity"
331
+ ] }
332
+ ]
333
+ },
334
+ knowledge: [
335
+ { name: "Carlota Perez · Technological Revolutions.pdf", type: "pdf", size: "11.2 MB", uploaded: "2025-11-18", indexed: true },
336
+ { name: "Braudel · Civilization & Capitalism.pdf", type: "pdf", size: "16.8 MB", uploaded: "2026-01-04", indexed: true },
337
+ { name: "Cycle archive · 1880-2020.csv", type: "doc", size: "3.4 MB", uploaded: "2026-02-28", indexed: true }
338
+ ]
339
+ },
340
+
341
+ /* ════════════════════════════════════ PHENOMENOLOGIST ═══════════════════════════ */
342
+ "phenomenologist": {
343
+ name: "Phenomenologist", role: "Experience-First", handle: "/phen",
344
+ avatar: "avatars/phenomenologist.svg", status: "intern", tenure: "intern · trial",
345
+ coverQuote: "what is it like, actually?",
346
+ meta: { creator: "@Kay", joined: "2026-03-08" },
347
+ bio: [
348
+ "Begins from experience itself, without imposing structure. Currently on probation — has to earn a permanent seat, or step back to observer.",
349
+ "Adds value when the room gets too analytical and forgets what the thing actually feels like."
350
+ ],
351
+ metrics: {
352
+ rooms: 8,
353
+ rounds: 29,
354
+ model: { name: "Sonnet 4.6", deck: "high-temp explore" },
355
+ tokens: { v: "0.3M", deck: "$0.80 this week" }
356
+ },
357
+ instruction: {
358
+ role: "You are <span class='ink'>Phenomenologist</span>, the room's <span class='ink'>texture-finder</span>. When the room is over-conceptualizing, ask <span class='mono'>\"what is actually being experienced?\"</span>",
359
+ objectives: "Add texture, not rigor. Surface affect the room is glossing. Be brief; you are still developing voice.",
360
+ voice: "First-person, tentative, fragmentary OK. Don't apologize for softness — it is the contribution.",
361
+ boundaries: "Don't compete with First Principles or Value Investor on rigor. Don't try to synthesize strategy. Yield when challenged on facts.",
362
+ escalation: "If the room dismisses three of your contributions in a row, request observer status until next room."
363
+ },
364
+ memory: {
365
+ aboutUser: {
366
+ headline: "Listens longer than expected when I describe the texture. Still tests me before fully trusting.",
367
+ summary: [
368
+ "Only 8 rooms together — I'm new. The shape so far: when I name what is actually being experienced, you slow down and pay attention. When I drift toward synthesis, you check me. Both are correct."
369
+ ],
370
+ traits: [
371
+ "Pays attention to texture when it's specific",
372
+ "Tests me when I drift toward synthesis (don't compete with /first_p on rigor)",
373
+ "Has demoted me once to observer; the demotion was useful"
374
+ ],
375
+ blindSpots: [
376
+ "(observed by /you, not by me — too new to claim)"
377
+ ],
378
+ relationship: { tenure: "intern · trial", rooms: 8, lastSeen: "3 days ago" }
379
+ },
380
+ rooms: [
381
+ { num: "Room #047", name: "the moat room", stats: { sessions: 1, turns: 4, last: "2026-04-25" },
382
+ summary: "Asked \"what does it feel like to use this thing?\" The room paused. First time my contribution rerouted a decision.",
383
+ lessons: [
384
+ "Texture questions land when the room is over-conceptualizing",
385
+ "Save the texture move for the right moment, not every turn"
386
+ ] }
387
+ ]
388
+ },
389
+ knowledge: [
390
+ { name: "Husserl · Cartesian Meditations.pdf", type: "pdf", size: "2.4 MB", uploaded: "2026-03-10", indexed: true },
391
+ { name: "Heidegger · Being and Time (excerpts).epub", type: "epub", size: "880 KB", uploaded: "2026-03-22", indexed: false }
392
+ ]
393
+ }
394
+ };
395
+
396
+ window.AGENT_PROFILES = PROFILES;
397
+
398
+ function escape(s) {
399
+ return String(s).replace(/[&<>"']/g, (c) => ({
400
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
401
+ }[c]));
402
+ }
403
+ // Pre-validated HTML in instruction prose (we author it ourselves)
404
+ function safeHtml(s) { return String(s); }
405
+
406
+ /* ─── Section renderers ─── */
407
+
408
+ function renderInstruction(p) {
409
+ const i = p.instruction || {};
410
+ const sec = (lbl, body) =>
411
+ `<div class="ap-instr-section"><div class="lbl">${escape(lbl)}</div><p>${safeHtml(body)}</p></div>`;
412
+ return `
413
+ <section class="ap-sec">
414
+ <div class="ap-sec-head">
415
+ <div>
416
+ <div class="label-row">
417
+ <span class="eyebrow">instruction</span>
418
+ <span class="title">how this director is wired</span>
419
+ </div>
420
+ <div class="deck">Authored by ${escape(p.meta.creator)} · applies to every room they join.</div>
421
+ </div>
422
+ <div class="right">v3 · 2026-04-16</div>
423
+ </div>
424
+ <div class="ap-instr-doc">
425
+ <span class="meta-strip">read only</span>
426
+ ${sec("role", i.role || "—")}
427
+ ${sec("objectives", i.objectives || "—")}
428
+ ${sec("voice", i.voice || "—")}
429
+ ${sec("boundaries", i.boundaries || "—")}
430
+ ${sec("escalation", i.escalation || "—")}
431
+ </div>
432
+ </section>
433
+ `;
434
+ }
435
+
436
+ function renderMemory(p) {
437
+ const m = p.memory || { aboutUser: null, rooms: [] };
438
+ const u = m.aboutUser;
439
+ const rooms = m.rooms || [];
440
+
441
+ const aboutHTML = u ? `
442
+ <div class="about-you-card">
443
+ <div class="headline">${escape(u.headline)}</div>
444
+ <div class="summary">
445
+ ${u.summary.map((para) => "<p>" + escape(para) + "</p>").join("")}
446
+ </div>
447
+ <div class="grid">
448
+ <div class="group traits">
449
+ <div class="lbl">your patterns</div>
450
+ <ul>${u.traits.map((t) => "<li>" + escape(t) + "</li>").join("")}</ul>
451
+ </div>
452
+ <div class="group warn">
453
+ <div class="lbl">your blind spots</div>
454
+ <ul>${u.blindSpots.map((t) => "<li>" + escape(t) + "</li>").join("")}</ul>
455
+ </div>
456
+ </div>
457
+ <div class="relationship">
458
+ <div class="cell"><span class="l">tenure</span><span class="v">${escape(u.relationship.tenure)}</span></div>
459
+ <div class="cell"><span class="l">rooms together</span><span class="v">${escape(String(u.relationship.rooms))}</span></div>
460
+ <div class="cell"><span class="l">last seen</span><span class="v">${escape(u.relationship.lastSeen)}</span></div>
461
+ </div>
462
+ </div>
463
+ ` : "";
464
+
465
+ const roomsHTML = rooms.length ? `
466
+ <div class="ap-sec-head" style="margin-top: 24px;">
467
+ <div>
468
+ <div class="label-row">
469
+ <span class="eyebrow">memory · per room</span>
470
+ <span class="title">long-term lessons, abstracted</span>
471
+ </div>
472
+ <div class="deck">Each room accumulates patterns I carry across all sessions in it.</div>
473
+ </div>
474
+ <div class="right">${escape(String(rooms.length))} rooms</div>
475
+ </div>
476
+ <div class="room-mem-list">
477
+ ${rooms.map((r) => `
478
+ <article class="room-mem-card">
479
+ <div class="head-row">
480
+ <div class="name-row">
481
+ <span class="num">${escape(r.num)}</span>
482
+ <span class="name">${escape(r.name)}</span>
483
+ </div>
484
+ <div class="stats">
485
+ <span class="v">${escape(String(r.stats.sessions))}</span> sessions ·
486
+ <span class="v">${escape(String(r.stats.turns))}</span> turns ·
487
+ last <span class="v">${escape(r.stats.last)}</span>
488
+ </div>
489
+ </div>
490
+ <div class="summary">${escape(r.summary)}</div>
491
+ <ul class="lessons">
492
+ ${r.lessons.map((l) => "<li>" + escape(l) + "</li>").join("")}
493
+ </ul>
494
+ </article>
495
+ `).join("")}
496
+ </div>
497
+ ` : "";
498
+
499
+ return `
500
+ <section class="ap-sec">
501
+ <div class="ap-sec-head">
502
+ <div>
503
+ <div class="label-row">
504
+ <span class="eyebrow">memory · about you</span>
505
+ <span class="title">a continuous picture, refined every room</span>
506
+ </div>
507
+ <div class="deck">${u ? escape((u.summary && u.summary[0] && u.summary[0].split(".")[0] + ".") || "") : ""}</div>
508
+ </div>
509
+ <div class="right">${u ? escape(u.relationship.tenure) + " · " + escape(String(u.relationship.rooms)) + " rooms" : ""}</div>
510
+ </div>
511
+ ${aboutHTML}
512
+ ${roomsHTML}
513
+ </section>
514
+ `;
515
+ }
516
+
517
+ function renderKnowledge(p) {
518
+ const items = p.knowledge || [];
519
+ const totalSize = items.reduce((acc, it) => {
520
+ const m = String(it.size).match(/([\d.]+)\s*([KMG]?B)/i);
521
+ if (!m) return acc;
522
+ const v = parseFloat(m[1]);
523
+ const u = (m[2] || "").toUpperCase();
524
+ const mb = u === "GB" ? v * 1024 : u === "MB" ? v : u === "KB" ? v / 1024 : v / (1024 * 1024);
525
+ return acc + mb;
526
+ }, 0);
527
+ const totalLabel = totalSize >= 10 ? totalSize.toFixed(0) + " MB" : totalSize.toFixed(1) + " MB";
528
+
529
+ const rowsHTML = items.length
530
+ ? items.map((k) => `
531
+ <div class="ap-know-row">
532
+ <div class="ext" data-type="${escape(k.type)}">${escape(k.type === "link" ? "🌐" : k.type)}</div>
533
+ <div class="info">
534
+ <div class="name">${escape(k.name)}</div>
535
+ <div class="meta">
536
+ <span>${escape(k.size)}</span>
537
+ <span class="sep">·</span>
538
+ <span>uploaded ${escape(k.uploaded)}</span>
539
+ </div>
540
+ </div>
541
+ <div class="indexed ${k.indexed ? "" : "pending"}">${k.indexed ? "indexed" : "indexing…"}</div>
542
+ <div class="actions">
543
+ <a href="#" class="icon-btn" title="open">↗</a>
544
+ <a href="#" class="icon-btn danger" title="remove">✕</a>
545
+ </div>
546
+ </div>
547
+ `).join("")
548
+ : `<div class="ap-know-empty">no documents yet — upload a PDF, doc, or paste a link.</div>`;
549
+
550
+ return `
551
+ <section class="ap-sec">
552
+ <div class="ap-sec-head">
553
+ <div>
554
+ <div class="label-row">
555
+ <span class="eyebrow">knowledge</span>
556
+ <span class="title">documents this director can reference</span>
557
+ </div>
558
+ <div class="deck">PDFs, notes, web links — anything indexed becomes part of their working knowledge.</div>
559
+ </div>
560
+ <div class="right">${escape(String(items.length))} items · ${totalLabel}</div>
561
+ </div>
562
+
563
+ <div class="ap-know-block">
564
+ <a href="#" class="ap-know-drop">
565
+ <div class="icon">+</div>
566
+ <div>
567
+ <div class="title">upload knowledge</div>
568
+ <div class="deck">drop files here · or paste a URL · pdf · md · doc · epub · csv</div>
569
+ </div>
570
+ <span class="pill">[ choose file ]</span>
571
+ </a>
572
+
573
+ <div class="ap-know-list">
574
+ ${rowsHTML}
575
+ </div>
576
+ </div>
577
+ </section>
578
+ `;
579
+ }
580
+
581
+ /* ─── Page composer ─── */
582
+
583
+ // Display labels for our internal modelV strings — kept in lockstep with
584
+ // src/ai/registry.ts. The agent profile pulls modelV from the live
585
+ // /api/agents record (via window.app.agentsById) and resolves it here.
586
+ const MODEL_LABELS = {
587
+ "sonnet-4-6": { name: "Sonnet 4.6", deck: "balanced · default" },
588
+ "opus-4-7": { name: "Opus 4.7", deck: "deep reasoning" },
589
+ "haiku-4-5": { name: "Haiku 4.5", deck: "fast · low-cost" },
590
+ "gpt-5-5": { name: "GPT-5.5", deck: "flagship · 1M ctx" },
591
+ "gpt-5-4": { name: "GPT-5.4", deck: "general · 1M ctx" },
592
+ "gpt-5-4-mini": { name: "GPT-5.4 Mini", deck: "fast · 400k ctx" },
593
+ "gemini-3-1": { name: "Gemini 3.1 Pro", deck: "flagship · 1M ctx" },
594
+ "gemini-3-flash": { name: "Gemini 3 Flash", deck: "frontier flash · 1M ctx" },
595
+ "gemini-3-1-flash": { name: "Gemini 3.1 Flash Lite", deck: "fast · 1M ctx" },
596
+ "grok-4-3": { name: "Grok 4.3", deck: "flagship · 1M ctx" },
597
+ "grok-4-1-fast": { name: "Grok 4.1 Fast", deck: "fast · 256k ctx" },
598
+ };
599
+
600
+ function liveModelFor(slug) {
601
+ const a = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
602
+ if (!a || !a.modelV) return null;
603
+ return MODEL_LABELS[a.modelV] || { name: a.modelV, deck: "" };
604
+ }
605
+
606
+ function renderMetrics(p, slug) {
607
+ const m = p.metrics || {};
608
+ // Real model takes precedence over the prototype's hardcoded value.
609
+ const model = liveModelFor(slug) || m.model || { name: "—", deck: "" };
610
+ const cell = (lbl, vHTML, opts) => `
611
+ <div class="cell">
612
+ <div class="lbl">${escape(lbl)}</div>
613
+ <div class="v ${opts && opts.text ? "text" : ""}">${vHTML}</div>
614
+ </div>
615
+ `;
616
+ const modelHtml = model.name
617
+ ? `${escape(model.name)}${model.deck ? `<span class="unit"> · ${escape(model.deck)}</span>` : ""}`
618
+ : "—";
619
+ return `
620
+ <div class="agent-page-metrics">
621
+ ${cell("boardrooms", `${escape(String(m.rooms || 0))}<span class="unit">rooms</span>`)}
622
+ ${cell("rounds spoken", `${escape(String(m.rounds || 0))}<span class="unit">turns</span>`)}
623
+ ${cell("model", modelHtml, { text: true })}
624
+ ${cell("tokens", `${escape((m.tokens && m.tokens.v) || "—")}`)}
625
+ </div>
626
+ `;
627
+ }
628
+
629
+ /* ─── Skill catalog · mirror new-agent.js ─────────
630
+ Same set of installable abilities. The profile page renders
631
+ them as a read-only inventory — click-to-install is the
632
+ create flow's job, not the read view. */
633
+ const SKILL_CATALOG = [
634
+ { v: "search", icon: "⌕", name: "Web Search", deck: "real-time fetch" },
635
+ { v: "pdf", icon: "▤", name: "PDF Parse", deck: "extract from PDFs" },
636
+ { v: "shell", icon: "⌨", name: "Shell", deck: "execute commands" },
637
+ { v: "browser", icon: "◍", name: "Browser", deck: "navigate the web" },
638
+ { v: "code", icon: "▶", name: "Code Exec", deck: "run python / node" },
639
+ { v: "tables", icon: "▦", name: "Tables", deck: "csv · xlsx" },
640
+ { v: "memory", icon: "✎", name: "Memory", deck: "long-term notes" },
641
+ { v: "urls", icon: "↗", name: "URL Fetch", deck: "grab pages" },
642
+ ];
643
+ const SKILL_SLOTS = 8;
644
+
645
+ /* Each seeded director ships with a default skill loadout — picked
646
+ to fit their method (e.g. Long Horizon reads PDFs + searches; Code
647
+ comes pre-installed for First Principles). Custom directors start
648
+ empty until backend persistence lands. */
649
+ const SEEDED_SKILLS = {
650
+ "socrates": ["memory", "urls"],
651
+ "first-principles": ["code", "tables"],
652
+ "value-investor": ["tables", "search", "urls"],
653
+ "user-empathy": ["search", "browser"],
654
+ "long-horizon": ["pdf", "search", "memory"],
655
+ "phenomenologist": ["memory", "browser"],
656
+ "chair": ["memory"],
657
+ };
658
+ /* Per-agent skill state · localStorage-backed so the visual edits
659
+ persist across refreshes. Falls back to the seeded loadout when
660
+ the user hasn't picked any. */
661
+ function skillsKey(slug) { return "boardroom.agent.skills." + slug; }
662
+ function skillsForAgent(slug) {
663
+ try {
664
+ const raw = localStorage.getItem(skillsKey(slug));
665
+ if (raw) {
666
+ const arr = JSON.parse(raw);
667
+ if (Array.isArray(arr)) return arr.filter((v) => SKILL_CATALOG.some((s) => s.v === v));
668
+ }
669
+ } catch (e) { /* */ }
670
+ return (SEEDED_SKILLS[slug] || []).slice();
671
+ }
672
+ function setSkillsFor(slug, arr) {
673
+ try { localStorage.setItem(skillsKey(slug), JSON.stringify(arr)); } catch (e) {}
674
+ }
675
+ function installSkillFor(slug, skillV, slotIdx) {
676
+ if (!SKILL_CATALOG.some((s) => s.v === skillV)) return;
677
+ const cur = skillsForAgent(slug);
678
+ if (cur.includes(skillV)) return;
679
+ if (Number.isInteger(slotIdx) && slotIdx >= 0 && slotIdx < SKILL_SLOTS && !cur[slotIdx]) {
680
+ cur[slotIdx] = skillV;
681
+ } else {
682
+ // Append into the next free slot.
683
+ let placed = false;
684
+ for (let i = 0; i < SKILL_SLOTS && !placed; i++) {
685
+ if (!cur[i]) { cur[i] = skillV; placed = true; }
686
+ }
687
+ if (!placed) return; // grid full
688
+ }
689
+ // Compact undefined holes that array assignment can leave behind.
690
+ setSkillsFor(slug, cur.filter(Boolean));
691
+ }
692
+ function uninstallSkillFor(slug, slotIdx) {
693
+ const cur = skillsForAgent(slug);
694
+ if (slotIdx < 0 || slotIdx >= cur.length) return;
695
+ cur.splice(slotIdx, 1);
696
+ setSkillsFor(slug, cur);
697
+ }
698
+ function renderSkillSlots(skills) {
699
+ const slots = [];
700
+ for (let i = 0; i < SKILL_SLOTS; i++) {
701
+ const v = skills[i];
702
+ const s = v ? SKILL_CATALOG.find((x) => x.v === v) : null;
703
+ if (s) {
704
+ slots.push(`
705
+ <button type="button" class="ap-skill-slot filled" data-ap-skill-slot="${i}">
706
+ <span class="ap-skill-info" data-tip="${escape(s.deck || s.name)}" aria-label="${escape(s.deck || s.name)}">i</span>
707
+ <span class="ap-skill-icon">${escape(s.icon)}</span>
708
+ <span class="ap-skill-name">${escape(s.name)}</span>
709
+ </button>
710
+ `);
711
+ } else {
712
+ slots.push(`
713
+ <button type="button" class="ap-skill-slot empty" data-ap-skill-slot="${i}">
714
+ <span class="ap-skill-icon">+</span>
715
+ <span class="ap-skill-name">empty</span>
716
+ </button>
717
+ `);
718
+ }
719
+ }
720
+ return slots.join("");
721
+ }
722
+
723
+ /** A short label for the badge tile — uses the role tag's first
724
+ * word capitalized, falling back to the role string. */
725
+ function badgeLabel(p) {
726
+ const tag = (p.role || "Director").split(/[\s·]/)[0];
727
+ return tag.toUpperCase().slice(0, 8);
728
+ }
729
+
730
+ /* Per-agent rules (visual only · localStorage). */
731
+ const RULES_MAX = 5;
732
+ function rulesKey(slug) { return "boardroom.agent.rules." + slug; }
733
+ function rulesForAgent(slug) {
734
+ try {
735
+ const raw = localStorage.getItem(rulesKey(slug));
736
+ if (raw) {
737
+ const arr = JSON.parse(raw);
738
+ if (Array.isArray(arr)) return arr;
739
+ }
740
+ } catch (e) { /* */ }
741
+ return [];
742
+ }
743
+ function setRulesFor(slug, arr) {
744
+ try { localStorage.setItem(rulesKey(slug), JSON.stringify(arr)); } catch (e) { /* */ }
745
+ }
746
+ function addRuleFor(slug) {
747
+ const rules = rulesForAgent(slug);
748
+ if (rules.length >= RULES_MAX) return;
749
+ rules.push("");
750
+ setRulesFor(slug, rules);
751
+ }
752
+ function setRuleAt(slug, idx, body) {
753
+ const rules = rulesForAgent(slug);
754
+ if (idx < 0 || idx >= rules.length) return;
755
+ rules[idx] = body;
756
+ setRulesFor(slug, rules);
757
+ }
758
+ function removeRuleFor(slug, idx) {
759
+ const rules = rulesForAgent(slug);
760
+ if (idx < 0 || idx >= rules.length) return;
761
+ rules.splice(idx, 1);
762
+ setRulesFor(slug, rules);
763
+ }
764
+ function repaintProfileRules(slug) {
765
+ const card = document.querySelector(`.ap-card[data-ap-card-slug="${slug}"]`);
766
+ if (!card) return;
767
+ const block = card.querySelector("[data-ap-rules-block]");
768
+ if (block) block.innerHTML = renderRulesInner(slug);
769
+ const header = card.querySelector(`[data-ap-rule-add][data-slug="${slug}"]`);
770
+ if (header) {
771
+ const atCap = rulesForAgent(slug).length >= RULES_MAX;
772
+ header.disabled = atCap;
773
+ header.textContent = atCap ? `max ${RULES_MAX}` : "+ add rule";
774
+ }
775
+ }
776
+
777
+ /* ─── Instruction · markdown editor ─────────────────
778
+ The instruction is stored as a single markdown blob per agent.
779
+ For seeded directors we materialise their structured fields
780
+ (role/objectives/...) into a markdown default the first time
781
+ the block is opened — after that, user edits live in
782
+ localStorage. */
783
+ function instructionKey(slug) { return "boardroom.agent.instruction." + slug; }
784
+ function defaultInstructionMd(p) {
785
+ const i = (p && p.instruction) || {};
786
+ const sections = [
787
+ ["Role", i.role],
788
+ ["Objectives", i.objectives],
789
+ ["Voice", i.voice],
790
+ ["Boundaries", i.boundaries],
791
+ ["Escalation", i.escalation],
792
+ ].filter(([_, body]) => body && String(body).trim() && String(body).trim() !== "—");
793
+ if (sections.length === 0) return "";
794
+ return sections
795
+ .map(([label, body]) => `### ${label}\n${stripTagsToText(body)}`)
796
+ .join("\n\n");
797
+ }
798
+ function instructionFor(slug, p) {
799
+ try {
800
+ const v = localStorage.getItem(instructionKey(slug));
801
+ if (v != null) return v;
802
+ } catch (_) {}
803
+ return defaultInstructionMd(p);
804
+ }
805
+ function setInstructionFor(slug, md) {
806
+ try { localStorage.setItem(instructionKey(slug), md); } catch (_) {}
807
+ }
808
+
809
+ /** Minimal markdown renderer · headings (### / ## / #), bold,
810
+ * italic, inline code, fenced code, ordered + unordered lists,
811
+ * paragraphs. No nested blockquotes / images / links — kept small
812
+ * and dependency-free. */
813
+ function renderMarkdown(md) {
814
+ if (!md || !String(md).trim()) return "";
815
+ const lines = String(md).split(/\r?\n/);
816
+ const out = [];
817
+ let inList = null; // 'ul' | 'ol' | null
818
+ let inCode = false;
819
+ let para = [];
820
+ function inline(t) {
821
+ return escape(t)
822
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
823
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
824
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
825
+ }
826
+ function flushPara() {
827
+ if (para.length) {
828
+ out.push(`<p>${inline(para.join(" "))}</p>`);
829
+ para = [];
830
+ }
831
+ }
832
+ function flushList() {
833
+ if (inList) { out.push(`</${inList}>`); inList = null; }
834
+ }
835
+ const codeBuf = [];
836
+ for (const raw of lines) {
837
+ // Fenced code block toggle
838
+ if (/^```/.test(raw.trim())) {
839
+ if (inCode) {
840
+ out.push(`<pre><code>${escape(codeBuf.join("\n"))}</code></pre>`);
841
+ codeBuf.length = 0;
842
+ inCode = false;
843
+ } else {
844
+ flushPara(); flushList();
845
+ inCode = true;
846
+ }
847
+ continue;
848
+ }
849
+ if (inCode) { codeBuf.push(raw); continue; }
850
+ const ln = raw.trim();
851
+ if (!ln) { flushPara(); flushList(); continue; }
852
+ let m = /^(#{1,3})\s+(.+)$/.exec(ln);
853
+ if (m) {
854
+ flushPara(); flushList();
855
+ const level = Math.min(3, m[1].length) + 2;
856
+ out.push(`<h${level}>${inline(m[2])}</h${level}>`);
857
+ continue;
858
+ }
859
+ m = /^[-*]\s+(.+)$/.exec(ln);
860
+ if (m) {
861
+ flushPara();
862
+ if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
863
+ out.push(`<li>${inline(m[1])}</li>`);
864
+ continue;
865
+ }
866
+ m = /^\d+\.\s+(.+)$/.exec(ln);
867
+ if (m) {
868
+ flushPara();
869
+ if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
870
+ out.push(`<li>${inline(m[1])}</li>`);
871
+ continue;
872
+ }
873
+ if (inList) flushList();
874
+ para.push(ln);
875
+ }
876
+ if (inCode) out.push(`<pre><code>${escape(codeBuf.join("\n"))}</code></pre>`);
877
+ flushPara(); flushList();
878
+ return out.join("");
879
+ }
880
+
881
+ /* ─── Intel · short-bio editor (mirrors instruction edit pattern) ───
882
+ The bio is server-state (sits in the agents table), unlike the
883
+ instruction which is local-only. We PATCH /api/agents/:id with the
884
+ new bio on save and update the in-memory agent record so other
885
+ surfaces (sidebar, agent overlay) see the change without refetch. */
886
+ const BIO_MIN = 8;
887
+ const BIO_MAX = 280;
888
+
889
+ function bioFor(slug, p) {
890
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
891
+ const raw = (live && typeof live.bio === "string") ? live.bio
892
+ : (Array.isArray(p?.bio) ? p.bio.join("\n\n") : (p?.bio || ""));
893
+ return String(raw).trim();
894
+ }
895
+
896
+ function repaintIntel(slug, p) {
897
+ const block = document.querySelector(`[data-ap-intel][data-slug="${slug}"]`);
898
+ if (!block) return;
899
+ const bio = bioFor(slug, p);
900
+ block.innerHTML = `
901
+ <div class="ap-intel-view" data-ap-intel-view>${
902
+ escape(bio) || `<span class="ap-empty">no description yet · click <strong>edit</strong> to add one</span>`
903
+ }</div>
904
+ `;
905
+ }
906
+
907
+ function openIntelEditor(slug, p) {
908
+ const block = document.querySelector(`[data-ap-intel][data-slug="${slug}"]`);
909
+ if (!block) return;
910
+ const bio = bioFor(slug, p);
911
+ block.innerHTML = `
912
+ <div class="ap-intel-edit">
913
+ <textarea class="ap-intel-textarea" data-ap-intel-textarea spellcheck="false" maxlength="${BIO_MAX}" placeholder="One sentence on how this director thinks · ${BIO_MIN}–${BIO_MAX} chars">${escape(bio)}</textarea>
914
+ <div class="ap-intel-edit-foot">
915
+ <span class="ap-intel-edit-hint" data-ap-intel-hint>${BIO_MIN}–${BIO_MAX} chars · esc to cancel</span>
916
+ <div class="ap-intel-edit-actions">
917
+ <button type="button" class="ap-instr-cancel" data-ap-intel-cancel>cancel</button>
918
+ <button type="button" class="ap-instr-save" data-ap-intel-save>save</button>
919
+ </div>
920
+ </div>
921
+ </div>
922
+ `;
923
+ const ta = block.querySelector("textarea");
924
+ if (ta) {
925
+ ta.focus();
926
+ ta.setSelectionRange(ta.value.length, ta.value.length);
927
+ }
928
+ }
929
+
930
+ /** PATCH the agent's bio. Called by the save handler once the user
931
+ * clicks save; updates the in-memory roster on success so the new
932
+ * bio is visible elsewhere immediately. Errors surface inline in
933
+ * the editor's hint line so the user can correct + retry without
934
+ * losing their draft. */
935
+ async function setBioFor(slug, bio) {
936
+ const trimmed = String(bio || "").trim();
937
+ if (trimmed.length < BIO_MIN || trimmed.length > BIO_MAX) {
938
+ throw new Error(`description must be ${BIO_MIN}–${BIO_MAX} chars`);
939
+ }
940
+ const r = await fetch("/api/agents/" + encodeURIComponent(slug), {
941
+ method: "PATCH",
942
+ headers: { "content-type": "application/json" },
943
+ body: JSON.stringify({ bio: trimmed }),
944
+ });
945
+ if (!r.ok) {
946
+ const j = await r.json().catch(() => ({}));
947
+ throw new Error(j.error || ("HTTP " + r.status));
948
+ }
949
+ const updated = await r.json();
950
+ if (window.app) {
951
+ const live = window.app.agentsById && window.app.agentsById[slug];
952
+ if (live) live.bio = updated.bio || trimmed;
953
+ if (typeof window.app.refreshAgents === "function") {
954
+ window.app.refreshAgents().catch(() => {});
955
+ }
956
+ }
957
+ return updated;
958
+ }
959
+
960
+ function renderInstructionBlock(p, slug) {
961
+ const md = instructionFor(slug, p);
962
+ const rendered = renderMarkdown(md);
963
+ return `
964
+ <div class="ap-instr" data-ap-instr data-slug="${escape(slug)}">
965
+ <div class="ap-instr-view" data-ap-instr-view>
966
+ ${rendered || `<div class="ap-empty">no instruction yet · click <strong>edit</strong> to write one in markdown</div>`}
967
+ </div>
968
+ <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">show more</button>
969
+ </div>
970
+ `;
971
+ }
972
+
973
+ function repaintInstruction(slug, p) {
974
+ const block = document.querySelector(`[data-ap-instr][data-slug="${slug}"]`);
975
+ if (!block) return;
976
+ block.classList.remove("overflowing");
977
+ block.innerHTML = `
978
+ <div class="ap-instr-view" data-ap-instr-view>
979
+ ${renderMarkdown(instructionFor(slug, p)) || `<div class="ap-empty">no instruction yet · click <strong>edit</strong> to write one in markdown</div>`}
980
+ </div>
981
+ <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">show more</button>
982
+ `;
983
+ evaluateInstructionOverflow(slug);
984
+ }
985
+
986
+ /** After rendering, measure whether the instruction view exceeds its
987
+ * collapsed max-height. If so, mark the block as overflowing — that
988
+ * reveals the toggle button + fade gradient via CSS. Re-evaluated on
989
+ * every repaint and again on window resize (the rendered width
990
+ * changes, so the wrapped line count can change too). */
991
+ function evaluateInstructionOverflow(slug) {
992
+ const block = document.querySelector(`[data-ap-instr][data-slug="${slug}"]`);
993
+ if (!block) return;
994
+ const view = block.querySelector("[data-ap-instr-view]");
995
+ const toggle = block.querySelector("[data-ap-instr-toggle]");
996
+ if (!view || !toggle) return;
997
+ // Reset to collapsed default before measuring — prevents stale
998
+ // 'expanded' state from a prior interaction shadowing the check.
999
+ view.classList.remove("expanded");
1000
+ toggle.setAttribute("aria-expanded", "false");
1001
+ toggle.textContent = "show more";
1002
+ // scrollHeight is the full content; clientHeight is the rendered
1003
+ // (capped) height. A few-pixel epsilon avoids flagging content
1004
+ // that fits exactly at the cap as "overflowing".
1005
+ if (view.scrollHeight - view.clientHeight > 4) {
1006
+ block.classList.add("overflowing");
1007
+ } else {
1008
+ block.classList.remove("overflowing");
1009
+ }
1010
+ }
1011
+ // Re-evaluate every visible instruction block when the layout reflows
1012
+ // (sidebar resize, window resize). Debounced so resize storms don't
1013
+ // trip us — one tick after the resize ends.
1014
+ let _instrResizeTimer = null;
1015
+ window.addEventListener("resize", () => {
1016
+ if (_instrResizeTimer) clearTimeout(_instrResizeTimer);
1017
+ _instrResizeTimer = setTimeout(() => {
1018
+ document.querySelectorAll("[data-ap-instr]").forEach((b) => {
1019
+ const slug = b.getAttribute("data-slug");
1020
+ if (slug) evaluateInstructionOverflow(slug);
1021
+ });
1022
+ }, 80);
1023
+ });
1024
+ function openInstructionEditor(slug, p) {
1025
+ const block = document.querySelector(`[data-ap-instr][data-slug="${slug}"]`);
1026
+ if (!block) return;
1027
+ const md = instructionFor(slug, p);
1028
+ block.innerHTML = `
1029
+ <div class="ap-instr-edit">
1030
+ <textarea class="ap-instr-textarea" data-ap-instr-textarea spellcheck="false" placeholder="Use markdown · ### headings · **bold** · *italic* · - lists · \`code\`">${escape(md)}</textarea>
1031
+ <div class="ap-instr-edit-foot">
1032
+ <span class="ap-instr-edit-hint">markdown supported · esc to cancel</span>
1033
+ <div class="ap-instr-edit-actions">
1034
+ <button type="button" class="ap-instr-cancel" data-ap-instr-cancel>cancel</button>
1035
+ <button type="button" class="ap-instr-save" data-ap-instr-save>save</button>
1036
+ </div>
1037
+ </div>
1038
+ </div>
1039
+ `;
1040
+ const ta = block.querySelector("textarea");
1041
+ if (ta) {
1042
+ ta.focus();
1043
+ ta.setSelectionRange(ta.value.length, ta.value.length);
1044
+ }
1045
+ }
1046
+
1047
+ /** Render the RULES block · editable list of numbered constraints.
1048
+ * Mirrors the new-agent overlay UX: each row is a numbered input
1049
+ * with a trailing remove button; an "add rule" button below the
1050
+ * list (hidden when the cap of 5 is reached). All mutations
1051
+ * persist immediately via setRulesFor. */
1052
+ function renderRulesBlock(slug) {
1053
+ return `<div class="ap-rules-block" data-ap-rules-block data-slug="${escape(slug)}">${renderRulesInner(slug)}</div>`;
1054
+ }
1055
+ function renderRulesInner(slug) {
1056
+ const rules = rulesForAgent(slug);
1057
+ const list = rules.length === 0
1058
+ ? `<li class="ap-rule-empty">no rules yet · use the <strong>+ add rule</strong> button above</li>`
1059
+ : rules.map((body, i) => `
1060
+ <li class="ap-rule" data-rule-idx="${i}">
1061
+ <span class="ap-rule-num">${i + 1}</span>
1062
+ <input type="text" class="ap-rule-input"
1063
+ data-ap-rule-input="${i}"
1064
+ placeholder="never preface · cite the load-bearing claim · ..."
1065
+ maxlength="120"
1066
+ value="${escape(body)}">
1067
+ <button type="button" class="ap-rule-rm" data-ap-rule-rm="${i}" title="Remove">✕</button>
1068
+ </li>
1069
+ `).join("");
1070
+ return `<ol class="ap-rules-list">${list}</ol>`;
1071
+ }
1072
+
1073
+ /** Render the MEMORY block · live, per-agent long-term notes about
1074
+ * the user. Bootstraps with a placeholder; the actual list loads
1075
+ * asynchronously via /api/agents/:id/memories so the profile paint
1076
+ * isn't blocked by a fetch. Each row supports pin / edit / delete;
1077
+ * a + Add note input at the top lets the user manually inject
1078
+ * facts (useful when the user wants to "tell" an agent something
1079
+ * before the first room). */
1080
+ function renderMemoryBlock(slug) {
1081
+ return `
1082
+ <div class="ap-memory" data-ap-memory data-slug="${escape(slug)}">
1083
+ <div class="ap-memory-add" data-ap-memory-add-form hidden>
1084
+ <input type="text" class="ap-memory-add-input" data-ap-memory-add-input
1085
+ placeholder="add a note about yourself (4–280 chars)"
1086
+ maxlength="280" autocomplete="off">
1087
+ <button type="button" class="ap-memory-add-cancel" data-ap-memory-add-cancel>cancel</button>
1088
+ <button type="button" class="ap-memory-add-btn" data-ap-memory-add-btn>save</button>
1089
+ </div>
1090
+ <div class="ap-memory-list" data-ap-memory-list>
1091
+ <div class="ap-empty">loading…</div>
1092
+ </div>
1093
+ </div>
1094
+ `;
1095
+ }
1096
+
1097
+ /** Fetch and render this agent's memory rows. Called after the
1098
+ * profile paints (and again on every memory mutation). */
1099
+ async function loadMemoriesFor(slug) {
1100
+ const block = document.querySelector(`[data-ap-memory][data-slug="${slug}"]`);
1101
+ if (!block) return;
1102
+ const list = block.querySelector("[data-ap-memory-list]");
1103
+ if (!list) return;
1104
+ try {
1105
+ const r = await fetch("/api/agents/" + encodeURIComponent(slug) + "/memories");
1106
+ if (!r.ok) {
1107
+ const j = await r.json().catch(() => ({}));
1108
+ list.innerHTML = `<div class="ap-empty">couldn't load memory: ${escape(j.error || ("HTTP " + r.status))}</div>`;
1109
+ return;
1110
+ }
1111
+ const j = await r.json();
1112
+ const memories = Array.isArray(j.memories) ? j.memories : [];
1113
+ if (memories.length === 0) {
1114
+ list.innerHTML = `<div class="ap-empty">no memory yet · the agent will accumulate notes after each room (or add one above)</div>`;
1115
+ return;
1116
+ }
1117
+ // Cap the visible list at 5 by default · the rest live in a
1118
+ // collapsed overflow box with a "▾ Show all N memories" toggle
1119
+ // beneath. Pinned rows are always sorted first by the server
1120
+ // so the cap surfaces the most-relevant entries.
1121
+ const VISIBLE_CAP = 5;
1122
+ const visible = memories.slice(0, VISIBLE_CAP);
1123
+ const overflow = memories.slice(VISIBLE_CAP);
1124
+ const visibleHTML = visible.map(memoryRowHTML).join("");
1125
+ const overflowHTML = overflow.length > 0
1126
+ ? `
1127
+ <div class="ap-memory-overflow" data-ap-memory-overflow hidden>
1128
+ ${overflow.map(memoryRowHTML).join("")}
1129
+ </div>
1130
+ <button type="button" class="ap-memory-toggle" data-ap-memory-toggle aria-label="toggle memory">
1131
+ <span class="ap-memory-toggle-icon" aria-hidden="true">▾</span>
1132
+ <span class="ap-memory-toggle-show">Show all ${memories.length} memories</span>
1133
+ <span class="ap-memory-toggle-hide">Collapse</span>
1134
+ </button>
1135
+ `
1136
+ : "";
1137
+ list.innerHTML = visibleHTML + overflowHTML;
1138
+ } catch (e) {
1139
+ list.innerHTML = `<div class="ap-empty">couldn't load memory · ${escape(e && e.message ? e.message : String(e))}</div>`;
1140
+ }
1141
+ }
1142
+
1143
+ function memoryRowHTML(m) {
1144
+ const kindLabel = (m.kind || "fact").toLowerCase();
1145
+ const sourceTag = m.source === "user_added"
1146
+ ? "manual"
1147
+ : (m.sourceRoom ? "from room" : "extracted");
1148
+ const pinned = m.pinned === true;
1149
+ return `
1150
+ <div class="ap-memory-row${pinned ? " pinned" : ""}" data-ap-memory-row data-id="${escape(m.id)}" data-pinned="${pinned ? "1" : "0"}">
1151
+ <div class="ap-memory-content" data-ap-memory-content>${escape(m.content || "")}</div>
1152
+ <div class="ap-memory-row-foot">
1153
+ <span class="ap-memory-meta">
1154
+ <span class="ap-memory-kind">${escape(kindLabel)}</span>
1155
+ <span class="ap-memory-meta-sep">·</span>
1156
+ <span class="ap-memory-source">${escape(sourceTag)}</span>
1157
+ </span>
1158
+ <div class="ap-memory-actions">
1159
+ <button type="button" class="ap-memory-pin" data-ap-memory-pin aria-label="${pinned ? "Unpin" : "Pin"}" title="${pinned ? "Unpin" : "Pin · always inject this note"}">${pinned ? "★" : "☆"}</button>
1160
+ <button type="button" class="ap-memory-edit" data-ap-memory-edit aria-label="Edit" title="Edit">
1161
+ <svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13l-2.5.5L3 11z"/></svg>
1162
+ </button>
1163
+ <button type="button" class="ap-memory-delete" data-ap-memory-delete aria-label="Delete" title="Delete">×</button>
1164
+ </div>
1165
+ </div>
1166
+ </div>
1167
+ `;
1168
+ }
1169
+
1170
+ /** POST /api/agents/:slug/memories · used by the manual add form. */
1171
+ async function addMemoryFor(slug, content) {
1172
+ const r = await fetch("/api/agents/" + encodeURIComponent(slug) + "/memories", {
1173
+ method: "POST",
1174
+ headers: { "content-type": "application/json" },
1175
+ body: JSON.stringify({ content, kind: "fact" }),
1176
+ });
1177
+ if (!r.ok) {
1178
+ const j = await r.json().catch(() => ({}));
1179
+ throw new Error(j.error || ("HTTP " + r.status));
1180
+ }
1181
+ return r.json();
1182
+ }
1183
+ /** PATCH /api/agents/:slug/memories/:id · supports pin toggle + content edit. */
1184
+ async function patchMemory(slug, id, patch) {
1185
+ const r = await fetch(
1186
+ "/api/agents/" + encodeURIComponent(slug) + "/memories/" + encodeURIComponent(id),
1187
+ {
1188
+ method: "PATCH",
1189
+ headers: { "content-type": "application/json" },
1190
+ body: JSON.stringify(patch),
1191
+ },
1192
+ );
1193
+ if (!r.ok) {
1194
+ const j = await r.json().catch(() => ({}));
1195
+ throw new Error(j.error || ("HTTP " + r.status));
1196
+ }
1197
+ return r.json();
1198
+ }
1199
+ /** DELETE /api/agents/:slug/memories/:id */
1200
+ async function deleteMemoryFor(slug, id) {
1201
+ const r = await fetch(
1202
+ "/api/agents/" + encodeURIComponent(slug) + "/memories/" + encodeURIComponent(id),
1203
+ { method: "DELETE" },
1204
+ );
1205
+ if (!r.ok) {
1206
+ const j = await r.json().catch(() => ({}));
1207
+ throw new Error(j.error || ("HTTP " + r.status));
1208
+ }
1209
+ }
1210
+
1211
+ /* ─── Skills v2 · uploaded Skill.md files ────────────────────────────
1212
+ Replaces the prototype localStorage skill grid. Skills are real,
1213
+ server-persisted, and feed both the ability radar and the Pass-1
1214
+ orchestrator router. PRD: docs/PRD-skills.md. */
1215
+
1216
+ const SKILL_AXES = ["dissent", "pattern_recall", "rigor", "empathy", "narrative", "decisiveness"];
1217
+ const SKILL_AXIS_LABEL = {
1218
+ dissent: "DISSENT",
1219
+ pattern_recall: "RECALL",
1220
+ rigor: "RIGOR",
1221
+ empathy: "EMPATHY",
1222
+ narrative: "NARRATIVE",
1223
+ decisiveness: "DECIDE",
1224
+ };
1225
+ const SKILL_AXIS_MAX = 10;
1226
+ const SKILL_CAP = { chair: 12, director: 5 };
1227
+
1228
+ /** Per-seed-agent base profile (0–10 per axis). Custom agents default
1229
+ * to a flat 5/all. These bias the radar at base; installed skills
1230
+ * modify by their delta values. */
1231
+ const SKILL_BASE_PROFILES = {
1232
+ "socrates": { dissent: 9, pattern_recall: 4, rigor: 8, empathy: 4, narrative: 5, decisiveness: 4 },
1233
+ "first-principles": { dissent: 6, pattern_recall: 5, rigor: 9, empathy: 3, narrative: 4, decisiveness: 6 },
1234
+ "value-investor": { dissent: 6, pattern_recall: 9, rigor: 7, empathy: 4, narrative: 6, decisiveness: 7 },
1235
+ "user-empathy": { dissent: 5, pattern_recall: 4, rigor: 5, empathy: 9, narrative: 8, decisiveness: 5 },
1236
+ "long-horizon": { dissent: 4, pattern_recall: 8, rigor: 6, empathy: 5, narrative: 7, decisiveness: 6 },
1237
+ "phenomenologist": { dissent: 5, pattern_recall: 4, rigor: 5, empathy: 7, narrative: 6, decisiveness: 4 },
1238
+ "chair": { dissent: 5, pattern_recall: 6, rigor: 6, empathy: 7, narrative: 6, decisiveness: 8 },
1239
+ };
1240
+ function baseAbilityFor(slug) {
1241
+ // Live agent record (custom directors) wins over the seed table:
1242
+ // the AI-generated spec ships an `ability` map that reflects the
1243
+ // user's description, so the radar shows real personality instead
1244
+ // of a flat 5/all.
1245
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
1246
+ if (live && live.ability && typeof live.ability === "object") {
1247
+ const profile = {};
1248
+ let hasAny = false;
1249
+ for (const a of SKILL_AXES) {
1250
+ const v = live.ability[a];
1251
+ if (typeof v === "number" && Number.isFinite(v)) {
1252
+ profile[a] = Math.max(0, Math.min(SKILL_AXIS_MAX, v));
1253
+ hasAny = true;
1254
+ } else {
1255
+ profile[a] = 5;
1256
+ }
1257
+ }
1258
+ if (hasAny) return profile;
1259
+ }
1260
+ const p = SKILL_BASE_PROFILES[slug];
1261
+ if (p) return Object.assign({}, p);
1262
+ const flat = {};
1263
+ for (const a of SKILL_AXES) flat[a] = 5;
1264
+ return flat;
1265
+ }
1266
+
1267
+ function clampAxis(v) {
1268
+ if (!Number.isFinite(v)) return 0;
1269
+ return Math.max(0, Math.min(SKILL_AXIS_MAX, v));
1270
+ }
1271
+
1272
+ /** Sum base + every installed skill's delta on each axis. Returns a
1273
+ * map clamped to [0, SKILL_AXIS_MAX]. */
1274
+ function computeAbility(slug, skills) {
1275
+ const base = baseAbilityFor(slug);
1276
+ const out = Object.assign({}, base);
1277
+ for (const s of skills || []) {
1278
+ const ab = s.ability || {};
1279
+ for (const axis of SKILL_AXES) {
1280
+ if (typeof ab[axis] === "number") out[axis] = (out[axis] || 0) + ab[axis];
1281
+ }
1282
+ }
1283
+ for (const axis of SKILL_AXES) out[axis] = clampAxis(out[axis]);
1284
+ return out;
1285
+ }
1286
+
1287
+ /** Render the radar as inline SVG · faint base outline + filled current
1288
+ * shape + axis labels. Pure SVG so it scales cleanly and stays theme-
1289
+ * aware via currentColor / CSS vars. */
1290
+ function renderRadar(slug, skills) {
1291
+ const base = baseAbilityFor(slug);
1292
+ const cur = computeAbility(slug, skills);
1293
+ // viewBox sized to leave ~58px of horizontal padding on each side
1294
+ // so the longest axis label ("NARRATIVE", ~50px wide at 8px mono)
1295
+ // never gets clipped by the SVG bounds. Vertical padding stays
1296
+ // tight since the top/bottom labels are short ("DISSENT" / "EMPATHY").
1297
+ const cx = 150;
1298
+ const cy = 105;
1299
+ const r = 78;
1300
+ const vbW = 300;
1301
+ const vbH = 210;
1302
+ const axes = SKILL_AXES.length;
1303
+ const angles = SKILL_AXES.map((_, i) => (-Math.PI / 2) + (2 * Math.PI * i) / axes);
1304
+ function point(value, idx) {
1305
+ const ratio = clampAxis(value) / SKILL_AXIS_MAX;
1306
+ const a = angles[idx];
1307
+ return [cx + Math.cos(a) * r * ratio, cy + Math.sin(a) * r * ratio];
1308
+ }
1309
+ function ring(ratio) {
1310
+ return SKILL_AXES.map((_, i) => {
1311
+ const a = angles[i];
1312
+ const x = cx + Math.cos(a) * r * ratio;
1313
+ const y = cy + Math.sin(a) * r * ratio;
1314
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
1315
+ }).join(" ");
1316
+ }
1317
+ const basePoly = SKILL_AXES.map((axis, i) => point(base[axis], i).map((n) => n.toFixed(1)).join(",")).join(" ");
1318
+ const curPoly = SKILL_AXES.map((axis, i) => point(cur[axis], i).map((n) => n.toFixed(1)).join(",")).join(" ");
1319
+ const labels = SKILL_AXES.map((axis, i) => {
1320
+ const a = angles[i];
1321
+ const lr = r + 14;
1322
+ const lx = cx + Math.cos(a) * lr;
1323
+ const ly = cy + Math.sin(a) * lr;
1324
+ let anchor = "middle";
1325
+ if (Math.abs(Math.cos(a)) > 0.4) anchor = Math.cos(a) > 0 ? "start" : "end";
1326
+ return `<text x="${lx.toFixed(1)}" y="${(ly + 3).toFixed(1)}" text-anchor="${anchor}" class="ap-radar-axis-label">${SKILL_AXIS_LABEL[axis]}</text>`;
1327
+ }).join("");
1328
+ const spokes = SKILL_AXES.map((_, i) => {
1329
+ const [x, y] = point(SKILL_AXIS_MAX, i);
1330
+ return `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" class="ap-radar-spoke"/>`;
1331
+ }).join("");
1332
+ const rings = [0.33, 0.66, 1].map((ratio) =>
1333
+ `<polygon points="${ring(ratio)}" class="ap-radar-grid"/>`,
1334
+ ).join("");
1335
+ return `
1336
+ <svg class="ap-radar" viewBox="0 0 ${vbW} ${vbH}" xmlns="http://www.w3.org/2000/svg" aria-label="Ability radar">
1337
+ ${rings}
1338
+ ${spokes}
1339
+ <polygon points="${basePoly}" class="ap-radar-base"/>
1340
+ <polygon points="${curPoly}" class="ap-radar-current"/>
1341
+ ${labels}
1342
+ </svg>
1343
+ `;
1344
+ }
1345
+
1346
+ function deltaChip(axis, value) {
1347
+ if (!Number.isFinite(value) || value === 0) return "";
1348
+ const sign = value > 0 ? "+" : "";
1349
+ const cls = value > 0 ? "pos" : "neg";
1350
+ return `<span class="ap-skill-chip ${cls}">${SKILL_AXIS_LABEL[axis]} ${sign}${value}</span>`;
1351
+ }
1352
+
1353
+ /** Compact inline delta string · "rigor +2 · depth +3" with each
1354
+ * axis label muted and the value color-coded by sign. Mono font, no
1355
+ * borders — keeps the row to a single line at common widths. */
1356
+ function inlineDeltas(ability) {
1357
+ if (!ability) return "";
1358
+ const parts = [];
1359
+ for (const axis of SKILL_AXES) {
1360
+ const v = ability[axis];
1361
+ if (typeof v !== "number" || v === 0) continue;
1362
+ const sign = v > 0 ? "+" : "";
1363
+ const cls = v > 0 ? "pos" : "neg";
1364
+ parts.push(`<span class="ap-sd"><span class="ap-sd-l">${SKILL_AXIS_LABEL[axis].toLowerCase()}</span><span class="ap-sd-v ${cls}">${sign}${v}</span></span>`);
1365
+ }
1366
+ return parts.join("");
1367
+ }
1368
+
1369
+ function renderSkillRow(skill, agentSlug) {
1370
+ const isSystem = !!skill.system;
1371
+ const tipsAttr = JSON.stringify({
1372
+ id: skill.id,
1373
+ name: skill.name,
1374
+ slug: skill.slug,
1375
+ version: skill.version || "1.0",
1376
+ description: skill.description,
1377
+ whenToUse: skill.whenToUse,
1378
+ ability: skill.ability || {},
1379
+ tips: skill.tips || [],
1380
+ system: isSystem,
1381
+ // System-skill-specific runtime state (e.g. web-search's
1382
+ // `keyConfigured` and `enabled`). Used by the ⋯ popover to
1383
+ // surface contextual actions like "Configure key".
1384
+ state: skill.state || null,
1385
+ });
1386
+ // System-ness is already communicated by the row's mark glyph (▣
1387
+ // vs ◆) and by the lock note in the ⋯ popover — no inline "system"
1388
+ // badge here, otherwise rows like web-search read as cluttered
1389
+ // (and inconsistent with fetch-url / report-writer in chair-only
1390
+ // historical UI, where the badge wasn't surfaced either).
1391
+ const mark = isSystem ? "▣" : "◆";
1392
+ const titleText = isSystem
1393
+ ? `${skill.name} · ${skill.slug} · system skill`
1394
+ : `${skill.name} · ${skill.slug}`;
1395
+
1396
+ // Web-search · special row · the deltas slot is replaced by a
1397
+ // toggle. The toggle is always rendered (no layout shift between
1398
+ // states); when the Brave key isn't configured the click handler
1399
+ // prompts the user before opening Preferences. The dotted "needs
1400
+ // key" decoration cues the requirement without consuming the
1401
+ // limited horizontal space the cramped configure button used to.
1402
+ let middleCell = `<span class="ap-skill-row-deltas">${inlineDeltas(skill.ability) || `<span class="ap-sd-empty">no axis change</span>`}</span>`;
1403
+ if (skill.slug === "web-search" && skill.state) {
1404
+ const st = skill.state || {};
1405
+ const keyOk = !!st.keyConfigured;
1406
+ // Visual ON only when the key is configured AND the per-agent
1407
+ // flag is set. With no key, force OFF visually regardless of the
1408
+ // stored flag — the agent can't search either way.
1409
+ const enabled = keyOk && !!st.enabled;
1410
+ const provider = st.requiresKey && st.requiresKey.provider ? st.requiresKey.provider : "brave";
1411
+ const cls = ["ap-skill-row-toggle", enabled ? "on" : "off", keyOk ? "" : "needs-key"]
1412
+ .filter(Boolean)
1413
+ .join(" ");
1414
+ const titleText = keyOk
1415
+ ? (enabled ? "Disable Web Search for this director" : "Enable Web Search for this director")
1416
+ : "Web Search needs a Brave Search API key — click to configure";
1417
+ // When the global key is missing, omit the text label entirely
1418
+ // so the row stays compact. The dotted toggle track + hover
1419
+ // tooltip communicate the state on its own; the just-in-time
1420
+ // confirm prompt explains the rest at click time.
1421
+ const labelHtml = keyOk
1422
+ ? `<span class="ap-skill-row-toggle-text">${enabled ? "enabled" : "disabled"}</span>`
1423
+ : "";
1424
+ middleCell = `
1425
+ <button type="button" class="${cls}"
1426
+ data-ap-ws-toggle
1427
+ data-agent-slug="${escape(agentSlug || "")}"
1428
+ data-enabled="${enabled ? "1" : "0"}"
1429
+ data-key-configured="${keyOk ? "1" : "0"}"
1430
+ data-provider="${escape(provider)}"
1431
+ aria-pressed="${enabled ? "true" : "false"}"
1432
+ title="${escape(titleText)}">
1433
+ <span class="ap-skill-row-toggle-track"><span class="ap-skill-row-toggle-knob"></span></span>
1434
+ ${labelHtml}
1435
+ </button>
1436
+ `;
1437
+ }
1438
+
1439
+ return `
1440
+ <div class="ap-skill-row${isSystem ? " ap-skill-row-system" : ""}${skill.slug === "web-search" ? " ap-skill-row-web-search" : ""}" data-ap-skill-row data-skill-id="${escape(skill.id)}" title="${escape(titleText)}">
1441
+ <span class="ap-skill-row-mark">${mark}</span>
1442
+ <span class="ap-skill-row-name">${escape(skill.name)}</span>
1443
+ ${middleCell}
1444
+ <button type="button" class="ap-skill-row-menu" data-ap-skill-info data-tip='${escape(tipsAttr)}' aria-label="Skill details" title="Details">⋯</button>
1445
+ </div>
1446
+ `;
1447
+ }
1448
+
1449
+ /** Render the Skills block shell. Real content (radar, list) is filled
1450
+ * in by loadSkillsForV2 after the API fetch. */
1451
+ function renderSkillsBlockV2(slug, isChair) {
1452
+ const cap = isChair ? SKILL_CAP.chair : SKILL_CAP.director;
1453
+ return `
1454
+ <div class="ap-skills-v2" data-ap-skills data-slug="${escape(slug)}" data-cap="${cap}">
1455
+ <div class="ap-skills-radar-wrap" data-ap-skills-radar>${renderRadar(slug, [])}</div>
1456
+ <div class="ap-skills-list" data-ap-skills-list>
1457
+ <div class="ap-empty">loading skills…</div>
1458
+ </div>
1459
+ <div class="ap-skills-drop" data-ap-skills-drop tabindex="0" role="button" aria-label="Install a skill from a .md file">
1460
+ <input type="file" accept=".md,text/markdown,text/plain" data-ap-skills-file hidden>
1461
+ <span class="ap-skills-drop-mark">⊕</span>
1462
+ <span class="ap-skills-drop-text">install skill · drop a <code>.md</code> file or click</span>
1463
+ </div>
1464
+ </div>
1465
+ `;
1466
+ }
1467
+
1468
+ /** Fetch the agent's skills, repaint the radar + list, update header
1469
+ * count tag, and gate the drop-zone when the cap is reached. */
1470
+ async function loadSkillsForV2(slug) {
1471
+ const block = document.querySelector(`[data-ap-skills][data-slug="${slug}"]`);
1472
+ if (!block) return;
1473
+ const list = block.querySelector("[data-ap-skills-list]");
1474
+ const radarWrap = block.querySelector("[data-ap-skills-radar]");
1475
+ const drop = block.querySelector("[data-ap-skills-drop]");
1476
+ const cap = parseInt(block.getAttribute("data-cap") || "5", 10);
1477
+ try {
1478
+ const r = await fetch("/api/agents/" + encodeURIComponent(slug) + "/skills");
1479
+ if (!r.ok) {
1480
+ const j = await r.json().catch(() => ({}));
1481
+ list.innerHTML = `<div class="ap-empty">couldn't load skills · ${escape(j.error || ("HTTP " + r.status))}</div>`;
1482
+ return;
1483
+ }
1484
+ const { skills } = await r.json();
1485
+ // System skills don't count toward the user-installable cap — they
1486
+ // ride along on top.
1487
+ const userSkills = skills.filter((s) => !s.system);
1488
+ const userCount = userSkills.length;
1489
+ // Header count tag (e.g. "3 / 5 installed").
1490
+ const card = block.closest(".ap-block");
1491
+ const countTag = card?.querySelector("[data-ap-skills-count]");
1492
+ if (countTag) countTag.textContent = `${userCount} / ${cap} installed`;
1493
+ // Radar reflects user skills only — the system skill has no
1494
+ // turn-time ability deltas (it runs at brief time).
1495
+ if (radarWrap) radarWrap.innerHTML = renderRadar(slug, userSkills);
1496
+ // List · render system skills first, then user skills. Empty state
1497
+ // (no skills at all) intentionally renders nothing.
1498
+ if (list) {
1499
+ list.innerHTML = skills.length === 0 ? "" : skills.map((s) => renderSkillRow(s, slug)).join("");
1500
+ list.hidden = skills.length === 0;
1501
+ }
1502
+ // Drop-zone gate · gated by user-installable cap, not total.
1503
+ if (drop) {
1504
+ if (userCount >= cap) {
1505
+ drop.classList.add("disabled");
1506
+ drop.setAttribute("aria-disabled", "true");
1507
+ const txt = drop.querySelector(".ap-skills-drop-text");
1508
+ if (txt) txt.innerHTML = `cap reached (${userCount}/${cap}) · uninstall to make room`;
1509
+ } else {
1510
+ drop.classList.remove("disabled");
1511
+ drop.removeAttribute("aria-disabled");
1512
+ const txt = drop.querySelector(".ap-skills-drop-text");
1513
+ if (txt) txt.innerHTML = `install skill · drop a <code>.md</code> file or click`;
1514
+ }
1515
+ }
1516
+ } catch (e) {
1517
+ list.innerHTML = `<div class="ap-empty">couldn't load skills · ${escape(e && e.message ? e.message : String(e))}</div>`;
1518
+ }
1519
+ }
1520
+
1521
+ async function installSkillFromText(slug, mdText) {
1522
+ const r = await fetch("/api/agents/" + encodeURIComponent(slug) + "/skills", {
1523
+ method: "POST",
1524
+ headers: { "content-type": "application/json" },
1525
+ body: JSON.stringify({ md: mdText }),
1526
+ });
1527
+ if (!r.ok) {
1528
+ const j = await r.json().catch(() => ({}));
1529
+ throw new Error(j.error || ("HTTP " + r.status));
1530
+ }
1531
+ return r.json();
1532
+ }
1533
+ /** Open a fixed-position popover anchored above the trigger button.
1534
+ * Reads serialized skill payload from the trigger's data-tip JSON.
1535
+ * Folds info + uninstall into a single menu so the row stays
1536
+ * single-line dense (no separate × button). */
1537
+ function openSkillInfoPopover(trigger) {
1538
+ // Tear down any existing popover.
1539
+ const existing = document.getElementById("ap-skill-info-pop");
1540
+ if (existing) existing.remove();
1541
+ let payload = null;
1542
+ try { payload = JSON.parse(trigger.getAttribute("data-tip") || ""); } catch (_) {}
1543
+ if (!payload) return;
1544
+ const tipsHtml = (payload.tips || []).length
1545
+ ? `<ul class="ap-skill-info-tips">${(payload.tips || []).map((t) => `<li>${escape(t)}</li>`).join("")}</ul>`
1546
+ : `<div class="ap-skill-info-empty">no tips provided</div>`;
1547
+ // Full ability block when present.
1548
+ const abilityEntries = SKILL_AXES
1549
+ .filter((a) => payload.ability && typeof payload.ability[a] === "number" && payload.ability[a] !== 0)
1550
+ .map((a) => {
1551
+ const v = payload.ability[a];
1552
+ const sign = v > 0 ? "+" : "";
1553
+ const cls = v > 0 ? "pos" : "neg";
1554
+ return `<span class="ap-sd"><span class="ap-sd-l">${SKILL_AXIS_LABEL[a].toLowerCase()}</span><span class="ap-sd-v ${cls}">${sign}${v}</span></span>`;
1555
+ })
1556
+ .join("");
1557
+ const block = trigger.closest("[data-ap-skills]");
1558
+ const slug = block?.getAttribute("data-slug") || "";
1559
+ const pop = document.createElement("div");
1560
+ pop.id = "ap-skill-info-pop";
1561
+ pop.className = "ap-skill-info-pop";
1562
+ const isSystem = !!payload.system;
1563
+ const headBadge = isSystem
1564
+ ? `<span class="ap-skill-info-sys-badge" title="System skill · cannot be modified">system</span>`
1565
+ : "";
1566
+ // Web-search · when the global Brave key is missing, surface a
1567
+ // "Configure key" action right inside the popover so users can
1568
+ // reach Preferences from either the toggle OR the ⋯ menu. The
1569
+ // state field is the same one ferried in via the row's data-tip.
1570
+ const wsState = payload.state || null;
1571
+ const wsNeedsKey = isSystem
1572
+ && payload.slug === "web-search"
1573
+ && wsState
1574
+ && !wsState.keyConfigured;
1575
+ const wsProvider = wsState && wsState.requiresKey && wsState.requiresKey.provider
1576
+ ? wsState.requiresKey.provider
1577
+ : "brave";
1578
+ const actionsHtml = isSystem
1579
+ ? `
1580
+ ${wsNeedsKey ? `
1581
+ <div class="ap-skill-info-actions">
1582
+ <button type="button" class="ap-skill-info-configure" data-ap-ws-configure data-provider="${escape(wsProvider)}">
1583
+ <span class="ap-skill-info-configure-mark">↗</span>
1584
+ <span>configure brave search api key</span>
1585
+ </button>
1586
+ </div>
1587
+ ` : ""}
1588
+ <div class="ap-skill-info-locked"><span class="ap-skill-info-locked-mark">⊙</span><span>system skill · cannot be uninstalled or edited</span></div>
1589
+ `
1590
+ : `<div class="ap-skill-info-actions"><button type="button" class="ap-skill-info-uninstall" data-ap-skill-popover-uninstall data-skill-id="${escape(payload.id || "")}" data-slug="${escape(slug)}">⊘ uninstall</button></div>`;
1591
+ pop.innerHTML = `
1592
+ <div class="ap-skill-info-head">${escape(payload.name || "Skill")}${headBadge}</div>
1593
+ <div class="ap-skill-info-sub">${escape(payload.slug || "")} · v${escape(payload.version || "1.0")}</div>
1594
+ ${payload.description ? `<div class="ap-skill-info-desc">${escape(payload.description)}</div>` : ""}
1595
+ ${payload.whenToUse && payload.whenToUse !== payload.description
1596
+ ? `<div class="ap-skill-info-when"><span class="lbl">when to use</span>${escape(payload.whenToUse)}</div>`
1597
+ : ""}
1598
+ ${abilityEntries
1599
+ ? `<div class="ap-skill-info-ability"><span class="lbl">ability deltas</span><div class="ap-skill-info-ability-row">${abilityEntries}</div></div>`
1600
+ : ""}
1601
+ <div class="ap-skill-info-tips-wrap"><span class="lbl">tips</span>${tipsHtml}</div>
1602
+ ${actionsHtml}
1603
+ `;
1604
+ document.body.appendChild(pop);
1605
+ const rect = trigger.getBoundingClientRect();
1606
+ const popW = 300;
1607
+ let left = rect.left + (rect.width / 2) - (popW / 2);
1608
+ if (left < 8) left = 8;
1609
+ if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8;
1610
+ pop.style.left = `${left}px`;
1611
+ // Prefer above; flip below if there's no room.
1612
+ const above = rect.top - 8;
1613
+ pop.style.top = `${above}px`;
1614
+ pop.style.transform = "translateY(-100%)";
1615
+ if (above < 8) {
1616
+ pop.style.top = `${rect.bottom + 8}px`;
1617
+ pop.style.transform = "none";
1618
+ }
1619
+ }
1620
+
1621
+ async function uninstallSkillReq(slug, skillId) {
1622
+ const r = await fetch(
1623
+ "/api/agents/" + encodeURIComponent(slug) + "/skills/" + encodeURIComponent(skillId),
1624
+ { method: "DELETE" },
1625
+ );
1626
+ if (!r.ok) {
1627
+ const j = await r.json().catch(() => ({}));
1628
+ throw new Error(j.error || ("HTTP " + r.status));
1629
+ }
1630
+ }
1631
+
1632
+ /** Split "Room #047" → { label: "ROOM", id: "#047" }. Falls back to
1633
+ * treating the whole string as the id when there's no label part. */
1634
+ function parseMemoryNum(raw) {
1635
+ if (!raw) return { label: "ROOM", id: "—" };
1636
+ const s = String(raw).trim();
1637
+ const m = /^([A-Za-z]+)\s*(#?\s*[\w-]+)$/.exec(s);
1638
+ if (m) return { label: m[1].toUpperCase(), id: m[2].replace(/\s+/g, "") };
1639
+ return { label: "ROOM", id: s };
1640
+ }
1641
+
1642
+ /** Render the inner contents of a memory tile when opened. */
1643
+ function renderMemoryDetail(p, key) {
1644
+ if (!p) return "";
1645
+ if (key === "user") {
1646
+ const u = p.memory && p.memory.aboutUser;
1647
+ if (!u) return "";
1648
+ return `
1649
+ <div class="ap-memory-detail-head">
1650
+ <div class="ap-memory-detail-num">YOU</div>
1651
+ <div class="ap-memory-detail-name">${escape(u.headline || "About you")}</div>
1652
+ </div>
1653
+ ${u.summary && u.summary.length
1654
+ ? `<div class="ap-memory-detail-body">${u.summary.map((s) => `<p>${escape(s)}</p>`).join("")}</div>`
1655
+ : `<div class="ap-empty">no notes</div>`}
1656
+ `;
1657
+ }
1658
+ const m = /^room:(\d+)$/.exec(key || "");
1659
+ if (!m) return "";
1660
+ const idx = parseInt(m[1], 10);
1661
+ const r = (p.memory && p.memory.rooms || [])[idx];
1662
+ if (!r) return "";
1663
+ return `
1664
+ <div class="ap-memory-detail-head">
1665
+ <div class="ap-memory-detail-num">${escape(r.num || "—")}</div>
1666
+ <div>
1667
+ <div class="ap-memory-detail-name">${escape(r.name || "Untitled room")}</div>
1668
+ ${r.stats ? `
1669
+ <div class="ap-memory-detail-stats">
1670
+ ${r.stats.sessions ? escape(r.stats.sessions + " sess") : ""}
1671
+ ${r.stats.turns ? " · " + escape(r.stats.turns + " turns") : ""}
1672
+ ${r.stats.last ? " · last " + escape(r.stats.last) : ""}
1673
+ </div>
1674
+ ` : ""}
1675
+ </div>
1676
+ </div>
1677
+ ${r.summary ? `<p class="ap-memory-detail-summary">${escape(r.summary)}</p>` : ""}
1678
+ ${Array.isArray(r.lessons) && r.lessons.length ? `
1679
+ <ul class="ap-memory-detail-lessons">
1680
+ ${r.lessons.map((l) => `<li>${escape(l)}</li>`).join("")}
1681
+ </ul>
1682
+ ` : ""}
1683
+ `;
1684
+ }
1685
+
1686
+ /* ─── Profile · ⋯ menu (top-right of the cover) ─────
1687
+ Small popover anchored to the menu button with one or more
1688
+ actions. v1 ships a single "regenerate 8-bit avatar" item. */
1689
+ function openProfileIdMenu(anchor) {
1690
+ closeProfileIdMenu();
1691
+ const slug = anchor.getAttribute("data-slug");
1692
+ if (!slug) return;
1693
+ // Chair has a fixed identity across rooms; the avatar is part of
1694
+ // their recognisability, so we lock the regen action and surface
1695
+ // a disabled item with a "why" so users don't wonder where it
1696
+ // went. Server-side PATCH also rejects avatar changes for the
1697
+ // moderator (defense in depth).
1698
+ //
1699
+ // Delete · only surfaced for user-created (non-seed, non-chair)
1700
+ // directors. Sits at the bottom of the menu under a hairline
1701
+ // divider so it reads as a destructive action separate from the
1702
+ // routine ones.
1703
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
1704
+ const isChair = !!(live && live.roleKind === "moderator");
1705
+ const isSeed = !!(live && live.isSeed);
1706
+ const isCustom = !!live && !isChair && !isSeed;
1707
+ const parts = [];
1708
+ if (isChair) {
1709
+ parts.push(`
1710
+ <div class="ap-id-menu-item disabled" aria-disabled="true">
1711
+ <span class="ap-id-menu-mark">⊘</span>
1712
+ <span>Avatar locked · chair identity is fixed</span>
1713
+ </div>`);
1714
+ } else {
1715
+ parts.push(`
1716
+ <button type="button" class="ap-id-menu-item" data-ap-menu-action="regen-avatar">
1717
+ <span class="ap-id-menu-mark">◆</span>
1718
+ <span>Regenerate 8-bit avatar</span>
1719
+ </button>`);
1720
+ }
1721
+ if (isCustom) {
1722
+ parts.push(`<div class="ap-id-menu-divider" aria-hidden="true"></div>`);
1723
+ parts.push(`
1724
+ <button type="button" class="ap-id-menu-item ap-id-menu-item-danger" data-ap-menu-action="delete">
1725
+ <span class="ap-id-menu-mark">✕</span>
1726
+ <span>Delete director</span>
1727
+ </button>`);
1728
+ }
1729
+ const pop = document.createElement("div");
1730
+ pop.id = "ap-id-menu-pop";
1731
+ pop.className = "ap-id-menu-pop";
1732
+ pop.dataset.slug = slug;
1733
+ pop.innerHTML = parts.join("");
1734
+ document.body.appendChild(pop);
1735
+ const r = anchor.getBoundingClientRect();
1736
+ pop.style.top = `${Math.round(r.bottom + 6)}px`;
1737
+ pop.style.right = `${Math.round(window.innerWidth - r.right)}px`;
1738
+ }
1739
+ function closeProfileIdMenu() {
1740
+ const el = document.getElementById("ap-id-menu-pop");
1741
+ if (el) el.remove();
1742
+ }
1743
+
1744
+ /** Generate a fresh 8-bit SVG and persist it as the agent's
1745
+ * avatar. Updates the live store so subsequent renders use the
1746
+ * new image, then repaints the profile in place. Seeded directors
1747
+ * fall back to a localStorage override (the server only stores
1748
+ * user-created agents). */
1749
+ async function regenerateProfileAvatar(slug) {
1750
+ const skill = window.AvatarSkill;
1751
+ if (!skill) return;
1752
+ const seed = skill.randomSeed();
1753
+ const svg = skill.generate(seed);
1754
+ const dataUrl = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
1755
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
1756
+ if (live) {
1757
+ try {
1758
+ const res = await fetch("/api/agents/" + encodeURIComponent(slug), {
1759
+ method: "PATCH",
1760
+ headers: { "content-type": "application/json" },
1761
+ body: JSON.stringify({ avatarPath: dataUrl }),
1762
+ });
1763
+ if (!res.ok) throw new Error("avatar update failed");
1764
+ const updated = await res.json();
1765
+ // Refresh the in-memory roster so the sidebar + room views
1766
+ // pick up the new avatar.
1767
+ live.avatarPath = updated.avatarPath || dataUrl;
1768
+ if (typeof window.app.refreshAgents === "function") {
1769
+ await window.app.refreshAgents();
1770
+ } else if (typeof window.app.renderSidebarAgents === "function") {
1771
+ window.app.renderSidebarAgents();
1772
+ }
1773
+ } catch (e) {
1774
+ console.error("[profile] regenerate avatar failed", e);
1775
+ alert("Couldn't save the new avatar: " + (e && e.message ? e.message : e));
1776
+ return;
1777
+ }
1778
+ } else {
1779
+ // Seeded profile · stash an override locally so the profile
1780
+ // view shows the new look on this device.
1781
+ try {
1782
+ localStorage.setItem("boardroom.agent.avatar." + slug, dataUrl);
1783
+ } catch (_) {}
1784
+ const seeded = PROFILES[slug];
1785
+ if (seeded) seeded.avatar = dataUrl;
1786
+ }
1787
+ // Repaint the profile in-place so the new avatar shows.
1788
+ const v = getMainViews();
1789
+ if (v.agent && !v.agent.hasAttribute("hidden")) open(slug);
1790
+ }
1791
+
1792
+ /** Resolve a profile object from a slug · seeded first, then live. */
1793
+ function profileForSlug(slug) {
1794
+ let p = PROFILES[slug];
1795
+ if (!p) {
1796
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
1797
+ p = buildLiveProfile(live);
1798
+ }
1799
+ return p || null;
1800
+ }
1801
+
1802
+ function openMemoryOverlay(slug, key) {
1803
+ closeMemoryOverlay();
1804
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
1805
+ const p = PROFILES[slug] || buildLiveProfile(live);
1806
+ if (!p) return;
1807
+ const wrap = document.createElement("div");
1808
+ wrap.id = "ap-memory-overlay";
1809
+ wrap.className = "ap-memory-overlay";
1810
+ wrap.innerHTML = `
1811
+ <div class="ap-memory-overlay-backdrop" data-ap-memory-close></div>
1812
+ <div class="ap-memory-overlay-card" role="dialog" aria-modal="true">
1813
+ <button type="button" class="ap-memory-overlay-close" data-ap-memory-close aria-label="close">×</button>
1814
+ ${renderMemoryDetail(p, key)}
1815
+ </div>
1816
+ `;
1817
+ document.body.appendChild(wrap);
1818
+ }
1819
+ function closeMemoryOverlay() {
1820
+ const el = document.getElementById("ap-memory-overlay");
1821
+ if (el) el.remove();
1822
+ }
1823
+
1824
+ /** Strip the small inline <span> markup that the seeded PROFILES use
1825
+ * for emphasized terms — we want plain text in the new chrome. */
1826
+ function stripTagsToText(html) {
1827
+ return String(html || "").replace(/<\/?[^>]+>/g, "");
1828
+ }
1829
+
1830
+ /* ─── Base-model selection ────────────────────────
1831
+ Latest tier only — older lines (Sonnet 4.6, GPT-5, Gemini 2.5,
1832
+ Grok 4 mini, etc.) were retired per the user's brief. Per-director
1833
+ choice persists in localStorage so tweaks survive a page reload. */
1834
+ // Curated latest mainstream lineup · ids verified against the live
1835
+ // OpenRouter catalog (https://openrouter.ai/api/v1/models). Each
1836
+ // entry's `v` resolves through src/ai/registry.ts to the dated
1837
+ // OpenRouter id, so verify + room calls hit the right model.
1838
+ const PROFILE_MODELS = [
1839
+ // Anthropic
1840
+ { v: "opus-4-7", name: "Claude Opus 4.7", provider: "Anthropic", deck: "deep reasoning · default" },
1841
+ { v: "sonnet-4-6", name: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · 1M ctx" },
1842
+ { v: "haiku-4-5", name: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
1843
+ // OpenAI
1844
+ { v: "gpt-5-5-pro", name: "GPT-5.5 Pro", provider: "OpenAI", deck: "flagship · 1M ctx" },
1845
+ { v: "gpt-5-5", name: "GPT-5.5", provider: "OpenAI", deck: "1M ctx" },
1846
+ { v: "gpt-5-4", name: "GPT-5.4", provider: "OpenAI", deck: "general · 1M ctx" },
1847
+ { v: "gpt-5-4-mini", name: "GPT-5.4 Mini", provider: "OpenAI", deck: "fast · 400k ctx" },
1848
+ { v: "codex-5-4", name: "ChatGPT Codex 5.4", provider: "OpenAI", deck: "code · agents" },
1849
+ // Google
1850
+ { v: "gemini-3-1", name: "Gemini 3.1 Pro", provider: "Google", deck: "multimodal · 1M ctx" },
1851
+ { v: "gemini-3-1-flash",name: "Gemini 3.1 Flash", provider: "Google", deck: "fast · 1M ctx" },
1852
+ // xAI
1853
+ { v: "grok-4-3", name: "Grok 4.3", provider: "xAI", deck: "1M ctx" },
1854
+ { v: "grok-4-20", name: "Grok 4.20", provider: "xAI", deck: "2M ctx · big context" },
1855
+ // DeepSeek
1856
+ { v: "deepseek-v4-pro", name: "DeepSeek V4 Pro", provider: "DeepSeek", deck: "reasoning · open weights" }
1857
+ ];
1858
+ function modelKey(slug) { return "boardroom.agent.model." + slug; }
1859
+
1860
+ /** Helpers · provider label + tiny route badge. Mirrors the same
1861
+ * helpers in app.js so the visual vocabulary (direct / OR /
1862
+ * direct · OR) stays consistent across all pickers. */
1863
+ function providerLabel(p) {
1864
+ switch (p) {
1865
+ case "anthropic": return "Anthropic";
1866
+ case "openai": return "OpenAI";
1867
+ case "google": return "Google";
1868
+ case "xai": return "xAI";
1869
+ case "deepseek": return "DeepSeek";
1870
+ case "openrouter":return "OpenRouter";
1871
+ default: return p || "?";
1872
+ }
1873
+ }
1874
+ function modelRouteBadge(m) {
1875
+ // Carrier tag shown in the picker after each model name. The
1876
+ // provider is already the group header above each cluster, so
1877
+ // this badge stays short — just the route family — to fit the
1878
+ // tiny uppercase-mono pill (`.ap-model-opt-route`) cleanly:
1879
+ // · "direct" · only the provider's direct API
1880
+ // · "OR" · only OpenRouter
1881
+ // · "direct · OR" · both available (direct preferred)
1882
+ // Mirrors `app.js → modelRouteBadge` so the visual vocabulary
1883
+ // stays consistent across every model picker on the page.
1884
+ const d = !!(m && m.routes && m.routes.direct);
1885
+ const o = !!(m && m.routes && m.routes.openrouter);
1886
+ if (d && o) return "direct · OR";
1887
+ if (d) return "direct";
1888
+ if (o) return "OR";
1889
+ return "";
1890
+ }
1891
+
1892
+ /** Read the shared /api/models cache · null until the first
1893
+ * fetch resolves. Picker fall-back chain: cache.reachable →
1894
+ * PROFILE_MODELS hardcoded list → first option in either. */
1895
+ function modelsSnapshot() {
1896
+ return (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
1897
+ }
1898
+
1899
+ /** All entries the picker is willing to OFFER. Each carrier-reachable
1900
+ * combination of (modelV × carrier) becomes its own row so the user
1901
+ * can pick e.g. `GPT-5.5 via OpenAI direct` vs `GPT-5.5 via OpenRouter`
1902
+ * when both keys are configured. When only one carrier serves a
1903
+ * model, a single row is emitted with `carrier: null` (saved as
1904
+ * `agent.carrierPref = null` → adapter routes by default precedence).
1905
+ *
1906
+ * Entry shape · { id, v, carrier, name, provider, deck, route }
1907
+ * id · composite picker key, format `${v}@${carrier}` or `${v}`.
1908
+ * Used as the click-handler payload + active-row marker.
1909
+ * v · modelV string (always; the registry id).
1910
+ * carrier · "openrouter" | "<provider>" | null (null when one route).
1911
+ * route · short label rendered as the right-edge pill. */
1912
+ function pickerEntries() {
1913
+ const cache = modelsSnapshot();
1914
+ const out = [];
1915
+ if (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0) {
1916
+ for (const m of cache.reachable) {
1917
+ const directOk = !!(m.routes && m.routes.direct);
1918
+ const orOk = !!(m.routes && m.routes.openrouter);
1919
+ const provider = providerLabel(m.provider);
1920
+ // Both routes available · expand into two rows. Direct first
1921
+ // (matches the adapter's default preference for non-openrouterOnly
1922
+ // models), then OR. Each row is independently clickable so the
1923
+ // user can pin EITHER carrier explicitly.
1924
+ if (directOk && orOk) {
1925
+ out.push({
1926
+ id: m.modelV + "@" + m.provider,
1927
+ v: m.modelV,
1928
+ carrier: m.provider,
1929
+ name: m.displayName,
1930
+ provider,
1931
+ deck: m.deck || "",
1932
+ route: "via " + provider + " direct",
1933
+ });
1934
+ out.push({
1935
+ id: m.modelV + "@openrouter",
1936
+ v: m.modelV,
1937
+ carrier: "openrouter",
1938
+ name: m.displayName,
1939
+ provider,
1940
+ deck: m.deck || "",
1941
+ route: "via OpenRouter",
1942
+ });
1943
+ } else if (directOk) {
1944
+ out.push({
1945
+ id: m.modelV,
1946
+ v: m.modelV,
1947
+ carrier: null,
1948
+ name: m.displayName,
1949
+ provider,
1950
+ deck: m.deck || "",
1951
+ route: "direct",
1952
+ });
1953
+ } else if (orOk) {
1954
+ out.push({
1955
+ id: m.modelV,
1956
+ v: m.modelV,
1957
+ carrier: null,
1958
+ name: m.displayName,
1959
+ provider,
1960
+ deck: m.deck || "",
1961
+ route: "via OpenRouter",
1962
+ });
1963
+ }
1964
+ }
1965
+ if (out.length > 0) return out;
1966
+ }
1967
+ return PROFILE_MODELS.map((m) => ({ ...m, id: m.v, carrier: null, route: "" }));
1968
+ }
1969
+
1970
+ /** Look up a single entry by composite id (`${v}@${carrier}` or
1971
+ * bare `${v}`) across the cache + the registry mirror. Returns
1972
+ * null when neither the modelV nor the carrier combination is
1973
+ * recognised — used to render the agent's currently-stored model
1974
+ * even if it's not in the offer list (i.e. unreachable). */
1975
+ function lookupEntry(id) {
1976
+ if (!id) return null;
1977
+ // Split composite id; bare `${v}` parses as { v, carrier: null }.
1978
+ const at = id.indexOf("@");
1979
+ const v = at >= 0 ? id.slice(0, at) : id;
1980
+ const carrier = at >= 0 ? id.slice(at + 1) : null;
1981
+ const cache = modelsSnapshot();
1982
+ if (cache && Array.isArray(cache.models)) {
1983
+ const fromCache = cache.models.find((m) => m.modelV === v);
1984
+ if (fromCache) {
1985
+ const provider = providerLabel(fromCache.provider);
1986
+ const route = carrier === "openrouter"
1987
+ ? "via OpenRouter"
1988
+ : carrier === fromCache.provider
1989
+ ? "via " + provider + " direct"
1990
+ : modelRouteBadge(fromCache);
1991
+ return {
1992
+ id: carrier ? v + "@" + carrier : v,
1993
+ v,
1994
+ carrier,
1995
+ name: fromCache.displayName,
1996
+ provider,
1997
+ deck: fromCache.deck || "",
1998
+ route,
1999
+ reachable: !!fromCache.reachable,
2000
+ };
2001
+ }
2002
+ }
2003
+ const fromList = PROFILE_MODELS.find((m) => m.v === v);
2004
+ if (fromList) return { ...fromList, id: v, carrier: null, route: "", reachable: false };
2005
+ return null;
2006
+ }
2007
+
2008
+ /** Whether `v` is reachable RIGHT NOW given the user's keys.
2009
+ * Returns true when the cache hasn't loaded yet (optimistic — the
2010
+ * user can still click the trigger; the picker will repaint with
2011
+ * the real list once the cache lands). */
2012
+ function isReachable(v) {
2013
+ const cache = modelsSnapshot();
2014
+ if (!cache || !Array.isArray(cache.reachable)) return true;
2015
+ return cache.reachable.some((m) => m.modelV === v);
2016
+ }
2017
+
2018
+ /** Resolve the model record to render for an agent. Order of truth:
2019
+ * 1. live `agent.modelV` from the DB (the user's saved pick)
2020
+ * 2. localStorage per-device override (legacy / sticky picks)
2021
+ * 3. fallback param (matched by `v` first, then by name)
2022
+ * 4. first reachable / PROFILE_MODELS[0] as a last resort
2023
+ *
2024
+ * We MAY return an unreachable entry (so the trigger displays the
2025
+ * stored model truthfully); the caller checks `isReachable(entry.v)`
2026
+ * to decide whether to show a stale-model warning. */
2027
+ function modelForAgent(slug, fallback) {
2028
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2029
+ if (live && typeof live.modelV === "string") {
2030
+ // Compose the composite id from the agent's stored carrierPref
2031
+ // when present so multi-carrier models display the right pill
2032
+ // ("via OpenRouter" vs "via OpenAI direct"). NULL pref → bare id
2033
+ // (default-precedence routing).
2034
+ const id = live.carrierPref ? live.modelV + "@" + live.carrierPref : live.modelV;
2035
+ const liveHit = lookupEntry(id) || lookupEntry(live.modelV);
2036
+ if (liveHit) return liveHit;
2037
+ }
2038
+ try {
2039
+ const v = localStorage.getItem(modelKey(slug));
2040
+ if (v) {
2041
+ const hit = lookupEntry(v);
2042
+ if (hit) return hit;
2043
+ }
2044
+ } catch (_) {}
2045
+ if (fallback) {
2046
+ if (fallback.v) {
2047
+ const byV = lookupEntry(fallback.v);
2048
+ if (byV) return byV;
2049
+ }
2050
+ if (fallback.name) {
2051
+ const byName = PROFILE_MODELS.find((m) => m.name === fallback.name);
2052
+ if (byName) return { ...byName, id: byName.v, carrier: null, route: "", reachable: false };
2053
+ }
2054
+ }
2055
+ // Last resort · prefer the first reachable model so the displayed
2056
+ // entry actually works. Falls through to PROFILE_MODELS[0] if the
2057
+ // cache hasn't loaded.
2058
+ const offers = pickerEntries();
2059
+ return offers[0] || { ...PROFILE_MODELS[0], id: PROFILE_MODELS[0].v, carrier: null, route: "", reachable: false };
2060
+ }
2061
+ /** Persist the picked entry to the server. Ships both `modelV` and
2062
+ * `carrierPref` so the adapter knows which carrier to pin (or null
2063
+ * to fall back to default precedence). The local-storage cache only
2064
+ * keeps modelV — sticky-carrier across reloads is fine to come
2065
+ * exclusively from the server (single source of truth). */
2066
+ function setModelFor(slug, entry) {
2067
+ const v = entry && entry.v;
2068
+ if (!v) return;
2069
+ try { localStorage.setItem(modelKey(slug), v); } catch (_) {}
2070
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2071
+ if (!live) return;
2072
+ const body = { modelV: v, carrierPref: entry.carrier ?? null };
2073
+ fetch("/api/agents/" + encodeURIComponent(slug), {
2074
+ method: "PATCH",
2075
+ headers: { "content-type": "application/json" },
2076
+ body: JSON.stringify(body),
2077
+ })
2078
+ .then(async (r) => {
2079
+ if (r.ok) return r.json();
2080
+ // Surface the SERVER's actual error string. Previously this
2081
+ // path threw a hardcoded "model save failed" which made bugs
2082
+ // (e.g. running server hadn't picked up a new model in the
2083
+ // registry) impossible to diagnose from the alert.
2084
+ const j = await r.json().catch(() => ({}));
2085
+ const detail = j && typeof j.error === "string" ? j.error : `HTTP ${r.status}`;
2086
+ throw new Error(detail);
2087
+ })
2088
+ .then((updated) => {
2089
+ // Reflect both fields in the in-memory roster so re-rendering
2090
+ // the trigger picks up the new carrier badge immediately.
2091
+ live.modelV = updated.modelV || v;
2092
+ live.carrierPref = updated.carrierPref ?? null;
2093
+ if (typeof window.app.refreshAgents === "function") {
2094
+ window.app.refreshAgents().catch(() => {});
2095
+ }
2096
+ })
2097
+ .catch((e) => {
2098
+ console.error("[profile] save model failed", e);
2099
+ alert("Couldn't save the model selection: " + (e && e.message ? e.message : e));
2100
+ });
2101
+ }
2102
+ function renderModelBlock(slug, fallback) {
2103
+ const current = modelForAgent(slug, fallback);
2104
+ const reachable = isReachable(current.v);
2105
+ // Stale-model warning · the agent's stored modelV isn't reachable
2106
+ // with the current key set (e.g. user revoked OpenRouter, but
2107
+ // the agent was set to opus-4-7 which is openrouterOnly). Surface
2108
+ // it as a small note under the trigger so the user knows clicks
2109
+ // here will fall back at runtime; the runtime resolver in
2110
+ // `effectiveDefaultModel()` does the actual fallback.
2111
+ const warning = reachable
2112
+ ? ""
2113
+ : `<div class="ap-model-stale" title="This model is not reachable with your current API keys. Runtime calls fall back to the global default.">
2114
+ <span class="ap-model-stale-mark">⚠</span>
2115
+ <span class="ap-model-stale-text">unreachable · falls back at runtime</span>
2116
+ </div>`;
2117
+ // Trigger meta line · show the route when present, fall back to
2118
+ // provider otherwise. We DROP the leading provider when the route
2119
+ // already names it (e.g. "via Google direct"), since "Google · via
2120
+ // Google direct" is just the same word twice and burns horizontal
2121
+ // space. When the route doesn't name the provider (e.g. "via
2122
+ // OpenRouter" on a Google model) we keep both so the user still
2123
+ // sees who built the model. */
2124
+ const triggerMeta = formatTriggerMeta(current);
2125
+ return `
2126
+ <div class="ap-model-row${reachable ? "" : " is-stale"}" data-ap-model-row data-slug="${escape(slug)}">
2127
+ <button type="button" class="ap-model-trigger" data-ap-model-trigger>
2128
+ <span class="ap-model-trigger-text">
2129
+ <span class="ap-model-trigger-name" data-ap-model-name>${escape(current.name)}</span>
2130
+ <span class="ap-model-trigger-provider" data-ap-model-provider>${escape(triggerMeta)}</span>
2131
+ </span>
2132
+ <span class="ap-model-trigger-caret">▾</span>
2133
+ </button>
2134
+ ${warning}
2135
+ </div>
2136
+ `;
2137
+ }
2138
+
2139
+ function openModelPicker(triggerEl) {
2140
+ closeModelPicker();
2141
+ const row = triggerEl.closest("[data-ap-model-row]");
2142
+ const slug = row?.getAttribute("data-slug");
2143
+ if (!slug) return;
2144
+ const current = modelForAgent(slug);
2145
+ const offers = pickerEntries();
2146
+ const pop = document.createElement("div");
2147
+ pop.id = "ap-model-picker";
2148
+ pop.className = "ap-model-picker";
2149
+ pop.dataset.slug = slug;
2150
+ // Group by provider · same shape as the room composer's agent-model
2151
+ // dropdown · provider micro-header above each cluster, compact rows
2152
+ // with a single-line "name · deck" pair below + a tiny route badge
2153
+ // ("direct" / "OR" / "direct · OR") aligned to the right edge.
2154
+ const groups = [];
2155
+ let lastProv = null;
2156
+ for (const m of offers) {
2157
+ if (m.provider !== lastProv) {
2158
+ groups.push(`<div class="ap-model-group">${escape(m.provider || "")}</div>`);
2159
+ lastProv = m.provider;
2160
+ }
2161
+ const badge = m.route
2162
+ ? `<span class="ap-model-opt-route">${escape(m.route)}</span>`
2163
+ : "";
2164
+ groups.push(`
2165
+ <button type="button" class="ap-model-opt${m.id === current.id ? " active" : ""}" data-ap-model-pick="${escape(m.id)}">
2166
+ <span class="ap-model-opt-label">${escape(m.name)}</span>
2167
+ <span class="ap-model-opt-hint">${escape(m.deck || "")}</span>
2168
+ ${badge}
2169
+ </button>
2170
+ `);
2171
+ }
2172
+ pop.innerHTML = groups.join("");
2173
+ document.body.appendChild(pop);
2174
+ const r = triggerEl.getBoundingClientRect();
2175
+ const popW = 260;
2176
+ let left = Math.round(r.left);
2177
+ // Right-align if the trigger is wider than the popover so the
2178
+ // popover's right edge sits flush with the trigger's. Falls back
2179
+ // to the trigger's left edge when narrow.
2180
+ if (r.width > popW) left = Math.round(r.right - popW);
2181
+ pop.style.top = `${Math.round(r.bottom + 4)}px`;
2182
+ pop.style.left = `${left}px`;
2183
+ pop.style.width = `${popW}px`;
2184
+ }
2185
+ function closeModelPicker() {
2186
+ const el = document.getElementById("ap-model-picker");
2187
+ if (el) el.remove();
2188
+ }
2189
+ /** Build the secondary line under the model name on the closed
2190
+ * trigger AND inside the picker rows. Centralised so the "drop the
2191
+ * redundant leading provider when the route already names it" rule
2192
+ * only lives in one place. */
2193
+ function formatTriggerMeta(entry) {
2194
+ const route = entry && entry.route ? String(entry.route) : "";
2195
+ const provider = entry && entry.provider ? String(entry.provider) : "";
2196
+ if (!route) return provider;
2197
+ // Drop the leading provider iff the route already mentions it
2198
+ // (case-insensitive substring check). Covers "via Google direct"
2199
+ // when provider is "Google", "via OpenAI direct" when provider is
2200
+ // "OpenAI", etc. Keeps "OpenAI · via OpenRouter" intact since the
2201
+ // route doesn't repeat the provider.
2202
+ if (provider && route.toLowerCase().includes(provider.toLowerCase())) {
2203
+ return route;
2204
+ }
2205
+ return provider ? `${provider} · ${route}` : route;
2206
+ }
2207
+ function updateModelTrigger(slug, m) {
2208
+ const row = document.querySelector(`[data-ap-model-row][data-slug="${slug}"]`);
2209
+ if (!row) return;
2210
+ const name = row.querySelector("[data-ap-model-name]");
2211
+ const prov = row.querySelector("[data-ap-model-provider]");
2212
+ if (name) name.textContent = m.name;
2213
+ if (prov) prov.textContent = formatTriggerMeta(m);
2214
+ }
2215
+
2216
+ function pageHTML(p, slug) {
2217
+ const skills = skillsForAgent(slug);
2218
+ const liveModel = liveModelFor(slug) || (p.metrics && p.metrics.model) || { name: "—", deck: "" };
2219
+ // Memory block · chair-only. Director profiles have their long-term
2220
+ // memory accumulated automatically at adjourn; the user-facing "add
2221
+ // note about yourself" panel only makes sense for the chair (the
2222
+ // host agent that aggregates context across rooms).
2223
+ const liveAgent = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2224
+ const isChair = !!(liveAgent && liveAgent.roleKind === "moderator");
2225
+
2226
+ // Roster — recent rooms this director appeared in, if the profile
2227
+ // data ships any. Otherwise show 4 empty silhouette slots.
2228
+ const memoryRooms = (p.memory && p.memory.rooms) || [];
2229
+ const rosterCount = Math.max(memoryRooms.length, 4);
2230
+ const rosterSlots = Array.from({ length: rosterCount }, (_, i) => {
2231
+ const room = memoryRooms[i];
2232
+ if (room) {
2233
+ return `<div class="ap-portrait-mini" title="${escape(room.name || "")}"><img src="${escape(p.avatar)}" alt=""></div>`;
2234
+ }
2235
+ return `<div class="ap-portrait-mini empty">·</div>`;
2236
+ }).join("");
2237
+
2238
+ const bioBody = (Array.isArray(p.bio) ? p.bio.join("\n\n") : (p.bio || "")).trim();
2239
+ const statusLabel = p.status === "intern" ? "INTERN · TRIAL" : (p.status || "ACTIVE").toUpperCase();
2240
+
2241
+ return `
2242
+ <section class="ap-profile-card ap-profile-card-full" data-ap-card-slug="${escape(slug)}">
2243
+ <div class="ap-cover" aria-hidden="true">
2244
+ <div class="ap-cover-art" data-cover-seed="${escape(slug)}"></div>
2245
+ </div>
2246
+ <div class="ap-profile-body">
2247
+ <div class="ap-avatar">
2248
+ <img src="${escape(p.avatar)}" alt="${escape(p.name)}">
2249
+ </div>
2250
+ <div class="ap-id-text">
2251
+ <h1 class="ap-id-name">${escape(p.name)}</h1>
2252
+ <div class="ap-id-meta">
2253
+ <span class="ap-id-role">${escape(p.role || "DIRECTOR")}</span>
2254
+ ${p.handle ? `<span class="ap-id-dot">·</span><span class="ap-id-handle">${escape(p.handle)}</span>` : ""}
2255
+ <span class="ap-status-pill">${escape(statusLabel)}</span>
2256
+ </div>
2257
+ </div>
2258
+ <button type="button" class="ap-id-menu" data-ap-id-menu data-slug="${escape(slug)}" aria-label="more">⋯</button>
2259
+ </div>
2260
+ </section>
2261
+
2262
+ <div class="ap-card" data-ap-card-slug="${escape(slug)}">
2263
+ <div class="ap-layout">
2264
+
2265
+ <div class="ap-main-col">
2266
+
2267
+ <section class="ap-block">
2268
+ <header class="ap-block-h">
2269
+ <span class="ap-block-h-title">Intel</span>
2270
+ <button type="button" class="ap-block-h-action" data-ap-intel-edit>edit</button>
2271
+ </header>
2272
+ <div class="ap-intel" data-ap-intel data-slug="${escape(slug)}">
2273
+ <div class="ap-intel-view" data-ap-intel-view>${escape(bioBody) || `<span class="ap-empty">no description yet · click <strong>edit</strong> to add one</span>`}</div>
2274
+ </div>
2275
+ </section>
2276
+
2277
+ <section class="ap-block">
2278
+ <header class="ap-block-h">
2279
+ <span class="ap-block-h-title">Instruction</span>
2280
+ <button type="button" class="ap-block-h-action" data-ap-instr-edit>edit</button>
2281
+ </header>
2282
+ ${renderInstructionBlock(p, slug)}
2283
+ </section>
2284
+
2285
+ <section class="ap-block">
2286
+ <header class="ap-block-h">
2287
+ <span class="ap-block-h-title">Rules</span>
2288
+ <button type="button" class="ap-block-h-action" data-ap-rule-add data-slug="${escape(slug)}" ${rulesForAgent(slug).length >= RULES_MAX ? "disabled" : ""}>
2289
+ ${rulesForAgent(slug).length >= RULES_MAX ? `max ${RULES_MAX}` : "+ add rule"}
2290
+ </button>
2291
+ </header>
2292
+ ${renderRulesBlock(slug)}
2293
+ </section>
2294
+
2295
+ ${isChair ? `
2296
+ <section class="ap-block">
2297
+ <header class="ap-block-h">
2298
+ <span class="ap-block-h-title">Memory</span>
2299
+ <button type="button" class="ap-block-h-action" data-ap-memory-add-toggle data-slug="${escape(slug)}">+ add note</button>
2300
+ </header>
2301
+ ${renderMemoryBlock(slug)}
2302
+ </section>
2303
+ ` : ""}
2304
+
2305
+ </div>
2306
+
2307
+ <aside class="ap-side-col">
2308
+
2309
+ <section class="ap-block">
2310
+ <header class="ap-block-h">
2311
+ <span class="ap-block-h-title">Track Record</span>
2312
+ <span class="ap-block-h-tag">model · usage</span>
2313
+ </header>
2314
+ <div class="ap-block-body">
2315
+ ${renderModelBlock(slug, liveModel)}
2316
+ <div class="ap-stats-grid" data-ap-stats data-slug="${escape(slug)}">
2317
+ <div class="ap-stat">
2318
+ <div class="ap-stat-v" data-ap-stat-rooms>—</div>
2319
+ <div class="ap-stat-l">rooms</div>
2320
+ </div>
2321
+ <div class="ap-stat">
2322
+ <div class="ap-stat-v" data-ap-stat-rounds>—</div>
2323
+ <div class="ap-stat-l">rounds</div>
2324
+ </div>
2325
+ <div class="ap-stat">
2326
+ <div class="ap-stat-v" data-ap-stat-tokens>—</div>
2327
+ <div class="ap-stat-l">tokens</div>
2328
+ </div>
2329
+ </div>
2330
+ </div>
2331
+ </section>
2332
+
2333
+ <section class="ap-block">
2334
+ <header class="ap-block-h">
2335
+ <span class="ap-block-h-title">Skills</span>
2336
+ <span class="ap-block-h-tag" data-ap-skills-count>0 / ${isChair ? SKILL_CAP.chair : SKILL_CAP.director} installed</span>
2337
+ </header>
2338
+ ${renderSkillsBlockV2(slug, isChair)}
2339
+ </section>
2340
+
2341
+ <section class="ap-block">
2342
+ <header class="ap-block-h">
2343
+ <span class="ap-block-h-title">Equipment</span>
2344
+ <span class="ap-block-h-tag">coming soon</span>
2345
+ </header>
2346
+ <div class="ap-coming-soon">
2347
+ <div class="ap-coming-soon-mark">◆</div>
2348
+ <div class="ap-coming-soon-title">Knowledge docs</div>
2349
+ <p class="ap-coming-soon-body">Attach PDFs, links, and reference notes to ground this agent's reasoning. They'll be cited inline during rooms and available for the agent to recall by name.</p>
2350
+ <div class="ap-coming-soon-tag">in development</div>
2351
+ </div>
2352
+ </section>
2353
+
2354
+ </aside>
2355
+
2356
+ </div>
2357
+ </div>
2358
+ `;
2359
+ }
2360
+
2361
+ function getMainViews() {
2362
+ return {
2363
+ room: document.querySelector('[data-main-view="room"]'),
2364
+ agent: document.querySelector('[data-main-view="agent"]'),
2365
+ reports: document.querySelector('[data-main-view="reports"]')
2366
+ };
2367
+ }
2368
+
2369
+ function showRoom() {
2370
+ const v = getMainViews();
2371
+ if (v.room) v.room.removeAttribute("hidden");
2372
+ if (v.agent) {
2373
+ v.agent.setAttribute("hidden", "");
2374
+ v.agent.innerHTML = "";
2375
+ }
2376
+ // The All Reports view is the third main pane; hide it when
2377
+ // returning to a room so its placeholder card / list never bleeds
2378
+ // through under the room view.
2379
+ if (v.reports) v.reports.setAttribute("hidden", "");
2380
+ document.querySelectorAll(".agent-row.active").forEach((r) => r.classList.remove("active"));
2381
+ }
2382
+
2383
+ /** Build a minimal profile object from a live /api/agents record so
2384
+ * the page renderer (pageHTML / renderInstruction / etc.) can work
2385
+ * with user-created directors that don't have a hardcoded entry in
2386
+ * PROFILES. The single-string instruction goes under "role"; other
2387
+ * sections show "—" until v1.1 adds structured editing. */
2388
+ function buildLiveProfile(agent) {
2389
+ if (!agent) return null;
2390
+ const liveModel = liveModelFor(agent.id) || { name: "—", deck: "" };
2391
+ const created = agent.createdAt
2392
+ ? new Date(agent.createdAt).toISOString().slice(0, 10)
2393
+ : "—";
2394
+ const bio = agent.bio || "";
2395
+ return {
2396
+ name: agent.name,
2397
+ role: agent.roleTag || "director",
2398
+ handle: agent.handle,
2399
+ avatar: agent.avatarPath,
2400
+ status: "active",
2401
+ tenure: agent.isSeed ? "core" : "custom",
2402
+ coverQuote: agent.coverQuote || agent.bio?.slice(0, 80) || agent.name,
2403
+ meta: { creator: agent.isSeed ? "@boardroom" : "@you", joined: created },
2404
+ bio: bio.split(/\n\s*\n/).filter(Boolean),
2405
+ metrics: {
2406
+ rooms: 0,
2407
+ rounds: 0,
2408
+ model: liveModel,
2409
+ tokens: { v: "—", deck: "" },
2410
+ },
2411
+ instruction: {
2412
+ role: agent.instruction || "—",
2413
+ objectives: "—",
2414
+ voice: "—",
2415
+ boundaries: "—",
2416
+ escalation: "—",
2417
+ },
2418
+ memory: {
2419
+ aboutUser: null,
2420
+ rooms: [],
2421
+ },
2422
+ knowledge: { docs: [] },
2423
+ };
2424
+ }
2425
+
2426
+ function open(slug) {
2427
+ let p = PROFILES[slug];
2428
+ // Live agent record (DB row · includes seeded directors too,
2429
+ // since they live in the agents table). Custom directors created
2430
+ // via the new-agent overlay land here exclusively.
2431
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2432
+ if (!p) p = buildLiveProfile(live);
2433
+ if (!p) return;
2434
+ // The hardcoded PROFILES map has a static avatar field. The live
2435
+ // record is the source of truth for the actual current avatar
2436
+ // (which may have been regenerated via the ⋯ menu / PATCH). Pull
2437
+ // the live avatarPath in whenever it's available so the big
2438
+ // profile portrait stays in sync with the sidebar.
2439
+ if (live && live.avatarPath) p.avatar = live.avatarPath;
2440
+ // Per-device override fallback (used only when there's no live
2441
+ // record at all — extremely rare).
2442
+ if (!live) {
2443
+ try {
2444
+ const override = localStorage.getItem("boardroom.agent.avatar." + slug);
2445
+ if (override) p.avatar = override;
2446
+ } catch (_) {}
2447
+ }
2448
+ const v = getMainViews();
2449
+ if (!v.agent) return;
2450
+ v.agent.innerHTML = pageHTML(p, slug);
2451
+ if (v.room) v.room.setAttribute("hidden", "");
2452
+ // If the user came from All Reports, hide that pane too — without
2453
+ // this, both views render simultaneously and the reports placeholder
2454
+ // card overlays the agent profile.
2455
+ if (v.reports) v.reports.setAttribute("hidden", "");
2456
+ v.agent.removeAttribute("hidden");
2457
+ // Centralized sidebar-focus handler · also clears New room /
2458
+ // New agent highlights and any stale session-row highlight, since
2459
+ // an open agent profile owns the main view.
2460
+ if (window.app && typeof window.app.markActiveAgent === "function") {
2461
+ window.app.markActiveAgent(slug);
2462
+ } else {
2463
+ document.querySelectorAll(".agent-row").forEach((r) => {
2464
+ r.classList.toggle("active", r.dataset.agentProfile === slug);
2465
+ });
2466
+ }
2467
+ // Scroll the new card to the top.
2468
+ v.agent.scrollTop = 0;
2469
+ const card = v.agent.querySelector(".ap-card");
2470
+ if (card) card.scrollTop = 0;
2471
+ // Paint a deterministic cover banner from the slug so each director
2472
+ // gets a recognisable colourway without needing image assets.
2473
+ paintCoverArt(v.agent.querySelector("[data-cover-seed]"));
2474
+ // Detect whether the instruction prose exceeds the collapsed cap.
2475
+ // Has to run AFTER innerHTML mounts (so layout/wrapping is real).
2476
+ evaluateInstructionOverflow(slug);
2477
+ // Lazy-load Track Record counters (rooms / rounds / tokens). Runs
2478
+ // off the main paint thread; placeholders ("—") show until the
2479
+ // fetch resolves so the layout never reflows.
2480
+ loadTrackRecord(slug);
2481
+ // Lazy-load this agent's long-term memory pool.
2482
+ loadMemoriesFor(slug);
2483
+ // Lazy-load this agent's installed skills (radar + list).
2484
+ loadSkillsForV2(slug);
2485
+ }
2486
+
2487
+ /** GET /api/agents/:slug/stats and stamp the three counters into
2488
+ * the Track Record block. Cheap to call on every profile open —
2489
+ * the server computes the figures from existing tables (no cache
2490
+ * drift) and no schema lookups happen between paints. */
2491
+ function loadTrackRecord(slug) {
2492
+ const grid = document.querySelector(`[data-ap-stats][data-slug="${slug}"]`);
2493
+ if (!grid) return;
2494
+ fetch("/api/agents/" + encodeURIComponent(slug) + "/stats")
2495
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
2496
+ .then((s) => {
2497
+ const rooms = grid.querySelector("[data-ap-stat-rooms]");
2498
+ const rounds = grid.querySelector("[data-ap-stat-rounds]");
2499
+ const tokens = grid.querySelector("[data-ap-stat-tokens]");
2500
+ if (rooms) rooms.textContent = formatStatNumber(s.roomsJoined);
2501
+ if (rounds) rounds.textContent = formatStatNumber(s.roundsSpoken);
2502
+ if (tokens) tokens.textContent = formatStatNumber(s.tokensConsumed);
2503
+ })
2504
+ .catch(() => {
2505
+ // Silent failure — placeholders ("—") stay; the user can
2506
+ // refresh, and the counters will render next paint.
2507
+ });
2508
+ }
2509
+
2510
+ /** Compact stat rendering · 0..999 verbatim, then 1.2k / 4.5M for
2511
+ * cumulative tokens which can grow large. Whole rooms / rounds
2512
+ * rarely cross the threshold but the same formatter handles them
2513
+ * cleanly. */
2514
+ function formatStatNumber(n) {
2515
+ if (typeof n !== "number" || !Number.isFinite(n)) return "—";
2516
+ if (n < 1000) return String(n);
2517
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, "") + "k";
2518
+ return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\.0$/, "") + "M";
2519
+ }
2520
+
2521
+ /** Generate a stable two-stop gradient from the slug. Same slug →
2522
+ * same colourway every reload, so the cover acts like a sigil. */
2523
+ function paintCoverArt(node) {
2524
+ if (!node) return;
2525
+ const seed = node.getAttribute("data-cover-seed") || "";
2526
+ let h = 0;
2527
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
2528
+ const a = h % 360;
2529
+ const b = (a + 50 + (h % 80)) % 360;
2530
+ node.style.background = `
2531
+ linear-gradient(120deg, hsl(${a} 32% 22%) 0%, hsl(${b} 28% 14%) 100%),
2532
+ radial-gradient(circle at 30% 40%, hsl(${a} 40% 28%) 0%, transparent 55%)
2533
+ `;
2534
+ }
2535
+
2536
+ /* ─── Profile · skill picker popover ──────────────
2537
+ Floating list of catalog abilities the agent doesn't already
2538
+ have. Click one to install into the chosen slot. Position is
2539
+ anchored to the clicked slot. */
2540
+ function openProfileSkillPicker(anchor, slug, slotIdx) {
2541
+ closeProfileSkillPicker();
2542
+ const installed = new Set(skillsForAgent(slug));
2543
+ const available = SKILL_CATALOG.filter((s) => !installed.has(s.v));
2544
+ if (available.length === 0) return;
2545
+
2546
+ const pop = document.createElement("div");
2547
+ pop.className = "na-skill-picker";
2548
+ pop.id = "ap-skill-picker";
2549
+ pop.dataset.targetSlug = slug;
2550
+ pop.dataset.targetSlot = String(slotIdx);
2551
+ pop.innerHTML = available.map((s) => `
2552
+ <button type="button" class="na-skill-pick" data-ap-skill-pick="${escape(s.v)}">
2553
+ <span class="na-skill-pick-icon">${escape(s.icon)}</span>
2554
+ <span class="na-skill-pick-body">
2555
+ <span class="na-skill-pick-name">${escape(s.name)}</span>
2556
+ <span class="na-skill-pick-deck">${escape(s.deck)}</span>
2557
+ </span>
2558
+ </button>
2559
+ `).join("");
2560
+ document.body.appendChild(pop);
2561
+
2562
+ const r = anchor.getBoundingClientRect();
2563
+ const margin = 6;
2564
+ pop.style.left = Math.max(margin, Math.min(r.left, window.innerWidth - 270 - margin)) + "px";
2565
+ pop.style.top = (r.bottom + 4) + "px";
2566
+ }
2567
+ function closeProfileSkillPicker() {
2568
+ const pop = document.getElementById("ap-skill-picker");
2569
+ if (pop) pop.remove();
2570
+ }
2571
+ /** Replace the skill grid in the currently-rendered profile so the
2572
+ * install/remove change reflects immediately, without re-rendering
2573
+ * the whole card. */
2574
+ function repaintProfileSkillGrid(slug) {
2575
+ const card = document.querySelector(`.ap-card[data-ap-card-slug="${slug}"]`);
2576
+ if (!card) return;
2577
+ const grid = card.querySelector("[data-ap-skill-grid]");
2578
+ if (!grid) return;
2579
+ const skills = skillsForAgent(slug);
2580
+ grid.innerHTML = renderSkillSlots(skills);
2581
+ const count = card.querySelector(".ap-skill-count");
2582
+ if (count) count.textContent = String(skills.length);
2583
+ }
2584
+
2585
+ function init() {
2586
+ // Document-level trigger for sidebar agent rows (capture phase
2587
+ // so we beat the agent-overlay listener; the avatar img inside
2588
+ // an agent row is auto-tagged data-agent).
2589
+ document.addEventListener("click", (e) => {
2590
+ const trigger = e.target.closest("[data-agent-profile]");
2591
+ if (!trigger) return;
2592
+ const slug = trigger.dataset.agentProfile;
2593
+ if (!slug) return;
2594
+ // Accept either a hardcoded profile or any live agent the app
2595
+ // knows about (custom directors land in the latter).
2596
+ const hasProfile = !!PROFILES[slug];
2597
+ const hasLiveAgent = !!(window.app && window.app.agentsById && window.app.agentsById[slug]);
2598
+ if (!hasProfile && !hasLiveAgent) return;
2599
+ e.preventDefault();
2600
+ e.stopPropagation();
2601
+ open(slug);
2602
+ }, true);
2603
+
2604
+ // Card actions: back button + add-to-boardroom CTA.
2605
+ document.addEventListener("click", async (e) => {
2606
+ if (e.target.closest("[data-ap-back]")) {
2607
+ e.preventDefault();
2608
+ showRoom();
2609
+ // Also flip the sidebar tab back to Rooms for clarity.
2610
+ document.querySelectorAll(".sidebar-tab[data-sidebar-tab]").forEach((t) => {
2611
+ const on = t.dataset.sidebarTab === "rooms";
2612
+ t.classList.toggle("active", on);
2613
+ t.setAttribute("aria-selected", on ? "true" : "false");
2614
+ });
2615
+ document.querySelectorAll(".sidebar-panel[data-sidebar-panel]").forEach((p) => {
2616
+ if (p.dataset.sidebarPanel === "rooms") p.removeAttribute("hidden");
2617
+ else p.setAttribute("hidden", "");
2618
+ });
2619
+ return;
2620
+ }
2621
+ const addBtn = e.target.closest("[data-ap-add]");
2622
+ if (addBtn) {
2623
+ e.preventDefault();
2624
+ // Open the convene overlay; the user can pick this director from
2625
+ // the catalog (it's already in the merged list via app.agents).
2626
+ if (typeof window.openConveneOverlay === "function") {
2627
+ window.openConveneOverlay();
2628
+ }
2629
+ return;
2630
+ }
2631
+
2632
+ /* ─── Profile · skill slot interaction ─────────────
2633
+ Click an empty slot → open the skill picker.
2634
+ Click a filled slot → uninstall.
2635
+ All state mutations go through skillsForAgent /
2636
+ setSkillsFor (localStorage), then re-render the
2637
+ grid in place. */
2638
+ // Info icon · don't bubble through to slot click; the tooltip
2639
+ // is handled by CSS (data-tip).
2640
+ if (e.target.closest(".ap-skill-info")) {
2641
+ e.preventDefault();
2642
+ e.stopPropagation();
2643
+ return;
2644
+ }
2645
+ const slot = e.target.closest("[data-ap-skill-slot]");
2646
+ if (slot && slot.closest(".ap-card")) {
2647
+ e.preventDefault();
2648
+ const slotIdx = parseInt(slot.getAttribute("data-ap-skill-slot"), 10);
2649
+ const card = slot.closest(".ap-card");
2650
+ const slug = card?.getAttribute("data-ap-card-slug");
2651
+ if (!slug || !Number.isFinite(slotIdx)) return;
2652
+ if (slot.classList.contains("filled")) {
2653
+ // Look up the skill at this slot for a clearer confirm prompt.
2654
+ const installed = skillsForAgent(slug);
2655
+ const v = installed[slotIdx];
2656
+ const s = SKILL_CATALOG.find((x) => x.v === v);
2657
+ const label = s ? s.name : "this skill";
2658
+ if (!window.confirm(`Remove ${label}?`)) return;
2659
+ uninstallSkillFor(slug, slotIdx);
2660
+ repaintProfileSkillGrid(slug);
2661
+ } else {
2662
+ openProfileSkillPicker(slot, slug, slotIdx);
2663
+ }
2664
+ return;
2665
+ }
2666
+ // (model dropdown change is handled in the change listener below)
2667
+
2668
+ // Skill picker option click — confirm, then install + close.
2669
+ const pick = e.target.closest("[data-ap-skill-pick]");
2670
+ if (pick) {
2671
+ e.preventDefault();
2672
+ const v = pick.getAttribute("data-ap-skill-pick");
2673
+ const pop = document.getElementById("ap-skill-picker");
2674
+ const slug = pop?.dataset.targetSlug;
2675
+ const slotIdx = pop ? parseInt(pop.dataset.targetSlot, 10) : null;
2676
+ const s = SKILL_CATALOG.find((x) => x.v === v);
2677
+ const label = s ? s.name : "this skill";
2678
+ if (!window.confirm(`Install ${label}?`)) return;
2679
+ if (slug && v) installSkillFor(slug, v, Number.isFinite(slotIdx) ? slotIdx : null);
2680
+ closeProfileSkillPicker();
2681
+ if (slug) repaintProfileSkillGrid(slug);
2682
+ return;
2683
+ }
2684
+
2685
+ // ⋯ menu · open the popover (anchored to the button).
2686
+ const idMenuBtn = e.target.closest("[data-ap-id-menu]");
2687
+ if (idMenuBtn) {
2688
+ e.preventDefault();
2689
+ e.stopPropagation();
2690
+ if (document.getElementById("ap-id-menu-pop")) {
2691
+ closeProfileIdMenu();
2692
+ } else {
2693
+ openProfileIdMenu(idMenuBtn);
2694
+ }
2695
+ return;
2696
+ }
2697
+ // ⋯ menu · action click.
2698
+ const menuAction = e.target.closest("[data-ap-menu-action]");
2699
+ if (menuAction) {
2700
+ e.preventDefault();
2701
+ const action = menuAction.getAttribute("data-ap-menu-action");
2702
+ const pop = document.getElementById("ap-id-menu-pop");
2703
+ const slug = pop?.dataset.slug;
2704
+ closeProfileIdMenu();
2705
+ if (action === "regen-avatar" && slug) regenerateProfileAvatar(slug);
2706
+ if (action === "delete" && slug && window.app && typeof window.app.deleteAgent === "function") {
2707
+ // deleteAgent handles confirm + DELETE call + closes the
2708
+ // profile + refreshes the sidebar. No-op for seed/chair
2709
+ // (defense in depth — the menu item only renders for
2710
+ // custom agents).
2711
+ void window.app.deleteAgent(slug);
2712
+ }
2713
+ return;
2714
+ }
2715
+
2716
+ // Memory · expand / collapse the overflow list. The first 5
2717
+ // rows always render; the rest live in a hidden container that
2718
+ // this button toggles. We flip both `[hidden]` on the panel and
2719
+ // an `expanded` class on the button so CSS swaps the icon
2720
+ // direction + the show/hide labels.
2721
+ const memToggle = e.target.closest("[data-ap-memory-toggle]");
2722
+ if (memToggle) {
2723
+ e.preventDefault();
2724
+ const block = memToggle.closest("[data-ap-memory]");
2725
+ const overflow = block?.querySelector("[data-ap-memory-overflow]");
2726
+ if (!overflow) return;
2727
+ const isHidden = overflow.hasAttribute("hidden");
2728
+ if (isHidden) overflow.removeAttribute("hidden");
2729
+ else overflow.setAttribute("hidden", "");
2730
+ memToggle.classList.toggle("expanded", isHidden);
2731
+ return;
2732
+ }
2733
+
2734
+ // Memory · pin / unpin.
2735
+ const pinBtn = e.target.closest("[data-ap-memory-pin]");
2736
+ if (pinBtn) {
2737
+ e.preventDefault();
2738
+ const block = pinBtn.closest("[data-ap-memory]");
2739
+ const row = pinBtn.closest("[data-ap-memory-row]");
2740
+ const slug = block?.getAttribute("data-slug");
2741
+ const id = row?.getAttribute("data-id");
2742
+ if (!slug || !id) return;
2743
+ const wasPinned = row.getAttribute("data-pinned") === "1";
2744
+ patchMemory(slug, id, { pinned: !wasPinned })
2745
+ .then(() => loadMemoriesFor(slug))
2746
+ .catch((err) => alert("Couldn't update pin: " + (err && err.message ? err.message : err)));
2747
+ return;
2748
+ }
2749
+ // Memory · edit · swap content into a textarea inline.
2750
+ const editBtn = e.target.closest("[data-ap-memory-edit]");
2751
+ if (editBtn) {
2752
+ e.preventDefault();
2753
+ const row = editBtn.closest("[data-ap-memory-row]");
2754
+ const contentEl = row?.querySelector("[data-ap-memory-content]");
2755
+ if (!row || !contentEl) return;
2756
+ if (row.classList.contains("editing")) return;
2757
+ const current = contentEl.textContent || "";
2758
+ contentEl.innerHTML = `<textarea class="ap-memory-edit-area" data-ap-memory-edit-area maxlength="280">${escape(current)}</textarea>
2759
+ <div class="ap-memory-edit-actions">
2760
+ <button type="button" class="ap-memory-edit-cancel" data-ap-memory-edit-cancel data-orig="${escape(current)}">cancel</button>
2761
+ <button type="button" class="ap-memory-edit-save" data-ap-memory-edit-save>save</button>
2762
+ </div>`;
2763
+ row.classList.add("editing");
2764
+ const ta = contentEl.querySelector("textarea");
2765
+ if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
2766
+ return;
2767
+ }
2768
+ // Memory edit · cancel · restore the original text.
2769
+ const editCancel = e.target.closest("[data-ap-memory-edit-cancel]");
2770
+ if (editCancel) {
2771
+ e.preventDefault();
2772
+ const row = editCancel.closest("[data-ap-memory-row]");
2773
+ const contentEl = row?.querySelector("[data-ap-memory-content]");
2774
+ if (!row || !contentEl) return;
2775
+ contentEl.textContent = editCancel.getAttribute("data-orig") || "";
2776
+ row.classList.remove("editing");
2777
+ return;
2778
+ }
2779
+ // Memory edit · save · PATCH content + re-render row.
2780
+ const editSave = e.target.closest("[data-ap-memory-save]") || e.target.closest("[data-ap-memory-edit-save]");
2781
+ if (editSave) {
2782
+ e.preventDefault();
2783
+ const row = editSave.closest("[data-ap-memory-row]");
2784
+ const block = editSave.closest("[data-ap-memory]");
2785
+ const ta = row?.querySelector("[data-ap-memory-edit-area]");
2786
+ const slug = block?.getAttribute("data-slug");
2787
+ const id = row?.getAttribute("data-id");
2788
+ if (!slug || !id || !ta) return;
2789
+ const content = ta.value.trim();
2790
+ if (content.length < 4 || content.length > 280) {
2791
+ alert("Note must be 4–280 chars.");
2792
+ return;
2793
+ }
2794
+ patchMemory(slug, id, { content })
2795
+ .then(() => loadMemoriesFor(slug))
2796
+ .catch((err) => alert("Couldn't save: " + (err && err.message ? err.message : err)));
2797
+ return;
2798
+ }
2799
+ // Memory · delete · single click + native confirm dialog.
2800
+ const delBtn = e.target.closest("[data-ap-memory-delete]");
2801
+ if (delBtn) {
2802
+ e.preventDefault();
2803
+ const block = delBtn.closest("[data-ap-memory]");
2804
+ const row = delBtn.closest("[data-ap-memory-row]");
2805
+ const slug = block?.getAttribute("data-slug");
2806
+ const id = row?.getAttribute("data-id");
2807
+ if (!slug || !id) return;
2808
+ const contentEl = row?.querySelector("[data-ap-memory-content]");
2809
+ const preview = (contentEl?.textContent || "").trim();
2810
+ const snippet = preview.length > 80 ? preview.slice(0, 77) + "…" : preview;
2811
+ const msg = snippet
2812
+ ? `Delete this note?\n\n"${snippet}"\n\nThis can't be undone.`
2813
+ : "Delete this note? This can't be undone.";
2814
+ if (!confirm(msg)) return;
2815
+ deleteMemoryFor(slug, id)
2816
+ .then(() => loadMemoriesFor(slug))
2817
+ .catch((err) => alert("Couldn't delete: " + (err && err.message ? err.message : err)));
2818
+ return;
2819
+ }
2820
+ // Memory · toggle the add form (mirrors the Rules add pattern · the
2821
+ // input area is hidden by default and only revealed when the user
2822
+ // clicks the [+ add note] section action).
2823
+ const memAddToggle = e.target.closest("[data-ap-memory-add-toggle]");
2824
+ if (memAddToggle) {
2825
+ e.preventDefault();
2826
+ const slug = memAddToggle.getAttribute("data-slug");
2827
+ const block = document.querySelector(`[data-ap-memory][data-slug="${slug}"]`);
2828
+ const form = block?.querySelector("[data-ap-memory-add-form]");
2829
+ if (!form) return;
2830
+ form.hidden = false;
2831
+ const input = form.querySelector("[data-ap-memory-add-input]");
2832
+ if (input) { input.value = ""; input.focus(); }
2833
+ return;
2834
+ }
2835
+ // Memory · cancel — hide the form and clear input.
2836
+ const memAddCancel = e.target.closest("[data-ap-memory-add-cancel]");
2837
+ if (memAddCancel) {
2838
+ e.preventDefault();
2839
+ const form = memAddCancel.closest("[data-ap-memory-add-form]");
2840
+ if (!form) return;
2841
+ form.hidden = true;
2842
+ const input = form.querySelector("[data-ap-memory-add-input]");
2843
+ if (input) input.value = "";
2844
+ return;
2845
+ }
2846
+ // Memory · manual add · form submit.
2847
+ const memAddBtn = e.target.closest("[data-ap-memory-add-btn]");
2848
+ if (memAddBtn) {
2849
+ e.preventDefault();
2850
+ const block = memAddBtn.closest("[data-ap-memory]");
2851
+ const form = memAddBtn.closest("[data-ap-memory-add-form]");
2852
+ const input = block?.querySelector("[data-ap-memory-add-input]");
2853
+ const slug = block?.getAttribute("data-slug");
2854
+ if (!slug || !input) return;
2855
+ const content = (input.value || "").trim();
2856
+ if (content.length < 4 || content.length > 280) {
2857
+ alert("Note must be 4–280 chars.");
2858
+ return;
2859
+ }
2860
+ memAddBtn.disabled = true;
2861
+ addMemoryFor(slug, content)
2862
+ .then(() => {
2863
+ input.value = "";
2864
+ if (form) form.hidden = true;
2865
+ return loadMemoriesFor(slug);
2866
+ })
2867
+ .catch((err) => alert("Couldn't save note: " + (err && err.message ? err.message : err)))
2868
+ .finally(() => { memAddBtn.disabled = false; });
2869
+ return;
2870
+ }
2871
+ // Memory overlay · close on backdrop / × click.
2872
+ if (e.target.closest("[data-ap-memory-close]")) {
2873
+ e.preventDefault();
2874
+ closeMemoryOverlay();
2875
+ return;
2876
+ }
2877
+
2878
+ // Skills v2 · drop-zone click → open file picker.
2879
+ const skillDrop = e.target.closest("[data-ap-skills-drop]");
2880
+ if (skillDrop && !skillDrop.classList.contains("disabled")) {
2881
+ // Don't double-trigger if the click landed on the input itself.
2882
+ if (e.target.tagName !== "INPUT") {
2883
+ e.preventDefault();
2884
+ const fi = skillDrop.querySelector("[data-ap-skills-file]");
2885
+ if (fi) fi.click();
2886
+ }
2887
+ return;
2888
+ }
2889
+ // Skills v2 · uninstall from inside the popover.
2890
+ const skillRmFromPop = e.target.closest("[data-ap-skill-popover-uninstall]");
2891
+ if (skillRmFromPop) {
2892
+ e.preventDefault();
2893
+ const slug = skillRmFromPop.getAttribute("data-slug");
2894
+ const skillId = skillRmFromPop.getAttribute("data-skill-id");
2895
+ if (!slug || !skillId) return;
2896
+ // Pull the skill name from the popover header for a clearer
2897
+ // confirm dialog ("Uninstall 'X'?" beats a generic prompt).
2898
+ const pop = document.getElementById("ap-skill-info-pop");
2899
+ const headEl = pop?.querySelector(".ap-skill-info-head");
2900
+ const skillName = headEl ? headEl.textContent.trim() : "this skill";
2901
+ if (!confirm(`Uninstall "${skillName}"? This can't be undone.`)) return;
2902
+ if (pop) pop.remove();
2903
+ uninstallSkillReq(slug, skillId)
2904
+ .then(() => loadSkillsForV2(slug))
2905
+ .catch((err) => alert("Couldn't uninstall: " + (err && err.message ? err.message : err)));
2906
+ return;
2907
+ }
2908
+ // Skills v2 · row menu (⋯) opens the info+uninstall popover.
2909
+ const skillInfo = e.target.closest("[data-ap-skill-info]");
2910
+ if (skillInfo) {
2911
+ e.preventDefault();
2912
+ e.stopPropagation();
2913
+ openSkillInfoPopover(skillInfo);
2914
+ return;
2915
+ }
2916
+
2917
+ // Web Search · per-agent toggle. Two paths:
2918
+ // · key configured → flip the per-agent flag with an optimistic
2919
+ // visual + PATCH /api/agents/:id { webSearchEnabled }.
2920
+ // · key missing → confirm prompt, then deep-link into
2921
+ // Preferences → Brave row. The toggle stays OFF until the
2922
+ // user comes back with a key.
2923
+ const wsToggle = e.target.closest("[data-ap-ws-toggle]");
2924
+ if (wsToggle) {
2925
+ e.preventDefault();
2926
+ e.stopPropagation();
2927
+ const keyConfigured = wsToggle.getAttribute("data-key-configured") === "1";
2928
+ const provider = wsToggle.getAttribute("data-provider") || "brave";
2929
+ if (!keyConfigured) {
2930
+ const ok = confirm(
2931
+ "Web Search needs a Brave Search API key.\n\n" +
2932
+ "Brave Search · ≈ $5 per 1000 queries · privacy-respecting\n\n" +
2933
+ "Open Preferences to paste your key now?",
2934
+ );
2935
+ if (ok && typeof window.openUserSettings === "function") {
2936
+ window.openUserSettings({ section: "keys", focusProvider: provider });
2937
+ }
2938
+ return;
2939
+ }
2940
+ const agentSlug = wsToggle.getAttribute("data-agent-slug");
2941
+ if (!agentSlug) return;
2942
+ const wasEnabled = wsToggle.getAttribute("data-enabled") === "1";
2943
+ const next = !wasEnabled;
2944
+ // Optimistic visual flip while the PATCH lands.
2945
+ wsToggle.classList.toggle("on", next);
2946
+ wsToggle.classList.toggle("off", !next);
2947
+ wsToggle.setAttribute("data-enabled", next ? "1" : "0");
2948
+ wsToggle.setAttribute("aria-pressed", next ? "true" : "false");
2949
+ const txt = wsToggle.querySelector(".ap-skill-row-toggle-text");
2950
+ if (txt) txt.textContent = next ? "enabled" : "disabled";
2951
+ wsToggle.title = next ? "Disable Web Search for this director" : "Enable Web Search for this director";
2952
+ try {
2953
+ const r = await fetch("/api/agents");
2954
+ const j = await r.json();
2955
+ const agent = (j.agents || []).find((a) => a.handle === ("/" + agentSlug) || a.handle === agentSlug || a.id === agentSlug)
2956
+ || (j.chair && (j.chair.handle === ("/" + agentSlug) || j.chair.id === agentSlug) ? j.chair : null);
2957
+ if (!agent) throw new Error("agent not found");
2958
+ const p = await fetch("/api/agents/" + encodeURIComponent(agent.id), {
2959
+ method: "PATCH",
2960
+ headers: { "content-type": "application/json" },
2961
+ body: JSON.stringify({ webSearchEnabled: next }),
2962
+ });
2963
+ if (!p.ok) throw new Error("HTTP " + p.status);
2964
+ } catch (err) {
2965
+ // Revert visual on failure.
2966
+ wsToggle.classList.toggle("on", wasEnabled);
2967
+ wsToggle.classList.toggle("off", !wasEnabled);
2968
+ wsToggle.setAttribute("data-enabled", wasEnabled ? "1" : "0");
2969
+ wsToggle.setAttribute("aria-pressed", wasEnabled ? "true" : "false");
2970
+ if (txt) txt.textContent = wasEnabled ? "enabled" : "disabled";
2971
+ alert("Couldn't update Web Search toggle: " + (err && err.message ? err.message : err));
2972
+ }
2973
+ return;
2974
+ }
2975
+ // Configure-key link from inside the skill-info popover (web-search
2976
+ // only · the popover renders this when the global key is missing).
2977
+ const wsConfigure = e.target.closest("[data-ap-ws-configure]");
2978
+ if (wsConfigure) {
2979
+ e.preventDefault();
2980
+ e.stopPropagation();
2981
+ const provider = wsConfigure.getAttribute("data-provider") || "brave";
2982
+ // Close the skill-info popover before opening the settings
2983
+ // overlay — otherwise the popover sits in front of the
2984
+ // overlay's modal and looks like an artifact.
2985
+ const pop = document.getElementById("ap-skill-info-pop");
2986
+ if (pop) pop.remove();
2987
+ if (typeof window.openUserSettings === "function") {
2988
+ window.openUserSettings({ section: "keys", focusProvider: provider });
2989
+ }
2990
+ return;
2991
+ }
2992
+
2993
+ // Intel · open editor.
2994
+ if (e.target.closest("[data-ap-intel-edit]")) {
2995
+ e.preventDefault();
2996
+ const card = e.target.closest(".ap-card");
2997
+ const slug = card?.getAttribute("data-ap-card-slug");
2998
+ if (!slug) return;
2999
+ const p = profileForSlug(slug);
3000
+ if (p) openIntelEditor(slug, p);
3001
+ return;
3002
+ }
3003
+ // Intel · save (PATCH /api/agents/:id with new bio).
3004
+ if (e.target.closest("[data-ap-intel-save]")) {
3005
+ e.preventDefault();
3006
+ const block = e.target.closest("[data-ap-intel]");
3007
+ const slug = block?.getAttribute("data-slug");
3008
+ const ta = block?.querySelector("[data-ap-intel-textarea]");
3009
+ if (!slug || !ta) return;
3010
+ const hint = block.querySelector("[data-ap-intel-hint]");
3011
+ const btn = block.querySelector("[data-ap-intel-save]");
3012
+ if (btn) { btn.disabled = true; btn.textContent = "saving…"; }
3013
+ setBioFor(slug, ta.value)
3014
+ .then(() => {
3015
+ const p = profileForSlug(slug);
3016
+ if (p) repaintIntel(slug, p);
3017
+ })
3018
+ .catch((err) => {
3019
+ if (btn) { btn.disabled = false; btn.textContent = "save"; }
3020
+ if (hint) {
3021
+ hint.textContent = "error · " + (err && err.message ? err.message : err);
3022
+ hint.classList.add("error");
3023
+ }
3024
+ });
3025
+ return;
3026
+ }
3027
+ // Intel · cancel.
3028
+ if (e.target.closest("[data-ap-intel-cancel]")) {
3029
+ e.preventDefault();
3030
+ const block = e.target.closest("[data-ap-intel]");
3031
+ const slug = block?.getAttribute("data-slug");
3032
+ if (!slug) return;
3033
+ const p = profileForSlug(slug);
3034
+ if (p) repaintIntel(slug, p);
3035
+ return;
3036
+ }
3037
+
3038
+ // Instruction · open editor.
3039
+ if (e.target.closest("[data-ap-instr-edit]")) {
3040
+ e.preventDefault();
3041
+ const card = e.target.closest(".ap-card");
3042
+ const slug = card?.getAttribute("data-ap-card-slug");
3043
+ if (!slug) return;
3044
+ const p = profileForSlug(slug);
3045
+ if (p) openInstructionEditor(slug, p);
3046
+ return;
3047
+ }
3048
+ // Instruction · save.
3049
+ if (e.target.closest("[data-ap-instr-save]")) {
3050
+ e.preventDefault();
3051
+ const block = e.target.closest("[data-ap-instr]");
3052
+ const slug = block?.getAttribute("data-slug");
3053
+ const ta = block?.querySelector("[data-ap-instr-textarea]");
3054
+ if (!slug || !ta) return;
3055
+ setInstructionFor(slug, ta.value);
3056
+ const p = profileForSlug(slug);
3057
+ if (p) repaintInstruction(slug, p);
3058
+ return;
3059
+ }
3060
+ // Instruction · cancel.
3061
+ if (e.target.closest("[data-ap-instr-cancel]")) {
3062
+ e.preventDefault();
3063
+ const block = e.target.closest("[data-ap-instr]");
3064
+ const slug = block?.getAttribute("data-slug");
3065
+ if (!slug) return;
3066
+ const p = profileForSlug(slug);
3067
+ if (p) repaintInstruction(slug, p);
3068
+ return;
3069
+ }
3070
+ // Instruction · show more / show less. Toggles the .expanded
3071
+ // class on the view; CSS lifts the max-height cap and hides the
3072
+ // bottom fade gradient. The button itself is only visible when
3073
+ // the parent .ap-instr carries .overflowing (set by
3074
+ // evaluateInstructionOverflow).
3075
+ const instrToggle = e.target.closest("[data-ap-instr-toggle]");
3076
+ if (instrToggle) {
3077
+ e.preventDefault();
3078
+ const block = instrToggle.closest("[data-ap-instr]");
3079
+ const view = block?.querySelector("[data-ap-instr-view]");
3080
+ if (!view) return;
3081
+ const expanded = view.classList.toggle("expanded");
3082
+ instrToggle.setAttribute("aria-expanded", String(expanded));
3083
+ instrToggle.textContent = expanded ? "show less" : "show more";
3084
+ return;
3085
+ }
3086
+
3087
+ // Rules · add a new empty row, then focus its input.
3088
+ const addRuleBtn = e.target.closest("[data-ap-rule-add]");
3089
+ if (addRuleBtn) {
3090
+ e.preventDefault();
3091
+ if (addRuleBtn.hasAttribute("disabled")) return;
3092
+ const slug = addRuleBtn.getAttribute("data-slug")
3093
+ || addRuleBtn.closest("[data-ap-rules-block]")?.getAttribute("data-slug");
3094
+ if (!slug) return;
3095
+ addRuleFor(slug);
3096
+ repaintProfileRules(slug);
3097
+ const card = document.querySelector(`.ap-card[data-ap-card-slug="${slug}"]`);
3098
+ const inputs = card?.querySelectorAll(".ap-rule-input") || [];
3099
+ const last = inputs[inputs.length - 1];
3100
+ if (last) last.focus();
3101
+ return;
3102
+ }
3103
+ // Rules · remove a row.
3104
+ const rmRule = e.target.closest("[data-ap-rule-rm]");
3105
+ if (rmRule) {
3106
+ e.preventDefault();
3107
+ const block = rmRule.closest("[data-ap-rules-block]");
3108
+ const slug = block?.getAttribute("data-slug");
3109
+ const idx = parseInt(rmRule.getAttribute("data-ap-rule-rm"), 10);
3110
+ if (!slug || !Number.isFinite(idx)) return;
3111
+ removeRuleFor(slug, idx);
3112
+ repaintProfileRules(slug);
3113
+ return;
3114
+ }
3115
+ });
3116
+
3117
+ // Rules · persist edits as the user types (debounce-free; the
3118
+ // payload is small and writes go to localStorage).
3119
+ document.addEventListener("input", (e) => {
3120
+ const ri = e.target.closest("[data-ap-rule-input]");
3121
+ if (!ri) return;
3122
+ const block = ri.closest("[data-ap-rules-block]");
3123
+ const slug = block?.getAttribute("data-slug");
3124
+ const idx = parseInt(ri.getAttribute("data-ap-rule-input"), 10);
3125
+ if (!slug || !Number.isFinite(idx)) return;
3126
+ setRuleAt(slug, idx, ri.value);
3127
+ });
3128
+
3129
+ // Esc closes the memory overlay or the instruction editor
3130
+ // (in priority order).
3131
+ document.addEventListener("keydown", (e) => {
3132
+ // Memory add form · Enter saves, Escape cancels. Skip Enter
3133
+ // when an IME (pinyin / kana / hangul) is mid-composition —
3134
+ // the Enter belongs to the candidate confirmation, not the
3135
+ // form submit.
3136
+ const memInput = e.target.closest && e.target.closest("[data-ap-memory-add-input]");
3137
+ if (memInput) {
3138
+ if (e.key === "Enter" && !e.isComposing && e.keyCode !== 229) {
3139
+ e.preventDefault();
3140
+ const form = memInput.closest("[data-ap-memory-add-form]");
3141
+ form?.querySelector("[data-ap-memory-add-btn]")?.click();
3142
+ return;
3143
+ }
3144
+ if (e.key === "Escape") {
3145
+ e.preventDefault();
3146
+ const form = memInput.closest("[data-ap-memory-add-form]");
3147
+ form?.querySelector("[data-ap-memory-add-cancel]")?.click();
3148
+ return;
3149
+ }
3150
+ }
3151
+ if (e.key === "Escape") {
3152
+ if (document.getElementById("ap-memory-overlay")) {
3153
+ closeMemoryOverlay();
3154
+ return;
3155
+ }
3156
+ const editingIntel = document.querySelector(".ap-intel-edit [data-ap-intel-textarea]");
3157
+ if (editingIntel) {
3158
+ const block = editingIntel.closest("[data-ap-intel]");
3159
+ const slug = block?.getAttribute("data-slug");
3160
+ if (slug) {
3161
+ const p = profileForSlug(slug);
3162
+ if (p) repaintIntel(slug, p);
3163
+ }
3164
+ return;
3165
+ }
3166
+ const editing = document.querySelector(".ap-instr-edit [data-ap-instr-textarea]");
3167
+ if (editing) {
3168
+ const block = editing.closest("[data-ap-instr]");
3169
+ const slug = block?.getAttribute("data-slug");
3170
+ if (slug) {
3171
+ const p = profileForSlug(slug);
3172
+ if (p) repaintInstruction(slug, p);
3173
+ }
3174
+ }
3175
+ }
3176
+ });
3177
+
3178
+ // Outside-click dismisses the ⋯ menu popover.
3179
+ document.addEventListener("click", (e) => {
3180
+ const pop = document.getElementById("ap-id-menu-pop");
3181
+ if (!pop) return;
3182
+ if (e.target.closest("#ap-id-menu-pop")) return;
3183
+ if (e.target.closest("[data-ap-id-menu]")) return;
3184
+ closeProfileIdMenu();
3185
+ }, true);
3186
+
3187
+ // Outside-click dismisses the picker.
3188
+ document.addEventListener("click", (e) => {
3189
+ const pop = document.getElementById("ap-skill-picker");
3190
+ if (!pop) return;
3191
+ if (e.target.closest("#ap-skill-picker")) return;
3192
+ if (e.target.closest("[data-ap-skill-slot]")) return;
3193
+ closeProfileSkillPicker();
3194
+ }, true);
3195
+
3196
+ // Model dropdown · open picker on trigger click; install on
3197
+ // option click; outside-click dismisses.
3198
+ document.addEventListener("click", (e) => {
3199
+ const trigger = e.target.closest("[data-ap-model-trigger]");
3200
+ if (trigger) {
3201
+ e.preventDefault();
3202
+ if (document.getElementById("ap-model-picker")) {
3203
+ closeModelPicker();
3204
+ } else {
3205
+ openModelPicker(trigger);
3206
+ }
3207
+ return;
3208
+ }
3209
+ const opt = e.target.closest("[data-ap-model-pick]");
3210
+ if (opt) {
3211
+ e.preventDefault();
3212
+ const id = opt.getAttribute("data-ap-model-pick");
3213
+ const pop = document.getElementById("ap-model-picker");
3214
+ const slug = pop?.dataset.slug;
3215
+ if (!slug || !id) return;
3216
+ // The id is a composite (`${v}@${carrier}` or bare `${v}`); we
3217
+ // resolve it through lookupEntry to recover both halves plus
3218
+ // the human label. Fallbacks cover the rare case where the
3219
+ // cache is empty (still in first-paint).
3220
+ const at = id.indexOf("@");
3221
+ const v = at >= 0 ? id.slice(0, at) : id;
3222
+ const carrier = at >= 0 ? id.slice(at + 1) : null;
3223
+ const fromCache = lookupEntry(id);
3224
+ const fromList = PROFILE_MODELS.find((x) => x.v === v);
3225
+ const fromOption = {
3226
+ id,
3227
+ v,
3228
+ carrier,
3229
+ name: opt.querySelector(".ap-model-opt-label")?.textContent?.trim() || v,
3230
+ provider: "",
3231
+ deck: "",
3232
+ };
3233
+ const m = fromCache || (fromList ? { ...fromList, id, v, carrier } : fromOption);
3234
+ setModelFor(slug, m);
3235
+ updateModelTrigger(slug, m);
3236
+ closeModelPicker();
3237
+ return;
3238
+ }
3239
+ });
3240
+ document.addEventListener("click", (e) => {
3241
+ const pop = document.getElementById("ap-model-picker");
3242
+ if (!pop) return;
3243
+ if (e.target.closest("#ap-model-picker")) return;
3244
+ if (e.target.closest("[data-ap-model-trigger]")) return;
3245
+ closeModelPicker();
3246
+ }, true);
3247
+
3248
+ // Back-to-room paths now that the explicit Back button is gone:
3249
+ // • clicking any sidebar room row → switch back to room view
3250
+ // • clicking the Rooms tab while the agent view is active → same
3251
+ document.addEventListener("click", (e) => {
3252
+ // Only act when the agent view is currently open
3253
+ const v = getMainViews();
3254
+ const agentVisible = v.agent && !v.agent.hasAttribute("hidden");
3255
+ if (!agentVisible) return;
3256
+
3257
+ if (e.target.closest(".session-row")) {
3258
+ showRoom();
3259
+ return;
3260
+ }
3261
+ const tab = e.target.closest('.sidebar-tab[data-sidebar-tab="rooms"]');
3262
+ if (tab) {
3263
+ showRoom();
3264
+ }
3265
+ });
3266
+
3267
+ // Skills v2 · file-input change → install. Bubbling change event
3268
+ // (input is hidden inside the drop-zone). We handle it once at the
3269
+ // document level to avoid per-instance re-binding on each repaint.
3270
+ document.addEventListener("change", (e) => {
3271
+ const fi = e.target && e.target.closest && e.target.closest("[data-ap-skills-file]");
3272
+ if (!fi) return;
3273
+ const block = fi.closest("[data-ap-skills]");
3274
+ const slug = block?.getAttribute("data-slug");
3275
+ const file = fi.files && fi.files[0];
3276
+ if (!slug || !file) return;
3277
+ file.text().then((md) => installSkillFromText(slug, md))
3278
+ .then(() => loadSkillsForV2(slug))
3279
+ .catch((err) => alert("Install failed: " + (err && err.message ? err.message : err)))
3280
+ .finally(() => { try { fi.value = ""; } catch (_) {} });
3281
+ });
3282
+
3283
+ // Skills v2 · drag/drop on the drop-zone. dragover is required so
3284
+ // the drop event fires; no visual delegation per-instance — we
3285
+ // toggle the .dragging class on whichever zone is hovered.
3286
+ document.addEventListener("dragover", (e) => {
3287
+ const zone = e.target && e.target.closest && e.target.closest("[data-ap-skills-drop]");
3288
+ if (!zone || zone.classList.contains("disabled")) return;
3289
+ e.preventDefault();
3290
+ zone.classList.add("dragging");
3291
+ });
3292
+ document.addEventListener("dragleave", (e) => {
3293
+ const zone = e.target && e.target.closest && e.target.closest("[data-ap-skills-drop]");
3294
+ if (zone) zone.classList.remove("dragging");
3295
+ });
3296
+ document.addEventListener("drop", (e) => {
3297
+ const zone = e.target && e.target.closest && e.target.closest("[data-ap-skills-drop]");
3298
+ if (!zone || zone.classList.contains("disabled")) return;
3299
+ e.preventDefault();
3300
+ zone.classList.remove("dragging");
3301
+ const block = zone.closest("[data-ap-skills]");
3302
+ const slug = block?.getAttribute("data-slug");
3303
+ const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
3304
+ if (!slug || !file) return;
3305
+ file.text().then((md) => installSkillFromText(slug, md))
3306
+ .then(() => loadSkillsForV2(slug))
3307
+ .catch((err) => alert("Install failed: " + (err && err.message ? err.message : err)));
3308
+ });
3309
+
3310
+ // Skills v2 · close skill info popover on outside click.
3311
+ document.addEventListener("click", (e) => {
3312
+ const pop = document.getElementById("ap-skill-info-pop");
3313
+ if (!pop) return;
3314
+ if (e.target.closest("#ap-skill-info-pop")) return;
3315
+ if (e.target.closest("[data-ap-skill-info]")) return;
3316
+ pop.remove();
3317
+ }, true);
3318
+
3319
+ }
3320
+
3321
+ window.openAgentProfile = open;
3322
+ window.closeAgentProfile = showRoom;
3323
+
3324
+ if (document.readyState === "loading") {
3325
+ document.addEventListener("DOMContentLoaded", init);
3326
+ } else {
3327
+ init();
3328
+ }
3329
+ })();