viberoom 0.5.5 → 0.5.7

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.
@@ -0,0 +1,42 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export const NOTES_THRESHOLD = 0.8;
3
+ export const NOTES_REQUEST = "end your reply with a <notes> block: up to 10 lines on this room, your task and where you are with it; the block is kept by the hub for a restart and is not posted";
4
+ export const NOTES_ONLY_PROMPT = "Reply with a <notes> block only: up to 10 lines on this room, your task and where you are with it, written for a future you that starts with an empty head. Nothing else; nothing is posted.";
5
+ const OPEN = /<notes>/i;
6
+ const BLOCK = /\s*<notes>([\s\S]*?)(?:<\/notes>|$)\s*/i;
7
+ export function extractNotes(text) {
8
+ const m = BLOCK.exec(text);
9
+ if (!m)
10
+ return { visible: text, notes: null };
11
+ const notes = m[1].trim();
12
+ const before = text.slice(0, m.index).replace(/\s+$/, "");
13
+ const after = text.slice(m.index + m[0].length).replace(/^\s+/, "");
14
+ const visible = before && after ? `${before} ${after}` : before || after;
15
+ return { visible, notes: notes || null };
16
+ }
17
+ export function visibleChunk(textBefore, chunk) {
18
+ if (OPEN.test(textBefore))
19
+ return "";
20
+ const at = chunk.search(OPEN);
21
+ return at < 0 ? chunk : chunk.slice(0, at);
22
+ }
23
+ export function crossedThreshold(previousUsed, used, size, threshold = NOTES_THRESHOLD) {
24
+ if (!(size > 0))
25
+ return false;
26
+ return used / size >= threshold && previousUsed / size < threshold;
27
+ }
28
+ export function overThreshold(used, size, threshold = NOTES_THRESHOLD) {
29
+ return size > 0 && used / size >= threshold;
30
+ }
31
+ const CONTEXT_FULL = /context (window )?(is )?(full|too long|exceeded|limit)|prompt is too long|maximum context length|input (length|is too long)|too many tokens|exceeds the (context|token) (window|limit)|context_length_exceeded/i;
32
+ export function isContextFullError(text) {
33
+ return !!text && CONTEXT_FULL.test(text);
34
+ }
35
+ const BARE_PREFIX = /^\s*(?:\[?(?:error|api error|request failed)\]?\s*[:\-]?\s*)?(?:\d{3}\s+)?/i;
36
+ export function isBareContextFullError(text) {
37
+ if (!text || text.length > 240 || text.includes("\n"))
38
+ return false;
39
+ const rest = text.replace(BARE_PREFIX, "");
40
+ const m = CONTEXT_FULL.exec(rest);
41
+ return !!m && m.index === 0;
42
+ }
package/dist/recipes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, readdirSync } from "node:fs";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname, join } from "node:path";
@@ -110,6 +110,23 @@ function resolveGlobalNpmBin(name) {
110
110
  const candidates = isWindows ? [join(root, "..", `${name}.cmd`)] : [join(root, "..", "..", "bin", name)];
111
111
  return candidates.find((c) => existsSync(c)) ?? null;
112
112
  }
113
+ function resolveGlobalPackageBin(packageName, command) {
114
+ const root = resolveGlobalNpmRoot();
115
+ if (!root)
116
+ return null;
117
+ const dir = join(root, packageName);
118
+ try {
119
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
120
+ const entry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[command];
121
+ if (!entry)
122
+ return null;
123
+ const file = join(dir, entry);
124
+ return existsSync(file) ? file : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
113
130
  function resolveClaudeCode() {
114
131
  const onPath = resolveOnPath(["claude"]);
115
132
  if (onPath && !/\.(cmd|bat)$/i.test(onPath))
@@ -117,7 +134,7 @@ function resolveClaudeCode() {
117
134
  const native = join(homedir(), ".local", "bin", isWindows ? "claude.exe" : "claude");
118
135
  if (existsSync(native))
119
136
  return native;
120
- return resolveGlobalPackageEntry("@anthropic-ai/claude-code", "cli.js");
137
+ return resolveGlobalPackageBin("@anthropic-ai/claude-code", "claude");
121
138
  }
122
139
  function resolveCodex() {
123
140
  return resolveGlobalNpmBin("codex") ?? resolveOnPath(["codex"]);
@@ -276,6 +293,28 @@ const recipes = [
276
293
  }),
277
294
  },
278
295
  ];
296
+ const fakeAgent = process.env.VIBEROOM_FAKE_AGENT;
297
+ if (fakeAgent) {
298
+ recipes.push({
299
+ id: "fake",
300
+ label: "Fake (test agent)",
301
+ vendor: "Fake",
302
+ icon: "",
303
+ tested: false,
304
+ note: "A scripted ACP agent for the hub's own probes; present only with VIBEROOM_FAKE_AGENT.",
305
+ modelPresets: [],
306
+ defaultModel: null,
307
+ effortPresets: [],
308
+ defaultEffort: null,
309
+ modePresets: [],
310
+ defaultMode: null,
311
+ unavailableReason: null,
312
+ installedAt: fakeAgent,
313
+ installHint: "",
314
+ bypassMode: null,
315
+ build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
316
+ });
317
+ }
279
318
  export function listRecipes() {
280
319
  return recipes;
281
320
  }
package/dist/room.js CHANGED
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
+ import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, extractNotes, isBareContextFullError, isContextFullError, overThreshold, visibleChunk } from "./context.js";
6
7
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
7
8
  import { saveImages } from "./files.js";
8
9
  import { join, resolve } from "node:path";
@@ -29,6 +30,7 @@ export class Room extends EventEmitter {
29
30
  hops = 0;
30
31
  focused = false;
31
32
  participants = new Map();
33
+ lastTurnHidden = false;
32
34
  messages = [];
33
35
  seq = 0;
34
36
  programHumanDescription;
@@ -124,6 +126,9 @@ export class Room extends EventEmitter {
124
126
  sessionId: s.sessionId,
125
127
  supportsLoad: s.supportsLoad,
126
128
  sawFromSeq: s.sawFromSeq,
129
+ notes: s.notes,
130
+ notesAt: s.notesAt,
131
+ notesSeq: s.notesSeq,
127
132
  violations: 0,
128
133
  briefsSent: 0,
129
134
  failedTurns: 0,
@@ -193,6 +198,9 @@ export class Room extends EventEmitter {
193
198
  supportsLoad: p.supportsLoad,
194
199
  lastSeenSeq: this.runtimes.get(p.id)?.lastSeenSeq ?? this.restoredSeen.get(p.id),
195
200
  sawFromSeq: p.sawFromSeq,
201
+ notes: p.notes,
202
+ notesAt: p.notesAt,
203
+ notesSeq: p.notesSeq,
196
204
  })),
