Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions client/src/components/cos/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MessageCircle
} from 'lucide-react';
import { normalizeReviewerSlug, REVIEWER_VALUES } from '../../lib/reviewerPins';
import { AVATAR_STYLE_LABELS } from '../../lib/avatarStyles';
import { inPlaceClipName } from '../../utils/animationClips';

export const TABS = [
Expand Down Expand Up @@ -536,20 +537,7 @@ export const getDomainBudget = (config, domainId) => {
};

// Avatar style labels for display
export const AVATAR_STYLE_LABELS = {
svg: 'Digital (SVG)',
cyber: 'Cyberpunk (3D)',
sigil: 'Arcane Sigil (3D)',
esoteric: 'Esoteric (3D)',
nexus: 'Neural Nexus (3D)',
muse: 'Cyber Muse (3D)',
// Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed.
core: 'Core Assembly (Canvas)',
// Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars.
miniMaleC: 'Mini Character — Male (3D)',
miniFemaleD: 'Mini Character — Female (3D)',
ascii: 'Minimalist (ASCII)'
};
export { AVATAR_STYLE_LABELS };

// Dynamic avatar rules - maps task context to avatar styles
// Priority order: provider > analysisType > taskType > priority > fallback
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ grep -i "what you want to do" client/src/lib/README.md
| `agentResumeOutcome.js` | `agentResumeMessage(result, messages, fallback)` — the toast wording for a CoS agent resume/relaunch result. The server answers with a `mode` (what it did to the task) plus `spawned`/`spawnHold` (whether it also STARTED it — a resumed task is force-spawned when a slot is free, and `spawnHold` names the refusal when it isn't). `messages` is keyed by mode as `{ queued, running? }`; a mode with no `running` variant is one that deliberately queues nothing, so it can never be reported as started. Shared by the Resume dialog (`cos/tabs/AgentsTab.jsx`) and `cos/tabs/RelaunchAgentModal.jsx` so neither can say "queued" for a run that already started, or omit the reason it didn't. |
| `appIdentity.js` | `PORTOS_APP_ID` — stable id of the baseline PortOS app (mirrors `server/lib/appIdentity.js`). Dependency-free so a module reachable from a node-env server test (e.g. `components/apps/constants.js`) can identify PortOS without importing `services/apiCore.js`, which pulls in React via `ui/Toast`. `apiCore.js` re-exports it, so `import { PORTOS_APP_ID } from '../services/api'` still works. |
| `applyManuscriptEdits.js` | `applyEditsToContent(content, edits, anchorQuote)` — PREVIEW-ONLY client mirror of the server's accept splice (`server/services/pipeline/manuscriptFix.js`): locate each `find` (nearest the anchor when recurring), drop overlaps, replace bottom-up. Powers the Manuscript editor's whole-manuscript impact preview. |
| `avatarStyles.js` | `AVATAR_STYLES` — the single source of truth for the CoS avatar-style vocabulary (`{ id, label, webgl }`), plus `AVATAR_STYLE_IDS`, `AVATAR_STYLE_LABELS`, and `WEBGL_AVATAR_STYLE_IDS` derived from it. Consumed by the picker labels (`components/cos/constants.js`), the lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and — imported directly, dependency-free — the server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`). |
| `assessmentTuningNotice.js` | `tuningNoticeChip(entry)` — the short form of how a measured local-model assessment says its launch configuration did NOT take effect. `tuningApplied === false` covers two opposite cases and `tuningKey` separates them: a TUNED run whose knobs never reached the daemon (`tuning not applied`), versus an UNTUNED run PortOS could not put back on backend defaults (`not at defaults`) — #4759. Shared by `ModelThroughputReport.jsx`'s table cell and `LocalModelAssessments.jsx`'s result toast; the long form is the server's exclusion reason in `getAssessmentReport`. |
| `callAudioBridge.js` | Pure device/format helpers for the call-host page (`pages/VoiceCallHost.jsx`, #5308 + #5311) — shared by its Call mode (FaceTime Audio) and its Capture system audio mode (meeting transcription). `missingCallHostApis(scope)` lists every required browser API this browser lacks (MediaStreamTrackProcessor/Generator, AudioWorklet, `enumerateDevices`, `setSinkId`, Web Locks) so the page names all the gaps at once instead of one per reload; `findAudioDevice(devices, label, kind)` matches a device label EXACTLY after trim+case-fold (a substring match would resolve "BlackHole 1" to the 16ch device, indistinguishable from a dead call); `describeDeviceProblem(devices, {inputLabel, outputLabel})` returns the one blocking message or `null`, and deliberately blames MICROPHONE PERMISSION rather than a missing driver when every label comes back blank — an ungranted tab enumerates unlabeled devices, and "reinstall BlackHole" would send the user to fix the wrong thing; `outputLabel` is OPTIONAL — capture mode only listens, so omitting it skips the output-device check entirely rather than reporting a device named "undefined" as missing. Format side: `downmixToMono(channels)` (BlackHole 16ch carries the call on all channels; truncates to the shortest rather than reading past one), `resampleTo16k(samples, fromRate)` (linear, 48k→16k for whisper), `floatToInt16` (CLAMPED — wrapping would flip a hot sample's sign into an audible click), `rmsLevel` for the input meter, and `buildTestTone` (faded in/out so it cannot click through the call). Plus `CALL_HOST_LOCK` (the Web Lock name that stops a second tab double-answering — shared by both modes, since they read the same physical device) and `CALL_FRAME_SAMPLES`. |
| `audioRecorder.js` | `startMemoRecording()` → `{ stream, stop, cancel }` handle whose `stop()` resolves to `{ audioBase64, mimeType, peak, durationMs }` (16 kHz mono WAV, base64); the live `stream` lets a tuner tap the same mic. `createStreamAnalyser(stream, { fftSize })` → `{ analyser, context, close }` taps an `AnalyserNode` off an existing stream (no second getUserMedia). Plus `blobToWav16k`, `encodePcmToWav`, `pickRecordingMimeType`, `arrayBufferToBase64`. Standalone one-shot memo capture for catalog voice ingest — NOT the live voice-agent recorder in `services/voiceClient.js`. **`openAnalysisMic({ getUserMedia })`** → `{ stream, processing }` is the ONE way a pitch-analysis surface opens a mic: it requests `ANALYSIS_AUDIO_CONSTRAINTS` (echo cancellation / noise suppression / AGC off — plain booleans, never `{ exact }`, so a browser that can't honor one still opens the mic) and reads `track.getSettings()` back so the UI can say what actually stuck. `readAppliedProcessing(stream)` reports each stage as `true` / `false` / `null` (browser didn't say — never collapse unknown into off) and `hasUnwantedProcessing(p)` gates the warning. Memo capture deliberately keeps the speech-tuned defaults; Whisper wants them. |
Expand Down
38 changes: 38 additions & 0 deletions client/src/lib/avatarStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Single source of truth for the CoS avatar-style vocabulary. Every consumer
* that used to hand-maintain its own list derives from `AVATAR_STYLES`
* instead (#6253): the picker labels (`components/cos/constants.js`), the
* lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and the
* server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`,
* imported directly — this leaf has no transitive deps, so it's safe from
* the server workspace the way `personaTraitBlend.js`'s `clamp` import is).
*
* `webgl: true` marks a style that needs the three.js canvas stage —
* `CANVAS_AVATAR_STYLES` derives from this flag. The 2D `core` canvas style
* and the inline `svg`/`ascii` styles are deliberately `webgl: false`.
*/

export const AVATAR_STYLES = [
{ id: 'svg', label: 'Digital (SVG)', webgl: false },
{ id: 'cyber', label: 'Cyberpunk (3D)', webgl: true },
{ id: 'sigil', label: 'Arcane Sigil (3D)', webgl: true },
{ id: 'esoteric', label: 'Esoteric (3D)', webgl: true },
{ id: 'nexus', label: 'Neural Nexus (3D)', webgl: true },
{ id: 'muse', label: 'Cyber Muse (3D)', webgl: true },
// Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed.
{ id: 'core', label: 'Core Assembly (Canvas)', webgl: false },
// Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars.
{ id: 'miniMaleC', label: 'Mini Character — Male (3D)', webgl: true },
{ id: 'miniFemaleD', label: 'Mini Character — Female (3D)', webgl: true },
{ id: 'ascii', label: 'Minimalist (ASCII)', webgl: false },
];

export const AVATAR_STYLE_IDS = AVATAR_STYLES.map((style) => style.id);

export const AVATAR_STYLE_LABELS = Object.fromEntries(
AVATAR_STYLES.map((style) => [style.id, style.label])
);

export const WEBGL_AVATAR_STYLE_IDS = new Set(
AVATAR_STYLES.filter((style) => style.webgl).map((style) => style.id)
);
25 changes: 25 additions & 0 deletions client/src/lib/avatarStyles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { AVATAR_STYLES, AVATAR_STYLE_IDS, AVATAR_STYLE_LABELS, WEBGL_AVATAR_STYLE_IDS } from './avatarStyles';

describe('avatarStyles registry', () => {
it('has a unique id for every style', () => {
expect(new Set(AVATAR_STYLE_IDS).size).toBe(AVATAR_STYLES.length);
});

it('derives AVATAR_STYLE_LABELS with one entry per style', () => {
expect(Object.keys(AVATAR_STYLE_LABELS).sort()).toEqual([...AVATAR_STYLE_IDS].sort());
for (const style of AVATAR_STYLES) {
expect(AVATAR_STYLE_LABELS[style.id]).toBe(style.label);
}
});

it('derives WEBGL_AVATAR_STYLE_IDS from the webgl flag only', () => {
for (const style of AVATAR_STYLES) {
expect(WEBGL_AVATAR_STYLE_IDS.has(style.id)).toBe(style.webgl);
}
});

it('excludes the 2D core canvas style from the WebGL set', () => {
expect(WEBGL_AVATAR_STYLE_IDS.has('core')).toBe(false);
});
});
1 change: 1 addition & 0 deletions client/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export * from './a11yKeyboard.js';
export * from './agentResumeOutcome.js';
export * from './appIdentity.js';
export * from './applyManuscriptEdits.js';
export * from './avatarStyles.js';
export * from './assessmentTuningNotice.js';
export * from './audioRecorder.js';
export * from './callAudioBridge.js';
Expand Down
16 changes: 11 additions & 5 deletions client/src/pages/ChiefOfStaff.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as api from '../services/api';
import { isRiggedAvatarStyle, riggedRecordForStyle, useAvatarCapabilities } from '../hooks/useAvatarCapabilities';
import { coalesce } from '../utils/coalesce';
import { sameJsonShape } from '../lib/sameJsonShape';
import { WEBGL_AVATAR_STYLE_IDS } from '../lib/avatarStyles';
import { Play, Pause, Square, Clock, CheckCircle, AlertCircle, Cpu, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Brain, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import toast from '../components/ui/Toast';
import BrailleSpinner from '../components/BrailleSpinner';
Expand Down Expand Up @@ -61,7 +62,13 @@ const BriefingTab = lazy(() => import('../components/cos/tabs/BriefingTab'));
// Three.js-based avatars lazy-loaded so the R3F stack isn't bundled unless the
// user's chosen avatar style actually needs it. `core` is a plain 2D canvas
// (no three.js) but stays lazy so the default SVG path loads nothing extra.
const LAZY_AVATARS = {
// Every registry id (`lib/avatarStyles.js`) EXCEPT `svg`/`ascii` renders through
// a lazily-loaded component here — those two fall through to the inline
// `CoSCharacter` default below. `ChiefOfStaff.avatarStyles.test.jsx` fails if a
// registry id is missing from this map (or from the inline-rendered pair).
export const INLINE_RENDERED_AVATAR_STYLES = new Set(['svg', 'ascii']);

export const LAZY_AVATARS = {
cyber: lazy(() => import('../components/cos/CyberCoSAvatar')),
sigil: lazy(() => import('../components/cos/SigilCoSAvatar')),
esoteric: lazy(() => import('../components/cos/EsotericCoSAvatar')),
Expand All @@ -79,10 +86,9 @@ const LAZY_AVATARS = {
// avatar so three.js stays out of the main chunk until it is picked.
const LazyRiggedAvatar = lazy(() => import('../components/cos/MiniCharacterCoSAvatar'));

const CANVAS_AVATAR_STYLES = new Set([
'cyber', 'sigil', 'esoteric', 'nexus', 'muse',
'miniMaleC', 'miniFemaleD',
]);
// `CANVAS_AVATAR_STYLES` means "needs the WebGL/three.js stage" — derives
// directly from the registry's `webgl` flag (`lib/avatarStyles.js`).
const CANVAS_AVATAR_STYLES = WEBGL_AVATAR_STYLE_IDS;

// Shared brand gradient for the "CoS" wordmark headings (clipped to text).
const COS_TITLE_GRADIENT = 'linear-gradient(135deg, #6366f1, #8b5cf6, #06b6d4)';
Expand Down
25 changes: 24 additions & 1 deletion client/src/pages/ChiefOfStaff.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ vi.mock('../components/cos/MiniCharacterCoSAvatar', () => ({
),
}));

const { default: ChiefOfStaff, SPEAKING_MS } = await import('./ChiefOfStaff');
const { default: ChiefOfStaff, SPEAKING_MS, LAZY_AVATARS, INLINE_RENDERED_AVATAR_STYLES } = await import('./ChiefOfStaff');
const { AVATAR_STYLE_IDS } = await import('../lib/avatarStyles');

const config = {
avatarStyle: 'svg',
Expand Down Expand Up @@ -944,3 +945,25 @@ describe('CoS agent panel layout sizing', () => {
});
});

// #6253 — every avatar style in the shared registry (`lib/avatarStyles.js`)
// must render through either the lazy-load map or the inline-rendered pair,
// or a style added to the registry alone silently falls through to the
// default `CoSCharacter` render instead of failing loudly.
describe('avatar-style registry coverage', () => {
it('has a render path for every registry id, and no extra map entries', () => {
const lazyIds = new Set(Object.keys(LAZY_AVATARS));
for (const id of AVATAR_STYLE_IDS) {
expect(
lazyIds.has(id) || INLINE_RENDERED_AVATAR_STYLES.has(id),
`avatar style "${id}" is missing from both LAZY_AVATARS and INLINE_RENDERED_AVATAR_STYLES`
).toBe(true);
}
for (const id of lazyIds) {
expect(AVATAR_STYLE_IDS, `LAZY_AVATARS has an entry for unknown style "${id}"`).toContain(id);
}
for (const id of INLINE_RENDERED_AVATAR_STYLES) {
expect(AVATAR_STYLE_IDS, `INLINE_RENDERED_AVATAR_STYLES has an entry for unknown style "${id}"`).toContain(id);
}
});
});

6 changes: 5 additions & 1 deletion server/routes/cosStatusRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { validateRequest } from '../lib/validation.js';
import { z } from 'zod';
import { DOMAIN_IDS, DOMAIN_MODES } from '../lib/domainAutonomy.js';
import { AVATAR_VARIANT_PATTERN, RIGGED_VARIANT_PREFIX } from '../lib/avatarVariants.js';
// Single source of truth for the avatar-style vocabulary (#6253) — a
// dependency-free leaf, safe to import from the server the way
// `server/lib/personaTraitBlend.js` imports `clamp` from client `utils/formatters.js`.
import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js';
import { BUDGET_LIMIT_FIELDS } from '../lib/domainBudgets.js';
import { persistentMindCapabilitiesSchema } from '../lib/persistentMindCapabilities.js';
import { persistentMindProfileSchema } from '../lib/persistentMindProfile.js';
Expand Down Expand Up @@ -46,7 +50,7 @@ export const cosConfigSchema = z.object({
// the same traversal guard `server/routes/avatar.js` enforces), so unknown
// spellings still 400 here instead of persisting a style nothing can render.
avatarStyle: z.union([
z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'core', 'miniMaleC', 'miniFemaleD']),
z.enum(AVATAR_STYLE_IDS),
z.string().startsWith(RIGGED_VARIANT_PREFIX).refine(
(value) => AVATAR_VARIANT_PATTERN.test(value.slice(RIGGED_VARIANT_PREFIX.length)),
),
Expand Down
11 changes: 8 additions & 3 deletions server/routes/cosStatusRoutesAvatar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ vi.mock('../services/taskWatcher.js', () => ({}));
vi.mock('../services/memoryEmbeddings.js', () => ({ reinitialize: vi.fn() }));

import { cosConfigSchema } from './cosStatusRoutes.js';
import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js';

describe('cosConfigSchema avatarStyle', () => {
it('accepts built-in styles and rigged record spellings', () => {
expect(cosConfigSchema.safeParse({ avatarStyle: 'muse' }).success).toBe(true);
expect(cosConfigSchema.safeParse({ avatarStyle: 'core' }).success).toBe(true);
it('accepts every style in the shared registry, so a style added there is never a settings 400', () => {
for (const id of AVATAR_STYLE_IDS) {
expect(cosConfigSchema.safeParse({ avatarStyle: id }).success, `avatarStyle "${id}"`).toBe(true);
}
});

it('accepts rigged record spellings', () => {
expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-image3d-abc-123' }).success).toBe(true);
});

Expand Down