2026 03 14 Whistle Group Chat
Documentation for Plans
Whistle Group Chat — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let users "whistle" space coworkers into conversations with Vitto, creating cross-channel group chats with adaptive AI behavior.
Architecture: Extend ChatSession (ai-api) with participants[] for group persistence. New WhistleOrchestratorService (vitto-api) for permissions + delivery. Socket.IO broadcasts to all participant rooms. Four new Angular components for the whistle UI.
Tech Stack: Angular 20+ (signals, OnPush), NestJS, MongoDB, Socket.IO, ProactiveDispatcher, Dossier, Genome
Spec: docs/superpowers/specs/2026-03-14-whistle-group-chat-design.md
Phase Overview
| Phase | What | Files | Testable on its own? |
|---|---|---|---|
| 1 | Backend schemas + persistence | ai-api, backend-api, vitto-api schemas | Yes — API endpoints work |
| 2 | Whistle orchestration | vitto-api WhistleOrchestratorService | Yes — whistle via REST |
| 3 | Socket.IO real-time | ai-api ChatGateway | Yes — messages broadcast |
| 4 | Frontend components | @libs-ai/vitto-chat | Yes — UI renders |
| 5 | LLM tools + Vitto behavior | vitto-api tools + context assembly | Yes — natural language whistle |
| 6 | Close flow + Dossier extraction | ai-api cron + vitto-api extraction | Yes — conversations close |
Chunk 1: Phase 1 — Backend Schemas + Core Persistence
Task 1: Add ParticipantDoc schema and group fields to ChatSession
Files:
Modify:
APIs/backend-ai-api/src/app/sessions/sessions.schema.ts:43-229Modify:
APIs/backend-ai-api/src/app/sessions/sessions.types.ts:16-133Step 1: Add ParticipantDoc sub-document schema
Add after IntelligentMessageDoc class (after line 87):
@Schema({ _id: false })
export class ParticipantDoc {
@Prop({ required: true })
userId!: string;
@Prop({ required: true })
displayName!: string;
@Prop({ required: true, enum: ['originator', 'member'] })
role!: 'originator' | 'member';
@Prop({ required: true })
joinedAt!: number;
@Prop({ required: true, enum: ['full', 'summary', 'none'], default: 'summary' })
historyAccess!: 'full' | 'summary' | 'none';
@Prop()
readCursor?: number;
@Prop()
channel?: string;
}
- Step 2: Add senderId and senderName to IntelligentMessageDoc
Add after the channel field (after line 86):
// S218: Sender identity for group chat messages
@Prop()
senderId?: string;
@Prop()
senderName?: string;
- Step 3: Add group fields to ChatSession
Add after spaceId (after line 218):
// S218: Group chat — whistle feature
@Prop({ type: [ParticipantDoc], default: undefined })
participants?: ParticipantDoc[];
@Prop({ sparse: true })
isGroup?: boolean;
@Prop({ enum: ['intra-space', 'open'] })
groupType?: string;
@Prop()
groupConversationId?: string;
- Step 4: Add participants index
Add after the existing indexes (after line 229):
// S218: Group session queries — find all groups a user is in
ChatSessionSchema.index({ 'participants.userId': 1, isGroup: 1, updatedAt: -1 });
- Step 5: Update types
In sessions.types.ts, add to PersistedSession interface:
participants?: Array<{
userId: string;
displayName: string;
role: 'originator' | 'member';
joinedAt: number;
historyAccess: 'full' | 'summary' | 'none';
readCursor?: number;
channel?: string;
}>;
isGroup?: boolean;
groupType?: string;
groupConversationId?: string;
Add to IntelligentMessage type:
senderId?: string;
senderName?: string;
- Step 6: Commit
git add APIs/backend-ai-api/src/app/sessions/sessions.schema.ts APIs/backend-ai-api/src/app/sessions/sessions.types.ts
git commit -m "[S218.1] feat: add ParticipantDoc schema and group fields to ChatSession"
Task 2: Add whistle and group endpoints to ai-api SessionsController
Files:
Modify:
APIs/backend-ai-api/src/app/sessions/sessions.service.tsModify:
APIs/backend-ai-api/src/app/sessions/sessions.controller.tsStep 1: Add
addParticipantmethod to SessionsService
/**
* S218: Add a participant to a session (whistle).
* Upgrades a 1:1 session to a group session if not already a group.
*/
async addParticipant(
sessionId: string,
participant: {
userId: string;
displayName: string;
role: 'originator' | 'member';
historyAccess: 'full' | 'summary' | 'none';
channel?: string;
},
originatorUserId?: string,
): Promise<{ success: boolean; isNewGroup: boolean }> {
const session = await this.sessionModel.findOne({ sessionId });
if (!session) return { success: false, isNewGroup: false };
const isNewGroup = !session.isGroup;
const now = Date.now();
if (isNewGroup) {
// First whistle — upgrade to group
session.isGroup = true;
session.groupType = 'intra-space';
session.participants = [
{
userId: originatorUserId || session.userId || 'unknown',
displayName: 'Originator', // Will be set by orchestrator
role: 'originator',
joinedAt: session.createdAt,
historyAccess: 'full',
},
];
}
// Add the new participant
const existing = (session.participants || []).find(p => p.userId === participant.userId);
if (!existing) {
session.participants = [
...(session.participants || []),
{ ...participant, joinedAt: now },
];
}
session.updatedAt = now;
await session.save();
return { success: true, isNewGroup };
}
- Step 2: Add
getGroupSessionsmethod to SessionsService
/**
* S218: Get all group sessions a user participates in.
*/
async getGroupSessions(userId: string, limit = 20): Promise<SessionSummary[]> {
const sessions = await this.sessionModel
.find({ 'participants.userId': userId, isGroup: true })
.sort({ updatedAt: -1 })
.limit(limit)
.select('sessionId title context metadata createdAt updatedAt messages participants')
.lean();
return sessions.map(s => this.toSessionSummary(s));
}
- Step 3: Add
updateReadCursormethod to SessionsService
/**
* S218: Update a participant's read cursor position.
*/
async updateReadCursor(sessionId: string, userId: string, timestamp: number): Promise<void> {
await this.sessionModel.updateOne(
{ sessionId, 'participants.userId': userId },
{ $set: { 'participants.$.readCursor': timestamp } },
);
}
- Step 4: Add controller endpoints
Add BEFORE @Get(':id') to avoid route conflicts:
/**
* S218: Get all group sessions for the authenticated user.
* MUST be declared before @Get(':id') to prevent NestJS route conflict.
*/
@Public()
@Get('my/groups')
async getMyGroups(
@CurrentUser() authUser: AuthenticatedUser | null,
@Query('limit') limit?: string,
): Promise<{ sessions: SessionSummary[] }> {
if (!authUser?.userId) return { sessions: [] };
try {
const sessions = await this.sessionsService.getGroupSessions(
authUser.userId,
limit ? parseInt(limit, 10) : 20,
);
return { sessions };
} catch (error: any) {
this.logger.error(`Failed to get group sessions: ${error.message}`);
return { sessions: [] };
}
}
/**
* S218: Whistle — add a participant to a session.
*/
@Public()
@Post(':id/whistle')
async whistleParticipant(
@Param('id') sessionId: string,
@Body() body: {
userId: string;
displayName: string;
historyAccess?: 'full' | 'summary' | 'none';
channel?: string;
originatorUserId?: string;
originatorDisplayName?: string;
},
@CurrentUser() authUser: AuthenticatedUser | null,
): Promise<{ success: boolean; isNewGroup: boolean }> {
try {
const originatorId = authUser?.userId || body.originatorUserId;
const result = await this.sessionsService.addParticipant(
sessionId,
{
userId: body.userId,
displayName: body.displayName,
role: 'member',
historyAccess: body.historyAccess || 'summary',
channel: body.channel,
},
originatorId,
);
if (result.success && this.chatGateway) {
// Broadcast whistle event to the target user
this.chatGateway.emitWhistle(sessionId, body.userId, {
whistledBy: originatorId || 'unknown',
whistledByName: body.originatorDisplayName || 'Someone',
sessionId,
});
}
return result;
} catch (error: any) {
this.logger.error(`Failed to whistle ${body.userId} into ${sessionId}: ${error.message}`);
return { success: false, isNewGroup: false };
}
}
/**
* S218: Get participants for a group session.
*/
@Public()
@Get(':id/participants')
async getParticipants(
@Param('id') sessionId: string,
): Promise<{ participants: any[] }> {
try {
const session = await this.sessionsService.getSession(sessionId);
return { participants: session?.participants || [] };
} catch (error: any) {
this.logger.error(`Failed to get participants for ${sessionId}: ${error.message}`);
return { participants: [] };
}
}
/**
* S218: Update read cursor for unread badges.
*/
@Public()
@Patch(':id/read-cursor')
async updateReadCursor(
@Param('id') sessionId: string,
@Body() body: { timestamp: number },
@CurrentUser() authUser: AuthenticatedUser | null,
): Promise<{ success: boolean }> {
if (!authUser?.userId) return { success: false };
try {
await this.sessionsService.updateReadCursor(sessionId, authUser.userId, body.timestamp);
return { success: true };
} catch (error: any) {
this.logger.error(`Failed to update read cursor: ${error.message}`);
return { success: false };
}
}
- Step 5: Add Patch import to controller
Add Patch to the NestJS imports if not present.
- Step 6: Commit
git add APIs/backend-ai-api/src/app/sessions/sessions.service.ts APIs/backend-ai-api/src/app/sessions/sessions.controller.ts
git commit -m "[S218.1] feat: add whistle and group endpoints to ai-api"
Task 3: Add space members endpoint to backend-api
Files:
Modify:
APIs/backend-api/src/app/spaces/spaces.controller.tsModify:
APIs/backend-api/src/app/spaces/spaces.service.tsStep 1: Add
getSpaceMembersmethod to SpacesService
/**
* S218: Get space members with basic profile data for whistle UI.
*/
async getSpaceMembers(spaceId: string): Promise<SpaceMemberResponse[]> {
const space = await this.spaceModel.findById(spaceId).select('members').lean();
if (!space) return [];
return (space.members || []).map(m => ({
userId: m.userId,
role: m.role,
joinedAt: m.joinedAt?.toISOString() || new Date().toISOString(),
}));
}
- Step 2: Add controller endpoint
/**
* S218: List space members — used by whistle UI and vitto-api permission checks.
*/
@Public()
@Get(':spaceId/members')
async getSpaceMembers(
@Param('spaceId') spaceId: string,
): Promise<{ members: SpaceMemberResponse[] }> {
try {
const members = await this.spacesService.getSpaceMembers(spaceId);
return { members };
} catch (error: any) {
this.logger.error(`Failed to get space members: ${error.message}`);
return { members: [] };
}
}
- Step 3: Commit
git add APIs/backend-api/src/app/spaces/spaces.controller.ts APIs/backend-api/src/app/spaces/spaces.service.ts
git commit -m "[S218.1] feat: add GET /spaces/:spaceId/members endpoint"
Task 4: Add whistle preferences to UserNotificationPrefsDoc
Files:
Modify:
APIs/backend-vitto-api/src/app/mind/schemas/user-notification-prefs.schema.tsStep 1: Add whistlePreferences field
Add after the existing fields (after line 26):
/** S218: Whistle availability preferences */
@Prop({
type: Object,
default: { availability: 'anyone', overrideDelivery: 'smart' },
})
whistlePreferences?: {
availability: 'anyone' | 'admins_only' | 'allowlist' | 'nobody';
allowlist?: string[];
overrideDelivery?: 'smart' | 'always_notify' | 'always_silent';
};
- Step 2: Commit
git add APIs/backend-vitto-api/src/app/mind/schemas/user-notification-prefs.schema.ts
git commit -m "[S218.1] feat: add whistle preferences to UserNotificationPrefsDoc"
Task 5: Make GroupConversation.externalGroupId optional + add chatSessionId
Files:
Modify:
libs/libs-api/messaging-core/src/lib/schemas/group-conversation.schema.ts:49-51Step 1: Make externalGroupId optional and add chatSessionId
Change line 50 from required: true to optional, and add chatSessionId:
/** External group identifier — WhatsApp JID, Telegram chat_id, Slack channel_id */
@Prop({ index: true })
externalGroupId?: string;
/** S218: Link to ai-api ChatSession for web-originated groups */
@Prop({ index: true })
chatSessionId?: string;
- Step 2: Commit
git add libs/libs-api/messaging-core/src/lib/schemas/group-conversation.schema.ts
git commit -m "[S218.1] feat: make GroupConversation.externalGroupId optional, add chatSessionId"
Chunk 2: Phase 2 — Whistle Orchestration Service
Task 6: Create WhistleOrchestratorService in vitto-api
Files:
Create:
APIs/backend-vitto-api/src/app/engine/whistle-orchestrator.service.tsModify:
APIs/backend-vitto-api/src/app/engine/vitto.controller.tsStep 1: Create WhistleOrchestratorService
/**
* WhistleOrchestratorService — Core whistle logic
* S218: Permission check → delivery → session upgrade
*/
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { firstValueFrom } from 'rxjs';
import { UserNotificationPrefsDoc } from '../mind/schemas/user-notification-prefs.schema';
import { UserDossierService } from '../users/user-dossier.service';
import { ProactiveDispatcherService } from '../proactive/proactive-dispatcher.service';
import { BackendAiClientService } from '../connectivity/backend-ai-client.service';
import { ConversationGenomeService } from './conversation-genome-bridge.service'; // or similar
export interface WhistleRequest {
sessionId: string;
targetUserId: string;
targetDisplayName: string;
whistlerUserId: string;
whistlerDisplayName: string;
spaceId: string;
historyAccess: 'full' | 'summary' | 'none';
urgency: 'smart' | 'ring' | 'notify' | 'silent';
}
export interface WhistleResult {
success: boolean;
delivered: boolean;
deliveryChannel?: string;
reason?: string;
}
@Injectable()
export class WhistleOrchestratorService {
private readonly logger = new Logger('WhistleOrchestrator');
private readonly backendApiUrl: string;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
@InjectModel(UserNotificationPrefsDoc.name)
private readonly notifPrefsModel: Model<UserNotificationPrefsDoc>,
@Optional() private readonly dossier?: UserDossierService,
@Optional() private readonly dispatcher?: ProactiveDispatcherService,
@Optional() private readonly aiClient?: BackendAiClientService,
) {
this.backendApiUrl = this.config.get<string>('BACKEND_API_URL', 'http://localhost:3000/api');
}
async whistle(request: WhistleRequest): Promise<WhistleResult> {
const { sessionId, targetUserId, targetDisplayName, whistlerUserId,
whistlerDisplayName, spaceId, historyAccess, urgency } = request;
// 1. Check space membership
const isMember = await this.checkSpaceMembership(spaceId, targetUserId);
if (!isMember) {
return { success: false, delivered: false, reason: `${targetDisplayName} is not a member of this space` };
}
// 2. Check whistle preferences
const prefCheck = await this.checkWhistlePreferences(targetUserId, whistlerUserId, spaceId);
if (!prefCheck.allowed) {
return { success: false, delivered: false, reason: prefCheck.reason };
}
// 3. Upgrade session in ai-api
const aiApiUrl = this.config.get<string>('BACKEND_AI_API_URL', 'http://localhost:3001/ai-api');
const token = this.config.get<string>('BACKEND_API_INTERNAL_TOKEN', '');
try {
await firstValueFrom(this.http.post(
`${aiApiUrl}/sessions/${sessionId}/whistle`,
{
userId: targetUserId,
displayName: targetDisplayName,
historyAccess,
originatorUserId: whistlerUserId,
originatorDisplayName: whistlerDisplayName,
},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 5000 },
));
} catch (err) {
this.logger.error(`Failed to upgrade session ${sessionId}: ${(err as Error).message}`);
return { success: false, delivered: false, reason: 'Failed to upgrade session' };
}
// 4. Deliver whistle notification
const deliveryResult = await this.deliverWhistle(request, prefCheck.overrideDelivery);
this.logger.log(`Whistled ${targetDisplayName} into ${sessionId} via ${deliveryResult.channel}`);
return { success: true, delivered: true, deliveryChannel: deliveryResult.channel };
}
private async checkSpaceMembership(spaceId: string, userId: string): Promise<boolean> {
try {
const { data } = await firstValueFrom(this.http.get<{ members: Array<{ userId: string }> }>(
`${this.backendApiUrl}/spaces/${spaceId}/members`,
{ timeout: 3000 },
));
return (data?.members || []).some(m => m.userId === userId);
} catch {
return false;
}
}
private async checkWhistlePreferences(
targetUserId: string,
whistlerUserId: string,
spaceId: string,
): Promise<{ allowed: boolean; reason?: string; overrideDelivery?: string }> {
const prefs = await this.notifPrefsModel.findOne({ userId: targetUserId }).lean().catch(() => null);
const whistlePrefs = (prefs as any)?.whistlePreferences || { availability: 'anyone', overrideDelivery: 'smart' };
switch (whistlePrefs.availability) {
case 'nobody':
return { allowed: false, reason: 'User has disabled whistles' };
case 'allowlist':
if (!whistlePrefs.allowlist?.includes(whistlerUserId)) {
return { allowed: false, reason: 'User only accepts whistles from specific people' };
}
break;
case 'admins_only': {
const isAdmin = await this.checkWhistlerIsAdmin(spaceId, whistlerUserId);
if (!isAdmin) {
return { allowed: false, reason: 'User only accepts whistles from space admins' };
}
break;
}
case 'anyone':
default:
break;
}
// Check quiet hours (unless urgency = ring)
const dossierData = await this.dossier?.getOrCreateDossier(targetUserId).catch(() => null);
const quietPrefs = dossierData?.proactivePreferences;
if (quietPrefs?.quietHoursStart && quietPrefs?.quietHoursEnd) {
// Simplified quiet hours check — reuse ProactiveDispatcher pattern
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const [startH, startM] = quietPrefs.quietHoursStart.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const [endH, endM] = quietPrefs.quietHoursEnd.split(':').map(Number);
const endMinutes = endH * 60 + endM;
const inQuietHours = startMinutes <= endMinutes
? currentMinutes >= startMinutes && currentMinutes < endMinutes
: currentMinutes >= startMinutes || currentMinutes < endMinutes;
if (inQuietHours) {
return { allowed: true, reason: 'In quiet hours — will deliver silently', overrideDelivery: 'always_silent' };
}
}
return { allowed: true, overrideDelivery: whistlePrefs.overrideDelivery };
}
private async checkWhistlerIsAdmin(spaceId: string, userId: string): Promise<boolean> {
try {
const { data } = await firstValueFrom(this.http.get<{ members: Array<{ userId: string; role: string }> }>(
`${this.backendApiUrl}/spaces/${spaceId}/members`,
{ timeout: 3000 },
));
const member = (data?.members || []).find(m => m.userId === userId);
return member?.role === 'owner' || member?.role === 'admin';
} catch {
return false;
}
}
private async deliverWhistle(
request: WhistleRequest,
overrideDelivery?: string,
): Promise<{ channel: string }> {
const delivery = request.urgency !== 'smart' ? request.urgency : (overrideDelivery || 'smart');
if (delivery === 'silent') {
// No notification — session just appears in sidebar via Socket.IO
return { channel: 'silent' };
}
// Use ProactiveDispatcher for notification delivery
if (this.dispatcher) {
const severity = delivery === 'ring' ? 'critical' : 'info';
const result = await this.dispatcher.dispatch({
userId: request.targetUserId,
triggerName: 'whistle',
severity,
title: `${request.whistlerDisplayName} wants your input`,
message: `You've been whistled into a conversation. Join to see what's being discussed.`,
metadata: {
sessionId: request.sessionId,
whistlerUserId: request.whistlerUserId,
spaceId: request.spaceId,
},
bypassGuards: delivery === 'ring', // Ring bypasses quiet hours
});
return { channel: result.channels[0] || 'web' };
}
return { channel: 'web' };
}
}
- Step 2: Add whistle endpoints to VittoController
Add to the existing VittoController:
/**
* S218: Whistle a user into a conversation.
*/
@Post('whistle')
async whistle(
@Body() body: WhistleRequest,
): Promise<WhistleResult> {
return this.whistleOrchestrator.whistle(body);
}
- Step 3: Register WhistleOrchestratorService in the engine module
Add to the appropriate module's providers array.
- Step 4: Commit
git add APIs/backend-vitto-api/src/app/engine/whistle-orchestrator.service.ts APIs/backend-vitto-api/src/app/engine/vitto.controller.ts
git commit -m "[S218.1] feat: WhistleOrchestratorService with permissions, delivery, session upgrade"
Chunk 3: Phase 3 — Socket.IO Real-Time
Task 7: Extend ChatGateway for group broadcast + whistle events
Files:
Modify:
APIs/backend-ai-api/src/app/sessions/chat.gateway.tsStep 1: Add emitWhistle method
/**
* S218: Emit whistle notification to target user.
*/
emitWhistle(sessionId: string, targetUserId: string, data: {
whistledBy: string;
whistledByName: string;
sessionId: string;
contextSummary?: string;
urgency?: string;
}): void {
this.server.to(`user:${targetUserId}`).emit('chat:whistled', {
...data,
timestamp: Date.now(),
});
}
/**
* S218: Emit participant-joined to all session viewers.
*/
emitParticipantJoined(sessionId: string, participant: {
userId: string;
displayName: string;
joinedAt: number;
}): void {
this.server.to(`chat:${sessionId}`).emit('chat:participant-joined', {
sessionId,
...participant,
});
}
- Step 2: Modify emitNewMessages for group broadcast
In the existing emitNewMessages method, add participant room broadcast:
// S218: For group sessions, also broadcast to each participant's user room
// This ensures delivery to all devices even if they haven't opened this session
if (participants?.length) {
for (const p of participants) {
if (p.userId !== senderId) {
this.server.to(`user:${p.userId}`).emit('chat:messages', {
sessionId, messages, timestamp: Date.now(),
});
}
}
}
The emitNewMessages method needs the participants array passed in. Modify the appendMessages call chain to pass participants from the session document.
- Step 3: Commit
git add APIs/backend-ai-api/src/app/sessions/chat.gateway.ts
git commit -m "[S218.1] feat: Socket.IO group broadcast + whistle events"
Chunk 4: Phase 4 — Frontend Components
Task 8: Add WhistleService (frontend orchestration)
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/services/whistle.service.tsStep 1: Create WhistleService
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AI_API_URL, VITTO_API_URL } from '@libs-foundational/api-config';
import { ChatRealtimeService } from '@libs-ai/chat-sync';
import { VittoChatPersistenceService } from './vitto-chat-persistence.service';
export interface SpaceMember {
userId: string;
displayName: string;
role: string;
online?: boolean;
}
@Injectable({ providedIn: 'root' })
export class WhistleService {
private readonly http = inject(HttpClient);
private readonly aiApiUrl = inject(AI_API_URL);
private readonly vittoApiUrl = inject(VITTO_API_URL, { optional: true });
private readonly realtime = inject(ChatRealtimeService);
private readonly persistence = inject(VittoChatPersistenceService);
readonly isWhistling = signal(false);
readonly spaceMembers = signal<SpaceMember[]>([]);
/** Load space members for the whistle dropdown */
loadMembers(spaceId: string): void {
this.http.get<{ members: SpaceMember[] }>(
`${this.aiApiUrl.replace('/ai-api', '/api')}/spaces/${spaceId}/members`,
).subscribe({
next: (res) => this.spaceMembers.set(res.members || []),
error: () => this.spaceMembers.set([]),
});
}
/** Execute a whistle via vitto-api orchestration */
whistle(params: {
sessionId: string;
targetUserId: string;
targetDisplayName: string;
whistlerUserId: string;
whistlerDisplayName: string;
spaceId: string;
historyAccess: 'full' | 'summary' | 'none';
urgency: 'smart' | 'ring' | 'notify' | 'silent';
}): void {
if (!this.vittoApiUrl) return;
this.isWhistling.set(true);
this.http.post(`${this.vittoApiUrl}/engine/whistle`, params).subscribe({
next: () => this.isWhistling.set(false),
error: () => this.isWhistling.set(false),
});
}
}
- Step 2: Commit
git add libs/libs-ai/vitto-chat/src/lib/services/whistle.service.ts
git commit -m "[S218.1] feat: WhistleService frontend orchestration"
Task 9: Create whistle-button component
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/components/whistle-button/whistle-button.component.tsCreate:
libs/libs-ai/vitto-chat/src/lib/components/whistle-button/whistle-button.component.htmlCreate:
libs/libs-ai/vitto-chat/src/lib/components/whistle-button/whistle-button.component.scssStep 1: Create component .ts
import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { WhistleService, SpaceMember } from '../../services/whistle.service';
@Component({
selector: 'lib-whistle-button',
standalone: true,
imports: [CommonModule],
templateUrl: './whistle-button.component.html',
styleUrl: './whistle-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WhistleButtonComponent {
readonly spaceId = input.required<string>();
readonly sessionId = input.required<string>();
readonly currentUserId = input.required<string>();
readonly currentUserName = input<string>('');
readonly memberSelected = output<{ member: SpaceMember; historyAccess: string; urgency: string }>();
private readonly whistleService = inject(WhistleService);
readonly members = this.whistleService.spaceMembers;
readonly isOpen = signal(false);
readonly isWhistling = this.whistleService.isWhistling;
readonly filteredMembers = computed(() =>
this.members().filter(m => m.userId !== this.currentUserId())
);
toggle(): void {
const opening = !this.isOpen();
this.isOpen.set(opening);
if (opening && this.members().length === 0) {
this.whistleService.loadMembers(this.spaceId());
}
}
selectMember(member: SpaceMember): void {
this.isOpen.set(false);
this.memberSelected.emit({ member, historyAccess: 'summary', urgency: 'smart' });
}
}
- Step 2: Create template
<div class="whistle-container">
<button class="whistle-btn" (click)="toggle()" [disabled]="isWhistling()" title="Whistle a coworker">
<span class="whistle-icon">📢</span>
</button>
@if (isOpen()) {
<div class="whistle-dropdown">
<div class="whistle-dropdown__header">Whistle someone in</div>
@for (member of filteredMembers(); track member.userId) {
<button class="whistle-dropdown__item" (click)="selectMember(member)">
<span class="member-avatar">{{ member.displayName?.charAt(0) || '?' }}</span>
<span class="member-name">{{ member.displayName || member.userId }}</span>
<span class="member-role">{{ member.role }}</span>
</button>
} @empty {
<div class="whistle-dropdown__empty">No other members in this space</div>
}
</div>
}
</div>
- Step 3: Create styles
.whistle-container {
position: relative;
}
.whistle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(139, 92, 246, 0.15);
color: var(--vitto-accent, #8b5cf6);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(139, 92, 246, 0.25);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.whistle-icon {
font-size: 1rem;
}
.whistle-dropdown {
position: absolute;
top: 100%;
right: 0;
width: 240px;
margin-top: 4px;
background: var(--vitto-surface, #1a1a2e);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
&__header {
padding: 10px 14px;
font-size: 0.8rem;
font-weight: 600;
color: var(--vitto-muted, #94a3b8);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
&__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
color: var(--vitto-text, #e2e8f0);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: rgba(139, 92, 246, 0.1);
}
}
&__empty {
padding: 12px 14px;
font-size: 0.8rem;
color: var(--vitto-muted, #94a3b8);
text-align: center;
}
}
.member-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--vitto-accent, #8b5cf6);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.member-name {
flex: 1;
text-align: left;
}
.member-role {
font-size: 0.7rem;
color: var(--vitto-muted, #94a3b8);
}
- Step 4: Commit
git add libs/libs-ai/vitto-chat/src/lib/components/whistle-button/
git commit -m "[S218.1] feat: whistle-button component with member dropdown"
Task 10: Create participant-strip component
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/components/participant-strip/participant-strip.component.tsCreate:
libs/libs-ai/vitto-chat/src/lib/components/participant-strip/participant-strip.component.htmlCreate:
libs/libs-ai/vitto-chat/src/lib/components/participant-strip/participant-strip.component.scssStep 1: Create component
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface Participant {
userId: string;
displayName: string;
role: 'originator' | 'member';
}
@Component({
selector: 'lib-participant-strip',
standalone: true,
imports: [CommonModule],
templateUrl: './participant-strip.component.html',
styleUrl: './participant-strip.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ParticipantStripComponent {
readonly participants = input<Participant[]>([]);
readonly isGroup = input(false);
readonly displayParticipants = computed(() => {
const ps = this.participants();
// Always show Vitto as first
return [{ userId: 'vitto', displayName: 'Vitto', role: 'member' as const }, ...ps];
});
getInitial(name: string): string {
return name?.charAt(0)?.toUpperCase() || '?';
}
getColor(index: number): string {
const colors = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'];
return colors[index % colors.length];
}
}
- Step 2: Create template
@if (isGroup()) {
<div class="participant-strip">
@for (p of displayParticipants(); track p.userId; let i = $index) {
<div
class="participant-avatar"
[style.background]="getColor(i)"
[title]="p.displayName">
{{ getInitial(p.displayName) }}
</div>
}
<span class="participant-count">{{ displayParticipants().length }} in conversation</span>
</div>
}
- Step 3: Create styles
.participant-strip {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.participant-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
border: 2px solid var(--vitto-surface, #1a1a2e);
margin-left: -6px;
&:first-child {
margin-left: 0;
}
}
.participant-count {
margin-left: 8px;
font-size: 0.75rem;
color: var(--vitto-muted, #94a3b8);
}
- Step 4: Commit
git add libs/libs-ai/vitto-chat/src/lib/components/participant-strip/
git commit -m "[S218.1] feat: participant-strip component for group chat header"
Task 11: Create whistle-notification component
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/components/whistle-notification/whistle-notification.component.tsCreate:
libs/libs-ai/vitto-chat/src/lib/components/whistle-notification/whistle-notification.component.htmlCreate:
libs/libs-ai/vitto-chat/src/lib/components/whistle-notification/whistle-notification.component.scssStep 1: Create component
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'lib-whistle-notification',
standalone: true,
imports: [CommonModule],
templateUrl: './whistle-notification.component.html',
styleUrl: './whistle-notification.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WhistleNotificationComponent {
readonly whistledBy = input.required<string>();
readonly sessionId = input.required<string>();
readonly contextSummary = input<string>('');
readonly urgency = input<string>('notify');
readonly joined = output<string>();
readonly declined = output<string>();
readonly deferred = output<string>();
onJoin(): void {
this.joined.emit(this.sessionId());
}
onDecline(): void {
this.declined.emit(this.sessionId());
}
onLater(): void {
this.deferred.emit(this.sessionId());
}
}
- Step 2: Create template
<div class="whistle-toast" [class.urgent]="urgency() === 'ring'">
<div class="whistle-toast__header">
<span class="whistle-toast__icon">📢</span>
<span class="whistle-toast__title">{{ whistledBy() }} wants your input</span>
</div>
@if (contextSummary()) {
<p class="whistle-toast__context">{{ contextSummary() }}</p>
}
<div class="whistle-toast__actions">
<button class="whistle-action join" (click)="onJoin()">Join</button>
<button class="whistle-action later" (click)="onLater()">Later</button>
<button class="whistle-action decline" (click)="onDecline()">Decline</button>
</div>
</div>
- Step 3: Create styles
.whistle-toast {
position: fixed;
top: 20px;
right: 20px;
width: 320px;
background: var(--vitto-surface, #1a1a2e);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 12px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
z-index: 10000;
animation: slideIn 0.3s ease-out;
&.urgent {
border-color: rgba(239, 68, 68, 0.5);
animation: slideIn 0.3s ease-out, pulse 1s ease-in-out 3;
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__icon {
font-size: 1.2rem;
}
&__title {
font-weight: 600;
font-size: 0.9rem;
}
&__context {
font-size: 0.82rem;
color: var(--vitto-muted, #94a3b8);
margin-bottom: 12px;
line-height: 1.5;
}
&__actions {
display: flex;
gap: 8px;
}
}
.whistle-action {
flex: 1;
padding: 8px;
border: none;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&.join {
background: var(--vitto-accent, #8b5cf6);
color: #fff;
&:hover { background: #7c3aed; }
}
&.later {
background: rgba(255, 255, 255, 0.08);
color: var(--vitto-text, #e2e8f0);
&:hover { background: rgba(255, 255, 255, 0.12); }
}
&.decline {
background: rgba(255, 255, 255, 0.04);
color: var(--vitto-muted, #94a3b8);
&:hover { background: rgba(255, 255, 255, 0.08); }
}
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); }
50% { box-shadow: 0 12px 40px rgba(239, 68, 68, 0.3); }
}
- Step 4: Commit
git add libs/libs-ai/vitto-chat/src/lib/components/whistle-notification/
git commit -m "[S218.1] feat: whistle-notification toast component"
Task 12: Update message-bubble for group sender display
Files:
Modify:
libs/libs-ai/vitto-chat/src/lib/components/message-bubble/message-bubble.component.tsModify:
libs/libs-ai/vitto-chat/src/lib/components/message-bubble/message-bubble.component.htmlModify:
libs/libs-ai/vitto-chat/src/lib/components/message-bubble/message-bubble.component.scssStep 1: Add isGroup input and sender computed to component
readonly isGroup = input(false);
readonly senderInfo = computed(() => {
const msg = this.message() as any;
if (!this.isGroup() || !msg.senderId) return null;
return {
name: msg.senderName || 'Unknown',
initial: (msg.senderName || '?').charAt(0).toUpperCase(),
};
});
- Step 2: Add sender name display in template
Before the message-content div, add:
@if (senderInfo(); as sender) {
<div class="sender-label">{{ sender.name }}</div>
}
- Step 3: Add sender-label style
.sender-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--vitto-accent, #8b5cf6);
padding: 0 4px 2px;
}
- Step 4: Commit
git add libs/libs-ai/vitto-chat/src/lib/components/message-bubble/
git commit -m "[S218.1] feat: message-bubble shows sender name in group chats"
Task 13: Wire whistle Socket.IO events in VittoChatEngineService
Files:
Modify:
libs/libs-ai/vitto-chat/src/lib/services/vitto-chat-engine.service.tsStep 1: Add whistle event handling
In the auth setup effect (around line 194, after the existing onSessionUpdated subscription):
// S218: Handle incoming whistle notifications
this.realtimeService.onWhistleReceived?.subscribe(data => {
// The whistle-notification component will handle this
this._pendingWhistle.set(data);
});
Add signal:
private readonly _pendingWhistle = signal<any>(null);
readonly pendingWhistle = this._pendingWhistle.asReadonly();
- Step 2: Add whistle event to ChatRealtimeService
Add to chat-realtime.service.ts:
readonly onWhistleReceived = new Subject<{
sessionId: string;
whistledBy: string;
whistledByName: string;
contextSummary?: string;
urgency?: string;
}>();
And in the Socket.IO listener setup:
this.socket.on('chat:whistled', (data: any) => {
this.onWhistleReceived.next(data);
});
- Step 3: Commit
git add libs/libs-ai/vitto-chat/src/lib/services/vitto-chat-engine.service.ts libs/libs-ai/chat-sync/src/lib/services/chat-realtime.service.ts
git commit -m "[S218.1] feat: wire whistle Socket.IO events in frontend"
Chunk 5: Phase 5 — LLM Tools
Task 14: Create whistle tool plugin
Files:
Create:
APIs/backend-vitto-api/src/app/tools/plugins/whistle.plugin.tsStep 1: Create the whistle_user tool
import { ToolDefinition } from '../tool-registry.types';
export const whistleTools: ToolDefinition[] = [
{
name: 'whistle_user',
description: 'Whistle (invite) a space member into the current conversation. Use when the user asks to bring someone in, or when you detect that another person\'s expertise would help.',
parameters: {
type: 'object',
properties: {
targetName: {
type: 'string',
description: 'Name or userId of the person to whistle',
},
historyAccess: {
type: 'string',
enum: ['full', 'summary', 'none'],
description: 'How much conversation history the new participant sees. Default: summary',
},
urgency: {
type: 'string',
enum: ['smart', 'ring', 'notify', 'silent'],
description: 'How urgently to notify them. Default: smart (Vitto decides)',
},
},
required: ['targetName'],
},
category: 'collaboration',
handler: 'whistle_user_handler',
},
{
name: 'suggest_whistle',
description: 'Proactively suggest a space member to whistle based on the conversation topic and their expertise. Call this when you detect a knowledge gap that a specific person could fill.',
parameters: {
type: 'object',
properties: {},
},
category: 'collaboration',
handler: 'suggest_whistle_handler',
},
];
- Step 2: Register in ToolRegistryService
Add whistleTools to the tool registry initialization.
- Step 3: Commit
git add APIs/backend-vitto-api/src/app/tools/plugins/whistle.plugin.ts
git commit -m "[S218.1] feat: whistle_user and suggest_whistle LLM tools"
Chunk 6: Phase 6 — Close Flow + Dossier Extraction
Task 15: Add group close endpoint and inactivity cron
Files:
Modify:
APIs/backend-ai-api/src/app/sessions/sessions.controller.tsModify:
APIs/backend-ai-api/src/app/sessions/sessions.service.tsStep 1: Add close endpoint
/**
* S218: Close a group session and trigger Dossier extraction.
*/
@Public()
@Post(':id/close')
async closeGroupSession(
@Param('id') sessionId: string,
@CurrentUser() authUser: AuthenticatedUser | null,
): Promise<{ success: boolean }> {
try {
const session = await this.sessionsService.getSession(sessionId);
if (!session?.isGroup) return { success: false };
// Mark as closed
await this.sessionsService.closeSession(sessionId);
// Trigger genome compilation
if (session.messages && session.messages.length >= 3) {
this.genomeService.compile(sessionId, session.messages as any)
.then(genome => this.sessionsService.updateSessionGenome(sessionId, genome))
.catch(err => this.logger.debug(`Genome compile skipped: ${err.message}`));
}
return { success: true };
} catch (error: any) {
this.logger.error(`Failed to close session ${sessionId}: ${error.message}`);
return { success: false };
}
}
- Step 2: Add closeSession to service
async closeSession(sessionId: string): Promise<void> {
await this.sessionModel.updateOne(
{ sessionId },
{ $set: { status: 'archived', updatedAt: Date.now() } },
);
}
- Step 3: Commit
git add APIs/backend-ai-api/src/app/sessions/sessions.controller.ts APIs/backend-ai-api/src/app/sessions/sessions.service.ts
git commit -m "[S218.1] feat: group session close endpoint with genome compilation"
Summary of All Files
Created (8 files)
APIs/backend-vitto-api/src/app/engine/whistle-orchestrator.service.tsAPIs/backend-vitto-api/src/app/tools/plugins/whistle.plugin.tslibs/libs-ai/vitto-chat/src/lib/services/whistle.service.tslibs/libs-ai/vitto-chat/src/lib/components/whistle-button/(3 files)libs/libs-ai/vitto-chat/src/lib/components/participant-strip/(3 files)libs/libs-ai/vitto-chat/src/lib/components/whistle-notification/(3 files)
Modified (12 files)
APIs/backend-ai-api/src/app/sessions/sessions.schema.tsAPIs/backend-ai-api/src/app/sessions/sessions.types.tsAPIs/backend-ai-api/src/app/sessions/sessions.service.tsAPIs/backend-ai-api/src/app/sessions/sessions.controller.tsAPIs/backend-ai-api/src/app/sessions/chat.gateway.tsAPIs/backend-api/src/app/spaces/spaces.controller.tsAPIs/backend-api/src/app/spaces/spaces.service.tsAPIs/backend-vitto-api/src/app/mind/schemas/user-notification-prefs.schema.tsAPIs/backend-vitto-api/src/app/engine/vitto.controller.tslibs/libs-api/messaging-core/src/lib/schemas/group-conversation.schema.tslibs/libs-ai/vitto-chat/src/lib/services/vitto-chat-engine.service.tslibs/libs-ai/chat-sync/src/lib/services/chat-realtime.service.tslibs/libs-ai/vitto-chat/src/lib/components/message-bubble/(3 files)