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:

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;
}

Add after the channel field (after line 86):

  // S218: Sender identity for group chat messages
  @Prop()
  senderId?: string;

  @Prop()
  senderName?: string;

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;

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 });

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;
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:

/**
 * 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 };
}
/**
 * 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));
}
/**
 * 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 } },
  );
}

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 };
  }
}

Add Patch to the NestJS imports if not present.

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:

/**
 * 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(),
  }));
}
/**
 * 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: [] };
  }
}
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:

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';
  };
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:

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;
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:

/**
 * 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' };
  }
}

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);
}

Add to the appropriate module's providers array.

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:

/**
 * 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,
  });
}

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.

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:

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),
    });
  }
}
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:

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' });
  }
}
<div class="whistle-container">
  <button class="whistle-btn" (click)="toggle()" [disabled]="isWhistling()" title="Whistle a coworker">
    <span class="whistle-icon">&#x1F4E2;</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>
.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);
}
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:

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];
  }
}
@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>
}
.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);
}
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:

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());
  }
}
<div class="whistle-toast" [class.urgent]="urgency() === 'ring'">
  <div class="whistle-toast__header">
    <span class="whistle-toast__icon">&#x1F4E2;</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>
.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); }
}
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:

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(),
  };
});

Before the message-content div, add:

@if (senderInfo(); as sender) {
  <div class="sender-label">{{ sender.name }}</div>
}
.sender-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--vitto-accent, #8b5cf6);
  padding: 0 4px 2px;
}
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:

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();

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);
});
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:

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',
  },
];

Add whistleTools to the tool registry initialization.

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:

/**
 * 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 };
  }
}
async closeSession(sessionId: string): Promise<void> {
  await this.sessionModel.updateOne(
    { sessionId },
    { $set: { status: 'archived', updatedAt: Date.now() } },
  );
}
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)

Modified (12 files)