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.
- package/dist/context.js +42 -0
- package/dist/recipes.js +41 -2
- package/dist/room.js +151 -14
- package/dist/server.js +9 -3
- package/package.json +1 -1
- package/ui/app.css +49 -6
- package/ui/app.js +193 -16
- package/ui/theme.css +3 -0
package/dist/context.js
ADDED
|
@@ -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
|
|
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,
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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(--
|
|
278
|
-
.done-note:hover { background: var(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
637
|
-
.done-row:hover { background: var(--
|
|
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")}
|
|
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")}
|
|
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
|
-
|
|
1962
|
-
|
|
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;
|