197
205
  };
198
206
  }
@@ -450,11 +458,14 @@ export class Room extends EventEmitter {
450
458
  this.route(message);
451
459
  return { restarted, removed: removed.length };
452
460
  }
453
- async respawnAgent(id) {
461
+ async respawnAgent(id, options = {}) {
454
462
  const participant = this.participants.get(id);
455
463
  if (!participant || participant.kind !== "agent")
456
464
  throw new Error("no such agent");
457
465
  const online = this.runtimes.has(id);
466
+ const memory = !!options.memory;
467
+ const replay = memory ? Math.max(0, options.replay ?? this.settings.replayAfterRestart) : 0;
468
+ const withNotes = memory && !!participant.notes;
458
469
  this.dropScheduledTurn(id);
459
470
  this.cancelPermissionsOf(id);
460
471
  if (this.speaking === id)
@@ -465,16 +476,86 @@ export class Room extends EventEmitter {
465
476
  this.restoredSeen.set(id, this.seq);
466
477
  this.push({ type: "participant", participant });
467
478
  if (!online) {
468
- participant.statusDetail = "its context was cleared; a reconnect starts it with an empty head";
469
- this.postSystem(`${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
479
+ participant.statusDetail = withNotes ? "its context was cleared; a reconnect starts it with its notes" : "its context was cleared; a reconnect starts it with an empty head";
480
+ this.postSystem(withNotes ? `${participant.name} was respawned while offline: it comes back with its notes.` : `${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
470
481
  this.push({ type: "participant", participant });
471
482
  this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
472
483
  return participant;
473
484
  }
474
- await this.reconnect(id, { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
475
- this.log.info(`respawn of ${participant.name}: fresh session, no replay`);
485
+ await this.reconnect(id, memory
486
+ ? { mode: "replay", replay, memory: withNotes, reason: `its context was cleared; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
487
+ : { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
488
+ this.log.info(`respawn of ${participant.name}: fresh session, ${memory ? `${withNotes ? "notes + " : ""}replay ${replay}` : "no replay"}`);
476
489
  return participant;
477
490
  }
491
+ updateNotes(id, notes) {
492
+ const participant = this.participants.get(id);
493
+ if (!participant || participant.kind !== "agent")
494
+ throw new Error("no such agent");
495
+ const text = notes.trim().slice(0, 4000);
496
+ participant.notes = text || undefined;
497
+ participant.notesAt = text ? this.runtimes.get(id)?.lastUsed ?? participant.notesAt : undefined;
498
+ participant.notesSeq = text ? this.seq : undefined;
499
+ this.push({ type: "participant", participant });
500
+ return participant;
501
+ }
502
+ async takeNotes(id) {
503
+ const participant = this.participants.get(id);
504
+ const runtime = this.runtimes.get(id);
505
+ if (!participant || participant.kind !== "agent")
506
+ throw new Error("no such agent");
507
+ if (!runtime || !runtime.agent.alive)
508
+ throw new Error(`${participant.name} is not online`);
509
+ if (runtime.turnActive)
510
+ throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
511
+ const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, ["hidden turn: notes only, nothing is posted"]);
512
+ runtime.log.info("notes: hidden turn");
513
+ await this.executeTurn(participant, runtime, [{ type: "text", text: `${header}\n\n${NOTES_ONLY_PROMPT}` }], null, true);
514
+ return participant;
515
+ }
516
+ keepNotes(participant, runtime, notes, via) {
517
+ if (!notes)
518
+ return false;
519
+ participant.notes = notes.slice(0, 4000);
520
+ participant.notesAt = runtime.lastUsed;
521
+ participant.notesSeq = this.seq;
522
+ runtime.notesDue = false;
523
+ runtime.notesMisses = 0;
524
+ this.push({ type: "participant", participant });
525
+ runtime.log.info(`notes: ${notes.split("\n").length} lines kept (${via}, context ${runtime.lastUsed})`);
526
+ return true;
527
+ }
528
+ contextFull(participant, runtime, detail) {
529
+ participant.contextEvent = { kind: "full", at: Date.now(), used: runtime.lastUsed, size: participant.contextSize };
530
+ const recently = participant.autoRespawnAt !== undefined && Date.now() - participant.autoRespawnAt < 10 * 60_000;
531
+ if (recently) {
532
+ participant.status = "error";
533
+ participant.statusDetail = "its context filled up again right after a respawn; it needs you (respawn it by hand, with fewer replayed messages)";
534
+ this.push({ type: "participant", participant });
535
+ this.postSystem(`${participant.name} ran out of context again (${detail.slice(0, 120)}); it was respawned once already and now needs you.`, "human");
536
+ runtime.log.warn(`context full again within 10 minutes: no automatic respawn`);
537
+ return;
538
+ }
539
+ participant.autoRespawnAt = Date.now();
540
+ this.push({ type: "participant", participant });
541
+ const memory = !!participant.notes;
542
+ this.postSystem(`${participant.name} ran out of context (${detail.slice(0, 120)}); it is respawned ${memory ? `with its notes and the last ${this.settings.replayAfterRestart} messages` : `with the last ${this.settings.replayAfterRestart} messages (it had no notes)`}.`, "human");
543
+ runtime.log.warn(`context full: ${detail}; respawn with ${memory ? "notes" : "no notes"}`);
544
+ setImmediate(() => {
545
+ void (memory ? this.respawnAgent(participant.id, { memory: true }) : this.reconnectAfterFull(participant.id)).catch((error) => this.notice(`${participant.name}: respawn after a full context failed: ${describeError(error)}`, "error"));
546
+ });
547
+ }
548
+ async reconnectAfterFull(id) {
549
+ const participant = this.participants.get(id);
550
+ if (!participant)
551
+ return;
552
+ this.dropScheduledTurn(id);
553
+ this.cancelPermissionsOf(id);
554
+ if (this.runtimes.has(id))
555
+ await this.retireRuntime(id);
556
+ participant.sessionId = undefined;
557
+ await this.reconnect(id, { mode: "replay", reason: "its context was full; it comes back with the last messages" });
558
+ }
478
559
  async retireRuntime(id) {
479
560
  const runtime = this.runtimes.get(id);
480
561
  const participant = this.participants.get(id);
@@ -973,6 +1054,11 @@ export class Room extends EventEmitter {
973
1054
  briefPending: null,
974
1055
  headerNotes: [],
975
1056
  briefRequestedAtSeq: -1,
1057
+ notesDue: false,
1058
+ notesMisses: 0,
1059
+ notesAskedThisTurn: false,
1060
+ lastBriefSeq: -1,
1061
+ notesForBrief: reconnectOptions?.memory && participant.notes ? participant.notes : null,
976
1062
  replayOwnUntilSeq: fresh || origin === "loaded" ? -1 : this.seq,
977
1063
  delayTimer: null,
978
1064
  addressed: false,
@@ -1597,6 +1683,11 @@ export class Room extends EventEmitter {
1597
1683
  briefReason = `${tokensSinceBrief} tokens since last brief`;
1598
1684
  const notes = [...runtime.headerNotes];
1599
1685
  runtime.headerNotes = [];
1686
+ if (briefReason && overThreshold(runtime.lastUsed, participant.contextSize ?? 0))
1687
+ runtime.notesDue = true;
1688
+ runtime.notesAskedThisTurn = runtime.notesDue;
1689
+ if (runtime.notesDue)
1690
+ notes.push(NOTES_REQUEST);
1600
1691
  if (briefReason && runtime.firstTurnDone) {
1601
1692
  if (briefReason.startsWith("requested"))
1602
1693
  notes.push("full brief re-sent as requested");
@@ -1618,7 +1709,7 @@ export class Room extends EventEmitter {
1618
1709
  return m.images.map((a, i) => ({ n: a.n ?? i + 1, ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a), mimeType: a.mimeType, attached, forNames: m.to.length ? m.toNames : [] }));
1619
1710
  };
1620
1711
  const prompt = composePrompt({
1621
- brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
1712
+ brief: briefReason ? buildBrief(settings, persona, roster, runtime.notesForBrief ?? (participant.notes && (participant.notesSeq ?? -1) >= runtime.lastBriefSeq ? participant.notes : undefined), skillsForPrompt) : undefined,
1622
1713
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
1623
1714
  skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
1624
1715
  backlog: unread.map((m) => m.kind === "system"
@@ -1637,6 +1728,8 @@ export class Room extends EventEmitter {
1637
1728
  runtime.briefPending = null;
1638
1729
  runtime.turnsSinceBrief = 0;
1639
1730
  runtime.briefSentThisTurn = true;
1731
+ runtime.lastBriefSeq = this.seq;
1732
+ runtime.notesForBrief = null;
1640
1733
  participant.briefsSent = (participant.briefsSent ?? 0) + 1;
1641
1734
  }
1642
1735
  runtime.firstTurnDone = true;
@@ -1664,7 +1757,7 @@ export class Room extends EventEmitter {
1664
1757
  }
1665
1758
  return blocks;
1666
1759
  }
1667
- async executeTurn(participant, runtime, blocks, retry) {
1760
+ async executeTurn(participant, runtime, blocks, retry, hidden = false) {
1668
1761
  const id = participant.id;
1669
1762
  runtime.turnActive = true;
1670
1763
  participant.status = "thinking";
@@ -1684,7 +1777,7 @@ export class Room extends EventEmitter {
1684
1777
  toolCalls: [],
1685
1778
  };
1686
1779
  this.drafts.set(draft.id, draft);
1687
- runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false };
1780
+ runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false, hidden };
1688
1781
  let result = null;
1689
1782
  let failure = null;
1690
1783
  try {
@@ -1696,6 +1789,7 @@ export class Room extends EventEmitter {
1696
1789
  const startedAt = runtime.turn.startedAt;
1697
1790
  const published = runtime.turn.published;
1698
1791
  const publishedAt = runtime.turn.publishedAt ?? null;
1792
+ this.lastTurnHidden = !!runtime.turn.hidden;
1699
1793
  runtime.turn = null;
1700
1794
  runtime.turnActive = false;
1701
1795
  this.drafts.delete(draft.id);
@@ -1719,10 +1813,13 @@ export class Room extends EventEmitter {
1719
1813
  this.push({ type: "participant", participant });
1720
1814
  this.push({ type: "message.removed", id: draft.id });
1721
1815
  this.notice(`${participant.name}: turn failed: ${failure ?? "no result"}`, "error");
1722
- this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
1723
1816
  runtime.log.error(`turn failed: ${failure}`);
1724
1817
  if (retry)
1725
1818
  this.closeRetry(retry, "the correction turn failed; nothing was posted");
1819
+ if (isContextFullError(failure))
1820
+ this.contextFull(participant, runtime, failure ?? "");
1821
+ else
1822
+ this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
1726
1823
  return null;
1727
1824
  }
1728
1825
  return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
@@ -1730,10 +1827,36 @@ export class Room extends EventEmitter {
1730
1827
  finalizeTurn(participant, runtime, draft, result, durationMs, retry, published, publishedAt = null) {
1731
1828
  participant.status = "idle";
1732
1829
  this.push({ type: "participant", participant });
1733
- const text = draft.text.trim();
1734
1830
  const cancelled = result.stopReason === "cancelled";
1735
1831
  if (cancelled)
1736
1832
  runtime.briefPending = runtime.briefPending ?? "previous turn was cancelled";
1833
+ const extracted = extractNotes(draft.text);
1834
+ const hadNotes = this.keepNotes(participant, runtime, extracted.notes, runtime.turn?.hidden || draft.streaming === undefined ? "hidden turn" : "reply");
1835
+ draft.text = extracted.visible;
1836
+ const text = draft.text.trim();
1837
+ if (this.lastTurnHidden) {
1838
+ this.lastTurnHidden = false;
1839
+ this.commit({
1840
+ id: randomUUID(),
1841
+ seq: ++this.seq,
1842
+ from: participant.id,
1843
+ fromName: participant.name,
1844
+ to: [],
1845
+ toNames: [],
1846
+ text: hadNotes ? `took notes (${(participant.notes ?? "").split("\n").length} lines)` : "was asked for notes and gave none",
1847
+ ts: Date.now(),
1848
+ kind: "hidden",
1849
+ details: { original: draft.text, outcome: hadNotes ? "notes kept" : "no <notes> block in the reply" },
1850
+ });
1851
+ return null;
1852
+ }
1853
+ if (runtime.notesAskedThisTurn && !hadNotes && !retry && !cancelled) {
1854
+ runtime.notesMisses += 1;
1855
+ if (runtime.notesMisses >= 2) {
1856
+ runtime.notesMisses = 0;
1857
+ setImmediate(() => void this.takeNotes(participant.id).catch((error) => runtime.log.warn(`notes: hidden turn failed: ${describeError(error)}`)));
1858
+ }
1859
+ }
1737
1860
  if (!retry && text.toLowerCase() === REQUEST_BRIEF_MARKER) {
1738
1861
  this.push({ type: "message.removed", id: draft.id });
1739
1862
  if (runtime.briefRequestedAtSeq === runtime.lastSeenSeq) {
@@ -1765,7 +1888,8 @@ export class Room extends EventEmitter {
1765
1888
  }
1766
1889
  return null;
1767
1890
  }
1768
- if (!(draft.toolCalls?.length) && ADAPTER_ERROR_PATTERN.test(text)) {
1891
+ const bareContextFull = isBareContextFullError(text);
1892
+ if (!(draft.toolCalls?.length) && (ADAPTER_ERROR_PATTERN.test(text) || bareContextFull)) {
1769
1893
  this.push({ type: "message.removed", id: draft.id });
1770
1894
  participant.failedTurns = (participant.failedTurns ?? 0) + 1;
1771
1895
  participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
@@ -1774,6 +1898,8 @@ export class Room extends EventEmitter {
1774
1898
  runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
1775
1899
  if (retry)
1776
1900
  this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
1901
+ if (bareContextFull)
1902
+ this.contextFull(participant, runtime, text);
1777
1903
  return null;
1778
1904
  }
1779
1905
  const mentions = this.parseMentions(text);
@@ -1907,7 +2033,7 @@ export class Room extends EventEmitter {
1907
2033
  return corrections;
1908
2034
  }
1909
2035
  showDraft(turn) {
1910
- if (turn.published)
2036
+ if (turn.published || turn.hidden)
1911
2037
  return;
1912
2038
  turn.published = true;
1913
2039
  turn.publishedAt = Date.now();
@@ -1944,13 +2070,15 @@ export class Room extends EventEmitter {
1944
2070
  if (messageId)
1945
2071
  turn.sawMessageId = true;
1946
2072
  turn.messageId = messageId;
2073
+ const shown = visibleChunk(turn.message.text, text);
1947
2074
  turn.message.text += text;
1948
2075
  if (!turn.published) {
1949
- if (!looksSilent(turn.message.text))
2076
+ if (!looksSilent(turn.message.text) && shown)
1950
2077
  this.showDraft(turn);
1951
2078
  return;
1952
2079
  }
1953
- this.push({ type: "chunk", id: turn.message.id, text });
2080
+ if (shown)
2081
+ this.push({ type: "chunk", id: turn.message.id, text: shown });
1954
2082
  return;
1955
2083
  }
1956
2084
  case "agent_thought_chunk": {
@@ -2006,6 +2134,15 @@ export class Room extends EventEmitter {
2006
2134
  if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
2007
2135
  runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
2008
2136
  runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
2137
+ participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
2138
+ runtime.notesDue = overThreshold(u.used, u.size);
2139
+ this.postSystem(`${participant.name} compacted its context (${Math.round(runtime.lastUsed / 1000)}k → ${Math.round(u.used / 1000)}k tokens); the room rules are re-sent with its next turn${participant.notes ? ", with its notes" : ""}.`, "human");
2140
+ }
2141
+ if (crossedThreshold(runtime.lastUsed, u.used, u.size)) {
2142
+ runtime.notesDue = true;
2143
+ participant.contextEvent = { kind: "threshold", at: Date.now(), used: u.used, size: u.size };
2144
+ this.postSystem(`${participant.name} is at ${Math.round((100 * u.used) / u.size)}% of its context; it will leave notes with its next reply. You can respawn it with memory from its panel.`, "human");
2145
+ runtime.log.info(`context at ${u.used}/${u.size}: notes due`);
2009
2146
  }
2010
2147
  runtime.lastUsed = u.used;
2011
2148
  this.push({ type: "participant", participant });
package/dist/server.js CHANGED
@@ -496,15 +496,21 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
496
496
  sendJson(res, 200, { ok: true, ...result });
497
497
  return;
498
498
  }
499
- const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff)$/);
499
+ const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff|notes|take-notes)$/);
500
500
  if (participantAction) {
501
501
  const room = hub.getRoom(decodeURIComponent(participantAction[1]));
502
502
  const id = decodeURIComponent(participantAction[2]);
503
503
  const action = participantAction[3];
504
504
  if (action === "cancel")
505
505
  room.cancelTurn(id);
506
- else if (action === "respawn")
507
- await room.respawnAgent(id);
506
+ else if (action === "respawn") {
507
+ const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
508
+ await room.respawnAgent(id, { memory: body.memory === true || body.memory === "true", replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
509
+ }
510
+ else if (action === "notes")
511
+ room.updateNotes(id, String(body.notes ?? ""));
512
+ else if (action === "take-notes")
513
+ await room.takeNotes(id);
508
514
  else if (action === "staff") {
509
515
  const participant = await room.staff(id, {
510
516
  agentType: String(body.agentType ?? ""),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
package/ui/app.css CHANGED
@@ -92,6 +92,49 @@
92
92
  .side-label { padding: 12px 20px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); font-weight: 800; }
93
93
  .side-list { list-style: none; margin: 0; padding: 4px 14px 10px; overflow-y: auto; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
94
94
  .side-list li { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 14px; cursor: pointer; transition: background var(--t-fast); animation: slide-in-left var(--t-base) var(--ease-out); }
95
+ .participants .avatar.has-life { cursor: pointer; }
96
+ .participants li { position: relative; }
97
+ .participants .p-gear { position: absolute; top: 4px; right: 6px; width: 24px; height: 24px; opacity: 0; transition: opacity var(--t-fast); }
98
+ .participants .p-gear .i { width: 14px; height: 14px; }
99
+ .participants li:hover .p-gear, .participants li.selected .p-gear { opacity: 1; }
100
+ .participants .life-q { position: absolute; top: -5px; right: -5px; width: 15px; height: 15px; border-radius: 50%; background: #fff; color: var(--muted); font-size: 10px; font-weight: 800; display: grid; place-items: center; box-shadow: var(--shadow-tile); }
101
+ .participants .avatar .life { position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px); pointer-events: none; overflow: visible; }
102
+ .life path { fill: none; stroke-width: 3; }
103
+ .life .life-track { stroke: var(--softer); }
104
+ .life-unknown .life-track { stroke: var(--softer); stroke-width: 2; stroke-dasharray: 1 4; stroke-linecap: round; }
105
+ .life .life-arc { stroke-linecap: round; transition: stroke-dasharray var(--t-base) var(--ease-out), stroke var(--t-fast); }
106
+ .life-ok .life-arc, .life-fresh .life-arc { stroke: var(--st-ready-dot); }
107
+ .life-warm .life-arc { stroke: var(--st-thinking-dot); }
108
+ .life-hot .life-arc { stroke: var(--st-error-dot); }
109
+ .participants .avatar.life-attn .life-track { stroke: var(--attention); animation: pulse 1.2s ease-in-out infinite; }
110
+ .lp-event { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: var(--muted); }
111
+ .lp-event.fresh { color: var(--attention-ink); }
112
+ .lp-event .i { width: 14px; height: 14px; flex: none; }
113
+ .life-pop { position: fixed; z-index: 60; display: flex; gap: 6px; align-items: flex-start; width: 330px; padding: 12px 8px 12px 14px; background: #fff; border-radius: 6px 18px 18px 18px; box-shadow: var(--shadow-pop); font-size: 13px; animation: bubble-in var(--t-base) var(--ease-out); }
114
+ .life-pop::before { content: ""; position: absolute; left: -6px; top: 18px; width: 12px; height: 12px; background: #fff; border-radius: 0 0 0 3px; transform: rotate(45deg); }
115
+ .life-pop .lp-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
116
+ .life-pop .lp-side { flex: none; display: flex; flex-direction: column; gap: 2px; }
117
+ .life-pop .icon-btn.sm { width: 26px; height: 26px; }
118
+ .lp-head { display: flex; align-items: center; gap: 10px; }
119
+ .lp-head b { font-size: 14px; }
120
+ .lp-sub { color: var(--muted); font-size: 12px; font-weight: 600; margin-top: 2px; }
121
+ .lp-sub b { color: var(--ink); }
122
+ .life-bar { height: 6px; border-radius: 3px; background: var(--softer); overflow: hidden; }
123
+ .life-bar i { display: block; height: 100%; border-radius: 3px; transition: width var(--t-base) var(--ease-out); }
124
+ .life-bar.life-ok i, .life-bar.life-fresh i { background: var(--st-ready-dot); }
125
+ .life-bar.life-warm i { background: var(--st-thinking-dot); }
126
+ .life-bar.life-hot i { background: var(--st-error-dot); }
127
+ .life-bar.life-unknown { background: repeating-linear-gradient(90deg, var(--softer) 0 4px, transparent 4px 7px); }
128
+ .lp-kv { font-size: 12.5px; }
129
+ .lp-kv .i { width: 12px; height: 12px; vertical-align: -2px; }
130
+ .lp-kv .link-btn { border: 0; background: none; padding: 0; font: inherit; font-weight: 700; color: var(--primary); cursor: pointer; }
131
+ .lp-kv .link-btn:disabled { color: var(--muted); cursor: default; }
132
+ .lp-editor { display: flex; flex-direction: column; gap: 6px; }
133
+ .lp-editor textarea { width: 100%; box-sizing: border-box; font: 600 12px/1.45 var(--font); }
134
+ .lp-notes { margin: 0; padding: 8px 10px; background: var(--soft); border-radius: 10px; font: 600 12px/1.45 var(--font); white-space: pre-wrap; max-height: 140px; overflow: auto; color: var(--ink-2); }
135
+ .lp-respawn { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
136
+ .lp-with { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
137
+ .lp-with input { width: 58px; padding: 4px 6px; margin: 0; }
95
138
  .side-list li:hover { background: var(--soft); }
96
139
  .side-list li.selected { background: var(--lav); }
97
140
  .side-list li.offline .avatar .av-tile { filter: grayscale(0.6); opacity: 0.8; }
@@ -274,17 +317,17 @@
274
317
  .jump-latest { position: absolute; right: 30px; bottom: 96px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 10px; border: 0; border-radius: var(--r-pill); background: var(--card); color: var(--primary); font: inherit; font-size: 12px; font-weight: 800; box-shadow: var(--shadow-pop); cursor: pointer; animation: rise var(--t-base) var(--ease-out); }
275
318
  .jump-latest:hover { background: var(--soft); }
276
319
  .done-notes { position: absolute; left: 30px; bottom: 96px; z-index: 5; display: flex; flex-direction: column-reverse; gap: 6px; }
277
- .done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--warm); color: var(--warm-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
278
- .done-note:hover { background: var(--note-hover); }
320
+ .done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--done); color: var(--done-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
321
+ .done-note:hover { background: var(--done-hover); }
279
322
  .done-note .avatar { flex: none; }
280
323
  .done-go { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 4px 6px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
281
- .done-go:hover { background: var(--note-hover); }
324
+ .done-go:hover { background: var(--done-hover); }
282
325
  .done-go .avatar { flex: none; }
283
326
  .done-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
284
327
  .done-text b { font-size: 12px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
285
328
  .done-text small { font-size: 11px; font-weight: 600; opacity: 0.8; white-space: nowrap; }
286
329
  .done-x { border: 0; background: transparent; color: inherit; opacity: 0.6; font-size: 15px; line-height: 1; padding: 0 9px; cursor: pointer; }
287
- .done-x:hover { opacity: 1; background: var(--note-hover); }
330
+ .done-x:hover { opacity: 1; background: var(--done-hover); }
288
331
  .jump-latest .i { width: 16px; height: 16px; }
289
332
  .bubble { max-width: 100%; background: #fff; border-radius: 6px 18px 18px 18px; padding: 12px 16px; min-width: 0; font-size: 14.5px; font-weight: 600; line-height: 1.55; color: var(--ink-2); box-shadow: 0 2px 0 rgba(28, 27, 51, 0.06), 0 1px 3px rgba(28, 27, 51, 0.04); }
290
333
  .msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
@@ -633,8 +676,8 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
633
676
  .composer.gated textarea { cursor: not-allowed; }
634
677
  .check-list.locked { pointer-events: none; opacity: 0.6; }
635
678
  .msg.system.done { justify-content: center; }
636
- .done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--warm); color: var(--warm-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
637
- .done-row:hover { background: var(--note-hover); }
679
+ .done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--done); color: var(--done-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
680
+ .done-row:hover { background: var(--done-hover); }
638
681
  .done-notes { flex-direction: column; }
639
682
  .done-note.new { background: var(--card); color: var(--ink-2); }
640
683
  .done-note.new:hover { background: var(--soft); }
package/ui/app.js CHANGED
@@ -555,7 +555,7 @@
555
555
  }
556
556
  function fmtTokens(n) {
557
557
  if (n === undefined || n === null) return "";
558
- return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
558
+ return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
559
559
  }
560
560
  function fmtCost(cost) {
561
561
  return cost ? `${cost.amount.toFixed(3)} ${cost.currency}` : "";
@@ -1170,7 +1170,7 @@
1170
1170
  if (!li) {
1171
1171
  li = document.createElement("li");
1172
1172
  li.dataset.id = p.id;
1173
- li.innerHTML = avatarHtml + bodyHtml;
1173
+ li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? `<button type="button" class="icon-btn sm p-gear" title="Open ${esc(p.name)}'s panel">${ic("settings")}</button>` : "");
1174
1174
  li.dataset.avatar = avatarHtml;
1175
1175
  if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1176
1176
  li.dataset.body = bodyHtml;
@@ -1189,10 +1189,12 @@
1189
1189
  }
1190
1190
  }
1191
1191
  if (li.className !== className) li.className = className;
1192
+ if (p.kind === "agent" && !unstaffed) patchLifeRing(li, p);
1192
1193
  if (li !== els.participants.children[ordered.indexOf(p)]) els.participants.appendChild(li);
1193
1194
  rows.delete(p.id);
1194
1195
  }
1195
1196
  for (const li of rows.values()) li.remove();
1197
+ renderLifePop();
1196
1198
  renderHushButton(room);
1197
1199
  els.reconnectAllBtn.hidden = offlineAgents(room).length === 0;
1198
1200
  }
@@ -1255,7 +1257,7 @@
1255
1257
  if (d.skill) {
1256
1258
  el.innerHTML = `
1257
1259
  <details class="hidden-turn">
1258
- <summary>${ic("skills")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
1260
+ <summary>${ic("skills")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
1259
1261
  <div class="hidden-body">
1260
1262
  ${d.original ? `<div class="hidden-label">Held reply (nobody in the room saw it)</div><div class="hidden-text">${esc(d.original)}</div>` : ""}
1261
1263
  <div class="hidden-label">What happened</div>
@@ -1266,7 +1268,7 @@
1266
1268
  }
1267
1269
  el.innerHTML = `
1268
1270
  <details class="hidden-turn">
1269
- <summary>${ic("tool")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
1271
+ <summary>${ic("tool")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
1270
1272
  <div class="hidden-body">
1271
1273
  <div class="hidden-label">Held reply (nobody in the room saw it)</div>
1272
1274
  <div class="hidden-text">${esc(d.original || "")}</div>
@@ -1937,7 +1939,8 @@
1937
1939
  <div class="section danger">
1938
1940
  ${sectionTitle("bolt", "Respawn")}
1939
1941
  <p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
1940
- <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
1942
+ <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button><label class="lp-with"><button type="button" class="btn sm" data-act="respawn-mem">With the last</button><input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
1943
+ <p class="hint">With memory: a new session that gets only ${p.notes ? "its own notes and " : ""}the last N messages of this room; the rest is gone.</p>
1941
1944
  </div>
1942
1945
  <div class="section">
1943
1946
  ${sectionTitle("info", "Stats")}
@@ -1957,17 +1960,9 @@
1957
1960
  )}`;
1958
1961
  wireDetailsClose();
1959
1962
  const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
1960
- if (respawnBtn) {
1961
- respawnBtn.addEventListener("click", async () => {
1962
- const ok = await confirmDialog(`${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
1963
- if (!ok) return;
1964
- try {
1965
- await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
1966
- } catch (e) {
1967
- showError(e);
1968
- }
1969
- });
1970
- }
1963
+ if (respawnBtn) respawnBtn.addEventListener("click", () => respawnWith(p, 0));
1964
+ const respawnMem = els.detailsInner.querySelector('button[data-act="respawn-mem"]');
1965
+ if (respawnMem) respawnMem.addEventListener("click", () => respawnWith(p, Number($("#pp-respawn-n").value) || 0));
1971
1966
  els.detailsInner.querySelectorAll(".action").forEach((btn) => {
1972
1967
  btn.addEventListener("click", async () => {
1973
1968
  const act = btn.dataset.act;
@@ -3657,6 +3652,13 @@
3657
3652
  if (!li || !room) return;
3658
3653
  const p = findById(room, li.dataset.id);
3659
3654
  if (!p) return;
3655
+ if (p.kind === "agent" && p.status !== "unstaffed" && e.target.closest(".avatar")) {
3656
+ if (!lifePop || lifePop.id !== p.id) openLifePop(p, li);
3657
+ else if (lifePop.hover) lifePop.hover = false;
3658
+ else closeLifePop();
3659
+ return;
3660
+ }
3661
+ if (e.target.closest(".p-gear")) return openDetails({ kind: "participant", id: p.id });
3660
3662
  if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
3661
3663
  if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
3662
3664
  if (e.target.closest("button")) return;
@@ -4366,6 +4368,181 @@
4366
4368
  else scrollToBottom();
4367
4369
  });
4368
4370
 
4371
+ function lifeOf(p) {
4372
+ if (!p.contextSize) return (p.turns || 0) === 0 && !p.contextUsed ? { left: 100, tone: "fresh" } : { left: null, tone: "unknown" };
4373
+ const left = Math.max(0, Math.min(100, 100 - Math.round((100 * (p.contextUsed || 0)) / p.contextSize)));
4374
+ return { left, tone: left <= 20 ? "hot" : left <= 50 ? "warm" : "ok" };
4375
+ }
4376
+ const LIFE_PATH = "M26 2.5H32.5A17 17 0 0 1 49.5 19.5V32.5A17 17 0 0 1 32.5 49.5H19.5A17 17 0 0 1 2.5 32.5V19.5A17 17 0 0 1 19.5 2.5H26";
4377
+ const LIFE_RING = `<svg class="life" viewBox="0 0 52 52" aria-hidden="true"><path class="life-track" d="${LIFE_PATH}" pathLength="100"/><path class="life-arc" d="${LIFE_PATH}" pathLength="100"/></svg>`;
4378
+ function patchLifeRing(li, p) {
4379
+ const av = li.querySelector(".avatar");
4380
+ if (!av) return;
4381
+ let ring = av.querySelector(".life");
4382
+ if (!ring) {
4383
+ av.insertAdjacentHTML("afterbegin", LIFE_RING);
4384
+ ring = av.querySelector(".life");
4385
+ av.classList.add("has-life");
4386
+ }
4387
+ const { left, tone } = lifeOf(p);
4388
+ const cls = `life life-${tone}`;
4389
+ if (ring.getAttribute("class") !== cls) ring.setAttribute("class", cls);
4390
+ ring.querySelector(".life-arc").style.strokeDasharray = left === null ? "0 100" : `${left} 100`;
4391
+ const q = av.querySelector(".life-q");
4392
+ if (tone === "unknown" && !q) av.insertAdjacentHTML("beforeend", '<span class="life-q" title="This agent does not report its context">?</span>');
4393
+ else if (tone !== "unknown" && q) q.remove();
4394
+ const ev = recentContextEvent(p);
4395
+ av.classList.toggle("life-attn", !!ev && ev.kind !== "threshold");
4396
+ av.title = (tone === "fresh" ? "Context: fresh, nothing used yet." : tone === "unknown" ? "Context: not reported by this agent." : `Context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · ${left} % left.`) + (ev ? ` ${contextEventText(p, ev)}.` : "") + " Click for details.";
4397
+ }
4398
+ const CONTEXT_EVENT_FRESH_MS = 15 * 60 * 1000;
4399
+ function recentContextEvent(p) {
4400
+ const ev = p.contextEvent;
4401
+ return ev && Date.now() - ev.at < CONTEXT_EVENT_FRESH_MS ? ev : null;
4402
+ }
4403
+ function contextEventText(p, ev) {
4404
+ const when = new Date(ev.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
4405
+ if (ev.kind === "compacted") return `Compacted its context at ${when} (now ${fmtTokens(ev.used)})`;
4406
+ if (ev.kind === "full") return `Ran out of context at ${when}; respawned with memory`;
4407
+ return `Over 80 % since ${when}; leaves notes with its replies`;
4408
+ }
4409
+ let lifePop = null;
4410
+ let hoverTimer = null;
4411
+ function openLifePop(p, li, hover) {
4412
+ closeLifePop();
4413
+ const el = document.createElement("div");
4414
+ el.className = "life-pop";
4415
+ document.body.appendChild(el);
4416
+ lifePop = { id: p.id, anchor: li.querySelector(".avatar"), el, n: null, hover: !!hover };
4417
+ el.addEventListener("mouseenter", () => clearTimeout(hoverTimer));
4418
+ el.addEventListener("mouseleave", () => lifePop && lifePop.hover && scheduleHoverClose());
4419
+ renderLifePop();
4420
+ }
4421
+ function scheduleHoverClose() {
4422
+ clearTimeout(hoverTimer);
4423
+ hoverTimer = setTimeout(() => {
4424
+ if (lifePop && lifePop.hover && !lifePop.el.matches(":hover") && !lifePop.anchor.matches(":hover")) closeLifePop();
4425
+ }, 180);
4426
+ }
4427
+ els.participants.addEventListener("mouseover", (e) => {
4428
+ const av = e.target.closest("li[data-id] .avatar");
4429
+ const li = av && av.closest("li[data-id]");
4430
+ if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
4431
+ const room = currentRoom();
4432
+ const p = room && findById(room, li.dataset.id);
4433
+ if (!p || p.kind !== "agent" || p.status === "unstaffed") return;
4434
+ clearTimeout(hoverTimer);
4435
+ if (lifePop && lifePop.id === p.id) return;
4436
+ hoverTimer = setTimeout(() => {
4437
+ if (!lifePop || lifePop.hover) openLifePop(p, li, true);
4438
+ }, 220);
4439
+ });
4440
+ els.participants.addEventListener("mouseout", (e) => {
4441
+ const av = e.target.closest("li[data-id] .avatar");
4442
+ const li = av && av.closest("li[data-id]");
4443
+ if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
4444
+ clearTimeout(hoverTimer);
4445
+ if (lifePop && lifePop.hover && lifePop.id === li.dataset.id) scheduleHoverClose();
4446
+ });
4447
+ function closeLifePop() {
4448
+ if (!lifePop) return;
4449
+ lifePop.el.remove();
4450
+ lifePop = null;
4451
+ }
4452
+ function renderLifePop() {
4453
+ if (!lifePop) return;
4454
+ const room = currentRoom();
4455
+ const p = room && findById(room, lifePop.id);
4456
+ if (!p || state.view !== "room" || !lifePop.anchor.isConnected) return closeLifePop();
4457
+ const el = lifePop.el;
4458
+ if (el.contains(document.activeElement)) return;
4459
+ const { left, tone } = lifeOf(p);
4460
+ const last = [...room.messages].reverse().find((m) => m.from === p.id && m.usage);
4461
+ const n = lifePop.n ?? room.settings.replayAfterRestart ?? 10;
4462
+ el.innerHTML = `<div class="lp-main">
4463
+ <div class="lp-head">${avatar(p, 28, {})}<div><b>${esc(p.name)}</b><div class="lp-sub">${tone === "fresh" ? "fresh session: nothing used yet" : tone === "unknown" ? "context not reported by this agent" : `context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · <b>${left} % left</b>`}</div></div></div>
4464
+ <div class="life-bar life-${tone}"><i style="width:${left ?? 0}%"></i></div>
4465
+ ${p.contextEvent ? `<div class="lp-event${recentContextEvent(p) && p.contextEvent.kind !== "threshold" ? " fresh" : ""}">${ic("info")} ${esc(contextEventText(p, p.contextEvent))}</div>` : ""}
4466
+ <div class="kv lp-kv">
4467
+ <span>Turns</span><span>${p.turns}</span>
4468
+ <span>Last reply</span><span>${last ? `<span title="tokens in">${ic("arrow-down")} ${fmtTokens(last.usage.inputTokens)}</span> <span title="tokens out">${ic("arrow-up")} ${fmtTokens(last.usage.outputTokens)}</span>` : "—"}</span>
4469
+ <span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
4470
+ <span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
4471
+ <span>Notes</span><span>${p.notes ? `taken at ${fmtTokens(p.notesAt || 0)} tokens` : "none yet"}${p.status === "offline" || p.status === "unstaffed" ? "" : ` · <button type="button" class="link-btn lp-take" title="A hidden turn: the vibemate writes 10 lines for a future restart; nothing is posted">${p.notes ? "refresh" : "take now"}</button>`}${p.notes ? ` · <button type="button" class="link-btn lp-edit">edit</button>` : ""}</span>
4472
+ </div>
4473
+ ${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns"><button type="button" class="btn sm ghost lp-notes-clear">Clear</button><button type="button" class="btn sm primary lp-notes-save">Save</button></div></div>` : ""}
4474
+ <div class="lp-respawn">
4475
+ <button type="button" class="btn sm danger lp-empty" title="A new session that knows nothing of this conversation">${ic("bolt")}Respawn, empty head</button>
4476
+ <label class="lp-with"><button type="button" class="btn sm lp-mem" title="A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}">Respawn with the last</button><input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
4477
+ </div>
4478
+ </div>
4479
+ <div class="lp-side"><button type="button" class="icon-btn sm lp-x" title="Close">${ic("close")}</button><button type="button" class="icon-btn sm lp-more" title="Open this vibemate's panel">${ic("settings")}</button></div>`;
4480
+ el.querySelector(".lp-x").addEventListener("click", closeLifePop);
4481
+ el.querySelector(".lp-more").addEventListener("click", () => {
4482
+ closeLifePop();
4483
+ openDetails({ kind: "participant", id: p.id });
4484
+ });
4485
+ el.querySelector(".lp-empty").addEventListener("click", () => respawnWith(p, 0));
4486
+ el.querySelector(".lp-mem").addEventListener("click", () => respawnWith(p, Number(el.querySelector(".lp-n").value) || 0));
4487
+ el.querySelector(".lp-n").addEventListener("input", (e) => (lifePop.n = Number(e.target.value) || 0));
4488
+ const take = el.querySelector(".lp-take");
4489
+ if (take)
4490
+ take.addEventListener("click", async () => {
4491
+ take.disabled = true;
4492
+ take.textContent = "asking…";
4493
+ try {
4494
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/take-notes`));
4495
+ } catch (e) {
4496
+ showError(e);
4497
+ renderLifePop();
4498
+ }
4499
+ });
4500
+ const edit = el.querySelector(".lp-edit");
4501
+ if (edit)
4502
+ edit.addEventListener("click", () => {
4503
+ el.querySelector(".lp-notes").hidden = true;
4504
+ el.querySelector(".lp-editor").hidden = false;
4505
+ el.querySelector(".lp-notes-area").focus();
4506
+ });
4507
+ const saveNotes = async (text) => {
4508
+ try {
4509
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/notes`), { notes: text });
4510
+ } catch (e) {
4511
+ showError(e);
4512
+ }
4513
+ el.querySelector(".lp-notes-area").blur();
4514
+ renderLifePop();
4515
+ };
4516
+ const save = el.querySelector(".lp-notes-save");
4517
+ if (save) save.addEventListener("click", () => saveNotes(el.querySelector(".lp-notes-area").value));
4518
+ const clear = el.querySelector(".lp-notes-clear");
4519
+ if (clear) clear.addEventListener("click", () => saveNotes(""));
4520
+ const z = zoomFactor();
4521
+ const r = lifePop.anchor.getBoundingClientRect();
4522
+ el.style.left = `${Math.round((r.right + 12) / z)}px`;
4523
+ el.style.top = `${Math.round(Math.max(8, Math.min(r.top / z - 10, window.innerHeight / z - el.offsetHeight - 8)))}px`;
4524
+ }
4525
+ async function respawnWith(p, n) {
4526
+ const text =
4527
+ n > 0
4528
+ ? `${p.name} starts over with a new session that gets only ${p.notes ? "its own notes and " : ""}the last ${n} messages of this room. You keep the history; the rest of its memory is gone.`
4529
+ : `${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`;
4530
+ const ok = await confirmDialog(text, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
4531
+ if (!ok) return;
4532
+ closeLifePop();
4533
+ try {
4534
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`), { memory: n > 0, replay: n });
4535
+ } catch (e) {
4536
+ showError(e);
4537
+ }
4538
+ }
4539
+ document.addEventListener("click", (e) => {
4540
+ if (lifePop && !e.target.closest(".life-pop") && !e.target.closest("#participants .avatar")) closeLifePop();
4541
+ });
4542
+ document.addEventListener("keydown", (e) => {
4543
+ if (e.key === "Escape" && lifePop) closeLifePop();
4544
+ });
4545
+
4369
4546
  const pinsBtn = $("#pins-btn");
4370
4547
  const pinsPanel = $("#pins-panel");
4371
4548
  function pinnedMessages(room) {
package/ui/theme.css CHANGED
@@ -67,6 +67,9 @@
67
67
  --attention-shadow: 0 8px 18px -10px rgba(232, 100, 42, 0.7);
68
68
  --unread-grad: linear-gradient(135deg, #ff7a45, #f0452c);
69
69
  --note-hover: #ffeaa8;
70
+ --done: var(--st-ready);
71
+ --done-ink: var(--st-ready-ink);
72
+ --done-hover: #c4efdb;
70
73
  --tick-mine: #cdcdf9;
71
74
  --tick-mine-view: #a9a9f5;
72
75
  --tick-fallback: #9ca3af;