2026 03 23 Pvi V2 Intelligence Learning

Documentation for Plans

PVI V2: Intelligence + Learning — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make Vitto proactively analyze generated platforms, suggest improvements via chat, learn user preferences, and persist snapshot history to Hetzner git.

Architecture: V2 builds on V1's core loop (click → toolbar → apply). Adds 4 new capabilities: (1) post-gen proactive scan with parallel Haiku LLM analysis, (2) ambient dot overlay surviving recompiles, (3) learning layer recording user preferences to personalize suggestions, (4) Hetzner-backed snapshot timeline. Frontend services in vitto-studio, LLM routing via vitto-api, preference storage in MongoDB.

Tech Stack: Angular 20+ (signals, standalone, OnPush), RxJS (forkJoin for parallel LLM), NestJS (vitto-api), MongoDB (vitto-mind), Hetzner git (UniverseGitService)

Spec: docs/superpowers/specs/2026-03-23-proactive-visual-intelligence-design.md (sections 7, 9, 10, 12)

Depends on: PVI V1 (S263) — all V1 deliverables complete

IMPORTANT: Do NOT use git actions (commit, push, branch) — another session may be running concurrently.


File Structure

File Action Responsibility
libs/libs-structural/vitto-studio/src/lib/services/pvi-proactive-analysis.service.ts Create Post-gen scan orchestration (rule scan + parallel LLM + merge)
libs/libs-structural/vitto-studio/src/lib/services/pvi-enrichment.service.ts Create On-click LLM enrichment with debounce, cache, cancel
libs/libs-structural/vitto-studio/src/lib/services/pvi.service.ts Modify Add ambient dot state, Apply All, wire enrichment
libs/libs-structural/vitto-studio/src/lib/services/pvi-apply.service.ts Modify Add applyAll() with conflict detection
libs/libs-structural/vitto-studio/src/lib/services/pvi-snapshot.service.ts Modify Add Hetzner push integration
libs/libs-structural/vitto-studio/src/lib/services/studio-vitto-bridge.service.ts Modify Add pviEnrich() and pviScan() methods for LLM calls
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.ts Create Ranked top-3 suggestion card in Vitto chat
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.html Create Template
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.scss Create Styles
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.ts Create Horizontal snapshot history strip
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.html Create Template
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.scss Create Styles
libs/libs-structural/vitto-studio/src/lib/components/studio-dock-shell/studio-dock-shell.component.ts Modify Wire proactive analysis, chat summary, timeline
libs/libs-structural/vitto-studio/src/lib/components/studio-dock-shell/studio-dock-shell.component.html Modify Add chat summary + timeline components
libs/libs-structural/platform-preview/src/lib/services/preview-bridge.service.ts Modify Add sendPviMessage() for outbound iframe messages
APIs/backend-vitto-api/src/app/engine/pvi/pvi.module.ts Create NestJS module for PVI backend
APIs/backend-vitto-api/src/app/engine/pvi/pvi-model-router.service.ts Create Smart model selection (Haiku/Sonnet/Opus) for PVI tasks
APIs/backend-vitto-api/src/app/engine/pvi/pvi.controller.ts Create REST endpoints: POST /pvi/scan, POST /pvi/enrich
APIs/backend-vitto-api/src/app/engine/pvi/user-suggestion-tracker.service.ts Create MongoDB event recording for learning
APIs/backend-vitto-api/src/app/engine/pvi/suggestion-weight.service.ts Create Weight aggregation + personalization
libs/libs-ai/proactive-visual-intelligence/src/lib/models/pvi.types.ts Modify Add UserSuggestionEvent, PviScanRequest, PviEnrichRequest types

Task 1: Add outbound PVI messaging to PreviewBridgeService

Zero dependencies. Needed by ambient dots (Task 2) and proactive analysis (Task 3).

Files:

In preview-bridge.service.ts, add a public method to send messages TO the iframe:

/**
 * [S263-V2] Send a PVI message to the preview iframe.
 */
sendPviMessage(type: string, payload: unknown): void {
  const iframe = this.currentIframe;
  if (iframe?.contentWindow) {
    iframe.contentWindow.postMessage({ type, payload }, '*');
  }
}

Check if the service already stores a reference to the iframe (from initialize(iframe)). If it stores this.currentIframe, use it. If not, add:

private currentIframe: HTMLIFrameElement | null = null;

// In initialize():
this.currentIframe = iframe;

The preview iframe reloads on every recompile. Dots are lost. Add a method that re-injects dots from stored state:

/**
 * [S263-V2] Re-inject ambient dots after iframe reload.
 * Called by PviService when iframe load event fires.
 */
reinjectDots(dots: Array<{ studioId: string; dotType: 'high-impact' | 'enhancement'; count: number }>): void {
  if (dots.length === 0) return;
  // Delay slightly to ensure DOM is ready after iframe load
  setTimeout(() => {
    this.sendPviMessage('PVI_INJECT_DOTS', dots);
  }, 200);
}

Task 2: Ambient dots state management (V2-2)

Files:

/** Ambient dot map — persists across recompiles */
readonly ambientDots = signal<Array<{ studioId: string; dotType: 'high-impact' | 'enhancement'; count: number }>>([]);
/**
 * [S263-V2] Update ambient dots from analysis results.
 */
updateAmbientDots(analysisResult: PviAnalysisResult): void {
  const dots = analysisResult.components.map(c => ({
    studioId: c.studioId,
    dotType: c.dotType,
    count: c.suggestions.length,
  }));
  this.ambientDots.set(dots);
}

/**
 * [S263-V2] Re-inject dots after iframe reload.
 */
reinjectDotsAfterRecompile(): void {
  const dots = this.ambientDots();
  if (dots.length > 0) {
    this.previewBridge.reinjectDots(dots);
  }
}

Inject PreviewBridgeService into PviService.

In StudioDockShellComponent (or wherever the preview iframe's load event is observed), subscribe to iframe load and call pvi.reinjectDotsAfterRecompile(). Search for how the existing preview handles iframe load — likely via PreviewBridgeService or PreviewServerService.


Task 3: Post-generation proactive analysis (V2-1)

Depends on Task 1 (sendPviMessage) and Task 2 (ambient dot state).

Files:

/**
 * [S263-V2] Request PVI component scan from vitto-api.
 * Returns LLM-generated suggestions for a single component.
 */
pviScan(components: Array<{ componentName: string; template: string; styles: string; manifest: unknown }>): Observable<{
  components: Array<{
    componentName: string;
    suggestions: Array<{ label: string; description: string; category: string; impact: string; applyType: string; codeHint?: string }>;
  }>;
}> {
  return this.http.post<any>(`${this.vittoApiUrl}/pvi/scan`, { components }).pipe(
    timeout(10000),
    catchError(() => of({ components: [] })),
  );
}
import { Injectable, inject } from '@angular/core';
import { forkJoin, of, type Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import type { PviAnalysisResult, Suggestion, SuggestionCategory } from '@libs-ai/proactive-visual-intelligence';
import { runSuggestionRules, mergeSuggestions } from '@libs-ai/proactive-visual-intelligence';
import { PviService } from './pvi.service';
import { StudioVittoBridgeService } from './studio-vitto-bridge.service';

import sdkManifest from '@libs-ai/proactive-visual-intelligence/lib/data/sdk-manifest.json';

@Injectable({ providedIn: 'root' })
export class PviProactiveAnalysisService {
  private readonly pvi = inject(PviService);
  private readonly vittoBridge = inject(StudioVittoBridgeService);

  /**
   * [S263-V2] Run post-generation proactive analysis.
   * Phase A: Instant rule scan on all components.
   * Phase B: Parallel Haiku LLM deep scan (2-4s).
   * Merges results, updates ambient dots, returns PviAnalysisResult.
   */
  analyzeGeneration(vfsFiles: Record<string, string>): Observable<PviAnalysisResult> {
    // Phase A: Rule scan — instant, <100ms
    const componentFiles = this.extractComponents(vfsFiles);
    const ruleResults = this.runRuleScanAll(componentFiles);

    // Inject preliminary dots immediately
    this.pvi.updateAmbientDots(ruleResults);

    // Phase B: LLM deep scan — parallel Haiku calls
    const scanPayloads = componentFiles.map(c => ({
      componentName: c.name,
      template: c.template,
      styles: c.styles,
      manifest: (sdkManifest as any).components || {},
    }));

    if (scanPayloads.length === 0) {
      return of(ruleResults);
    }

    return this.vittoBridge.pviScan(scanPayloads).pipe(
      map(llmResults => {
        // Merge LLM results with rule results per component
        const merged = this.mergeResults(ruleResults, llmResults);
        // Update dots with merged results (some may upgrade to high-impact)
        this.pvi.updateAmbientDots(merged);
        return merged;
      }),
      catchError(() => of(ruleResults)), // Graceful degradation
    );
  }

  private extractComponents(vfsFiles: Record<string, string>): Array<{
    name: string;
    studioId: string;
    template: string;
    styles: string;
    classSource: string;
  }> {
    const components: Array<{ name: string; studioId: string; template: string; styles: string; classSource: string }> = [];

    for (const [path, content] of Object.entries(vfsFiles)) {
      if (!path.endsWith('.component.html')) continue;
      const name = path.split('/').pop()?.replace('.component.html', '') || '';
      const basePath = path.replace('.component.html', '');
      const styles = vfsFiles[basePath + '.component.scss'] || '';
      const classSource = vfsFiles[basePath + '.component.ts'] || '';
      const studioId = `component:${this.toPascalCase(name)}:file:${path}:line:1`;

      components.push({ name, studioId, template: content, styles, classSource });
    }

    return components;
  }

  private runRuleScanAll(components: Array<{ name: string; studioId: string; template: string; styles: string }>): PviAnalysisResult {
    const resultComponents: PviAnalysisResult['components'] = [];

    for (const comp of components) {
      // Build a lightweight ElementSelectionPayload from static analysis
      const payload = this.buildPayloadFromTemplate(comp);
      const suggestions = runSuggestionRules(payload, sdkManifest as any);

      if (suggestions.length > 0) {
        const hasHigh = suggestions.some(s => s.impact === 'high');
        resultComponents.push({
          componentName: comp.name,
          studioId: comp.studioId,
          suggestions,
          dotType: hasHigh ? 'high-impact' : 'enhancement',
        });
      }
    }

    // Build summary
    const allSuggestions = resultComponents.flatMap(c => c.suggestions);
    const byCategory: Record<string, number> = {};
    for (const s of allSuggestions) {
      byCategory[s.category] = (byCategory[s.category] || 0) + 1;
    }

    return {
      components: resultComponents,
      summary: {
        total: allSuggestions.length,
        byCategory: byCategory as Record<SuggestionCategory, number>,
        topThree: allSuggestions.slice(0, 3),
      },
    };
  }

  private buildPayloadFromTemplate(comp: { name: string; studioId: string; template: string; styles: string }): import('@libs-ai/proactive-visual-intelligence').ElementSelectionPayload {
    const html = comp.template.toLowerCase();
    const childGgs = (html.match(/ggs-[a-z-]+/g) || []).filter((v, i, a) => a.indexOf(v) === i);
    const missingGgs: string[] = [];
    if (/<button[^>]*>/i.test(comp.template)) missingGgs.push('<button>');
    if (/<table[^>]*>/i.test(comp.template)) missingGgs.push('<table>');

    return {
      studioId: comp.studioId,
      componentName: comp.name,
      tagName: 'div',
      childGgsComponents: childGgs,
      missingGgsComponents: missingGgs,
      hasNgFor: /\*ngfor|@for/i.test(html),
      hasFormControls: /<input|<select|<textarea|ggs-input|ggs-select/i.test(html),
      computedStyles: {},
      boundingRect: { x: 0, y: 0, width: 0, height: 0 },
    };
  }

  private mergeResults(ruleResults: PviAnalysisResult, llmResults: { components: Array<{ componentName: string; suggestions: Array<any> }> }): PviAnalysisResult {
    const merged = { ...ruleResults };

    for (const llmComp of llmResults.components) {
      const ruleComp = merged.components.find(c => c.componentName === llmComp.componentName);
      if (!ruleComp) continue;

      const llmSuggestions: Suggestion[] = llmComp.suggestions.map((s: any, i: number) => ({
        id: `pvi-llm-${llmComp.componentName}-${i}`,
        category: (s.category || 'ux') as SuggestionCategory,
        label: s.label || 'LLM suggestion',
        description: s.description || '',
        impact: (s.impact || 'medium') as 'high' | 'medium' | 'low',
        applyType: (s.applyType || 'structural') as 'style' | 'structural',
        source: 'llm' as const,
        conflicts: [],
        structuralChange: s.codeHint ? { prompt: s.codeHint, affectedFiles: [] } : undefined,
      }));

      ruleComp.suggestions = mergeSuggestions(ruleComp.suggestions, llmSuggestions);
      ruleComp.dotType = ruleComp.suggestions.some(s => s.impact === 'high') ? 'high-impact' : 'enhancement';
    }

    // Rebuild summary
    const allSuggestions = merged.components.flatMap(c => c.suggestions);
    const byCategory: Record<string, number> = {};
    for (const s of allSuggestions) {
      byCategory[s.category] = (byCategory[s.category] || 0) + 1;
    }
    merged.summary = {
      total: allSuggestions.length,
      byCategory: byCategory as Record<SuggestionCategory, number>,
      topThree: allSuggestions.slice(0, 3),
    };

    return merged;
  }

  private toPascalCase(str: string): string {
    return str.replace(/(^|-)([a-z])/g, (_, _p, c) => c.toUpperCase());
  }
}

In StudioDockShellComponent, subscribe to generationListener.filesReadyForReview$. After files are loaded:

this.generationListener.filesReadyForReview$.pipe(
  takeUntilDestroyed(this.destroyRef),
).subscribe(() => {
  // Trigger proactive PVI analysis after generation
  const vfsFiles = this.vfs.serialize(); // Get all VFS file contents
  this.proactiveAnalysis.analyzeGeneration(vfsFiles).subscribe(result => {
    this.lastAnalysisResult.set(result);
  });
});

Inject PviProactiveAnalysisService and VfsService into the component. Check VfsService for a serialize() or equivalent method that returns Record<string, string>.


Task 4: Chat summary component (V2-3)

Depends on Task 3 (analysis result).

Files:

import { Component, ChangeDetectionStrategy, inject, input, output } from '@angular/core';
import type { PviAnalysisResult, Suggestion } from '@libs-ai/proactive-visual-intelligence';

@Component({
  selector: 'lib-pvi-chat-summary',
  standalone: true,
  templateUrl: './pvi-chat-summary.component.html',
  styleUrl: './pvi-chat-summary.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PviChatSummaryComponent {
  readonly analysisResult = input.required<PviAnalysisResult>();
  readonly applySuggestion = output<Suggestion>();
  readonly applyAllTop3 = output<void>();

  getCategoryIcon(category: string): string {
    const icons: Record<string, string> = {
      style: 'palette', layout: 'dashboard', component: 'extension',
      ux: 'auto_awesome', data: 'analytics', accessibility: 'accessibility_new',
    };
    return icons[category] || 'lightbulb';
  }
}
<div class="pvi-chat-summary">
  <div class="pvi-summary-header">
    <span class="pvi-summary-icon">auto_awesome</span>
    <span>I found {{ analysisResult().summary.total }} enhancements for your platform:</span>
  </div>

  <div class="pvi-summary-list">
    @for (suggestion of analysisResult().summary.topThree; track suggestion.id) {
      <div class="pvi-summary-item">
        <span class="pvi-category-icon material-symbols-outlined">{{ getCategoryIcon(suggestion.category) }}</span>
        <div class="pvi-summary-content">
          <span class="pvi-summary-label">{{ suggestion.label }}</span>
          <span class="pvi-summary-impact impact-{{ suggestion.impact }}">{{ suggestion.impact }}</span>
        </div>
        <button class="pvi-summary-apply" (click)="applySuggestion.emit(suggestion)">Apply</button>
      </div>
    }
  </div>

  @if (analysisResult().summary.topThree.length > 0) {
    <button class="pvi-apply-all-btn" (click)="applyAllTop3.emit()">
      Apply All Top {{ analysisResult().summary.topThree.length }}
    </button>
  }

  @if (analysisResult().summary.total > 3) {
    <div class="pvi-more-hint">
      + {{ analysisResult().summary.total - 3 }} more — click the dots in the preview to explore.
    </div>
  }
</div>

Dark theme, card-like appearance, matching vitto chat message aesthetics. Use var(--ux-*) tokens. Entry animation. Impact badges color-coded.

Add PviChatSummaryComponent to imports. Show it when lastAnalysisResult() is populated. Wire (applySuggestion) to onPviApply() and (applyAllTop3) to a new onPviApplyAll() method.


Task 5: Apply All with conflict detection (V2-5)

Independent. Extends existing PviApplyService.

Files:

/**
 * [S263-V2] Apply multiple suggestions in order with conflict detection.
 * Style changes batched into single CSS injection. Structural changes sequential.
 */
applyAll(suggestions: Suggestion[]): { applied: string[]; skipped: Array<{ label: string; reason: string }> } {
  const sorted = [...suggestions].sort((a, b) => {
    const impact = { high: 3, medium: 2, low: 1 };
    return impact[b.impact] - impact[a.impact];
  });

  const applied: string[] = [];
  const skipped: Array<{ label: string; reason: string }> = [];
  const appliedIds = new Set<string>();

  // Create single batch snapshot
  const editContext = this.visualEditor.editingContext();
  if (editContext) {
    const files: Record<string, string> = {};
    if (editContext.paths.stylesPath) files[editContext.paths.stylesPath] = editContext.stylesSource || '';
    if (editContext.paths.templatePath) files[editContext.paths.templatePath] = editContext.templateSource || '';
    if (editContext.paths.classPath) files[editContext.paths.classPath] = editContext.classSource || '';
    this.snapshots.createSnapshot('Apply All', Object.keys(files), files);
  }

  for (const suggestion of sorted) {
    // Check conflicts
    const hasConflict = suggestion.conflicts.some(cid => appliedIds.has(cid));
    if (hasConflict) {
      skipped.push({ label: suggestion.label, reason: 'Conflicts with an already-applied suggestion' });
      continue;
    }

    const result = suggestion.applyType === 'style'
      ? this.applyStyle(suggestion)
      : this.applyStructural(suggestion);

    if (result.success) {
      applied.push(suggestion.label);
      appliedIds.add(suggestion.id);
    } else {
      skipped.push({ label: suggestion.label, reason: result.error || 'Failed to apply' });
    }
  }

  return { applied, skipped };
}

Task 6: On-click LLM enrichment (V2-4)

Independent. Extends PVI with LLM-powered suggestions on element click.

Files:

/**
 * [S263-V2] Request LLM enrichment for a specific component + category.
 */
pviEnrich(request: {
  componentName: string;
  category: string;
  template: string;
  styles: string;
  manifest: unknown;
}): Observable<{
  suggestions: Array<{ label: string; description: string; impact: string; applyType: string; codeHint?: string }>;
}> {
  return this.http.post<any>(`${this.vittoApiUrl}/pvi/enrich`, request).pipe(
    timeout(5000),
    catchError(() => of({ suggestions: [] })),
  );
}
import { Injectable, inject } from '@angular/core';
import { Subject, switchMap, debounceTime, tap, catchError, of, EMPTY } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { Suggestion, SuggestionCategory, ElementSelectionPayload } from '@libs-ai/proactive-visual-intelligence';
import { mergeSuggestions } from '@libs-ai/proactive-visual-intelligence';
import { PviService } from './pvi.service';
import { VisualEditorService } from './visual-editor.service';
import { StudioVittoBridgeService } from './studio-vitto-bridge.service';
import sdkManifest from '@libs-ai/proactive-visual-intelligence/lib/data/sdk-manifest.json';

@Injectable({ providedIn: 'root' })
export class PviEnrichmentService {
  private readonly pvi = inject(PviService);
  private readonly visualEditor = inject(VisualEditorService);
  private readonly vittoBridge = inject(StudioVittoBridgeService);

  /** Cache: componentName:category → Suggestion[] */
  private readonly cache = new Map<string, Suggestion[]>();

  /** Enrichment request trigger */
  private readonly enrichTrigger$ = new Subject<{ element: ElementSelectionPayload; category: SuggestionCategory }>();

  constructor() {
    // 500ms debounce, cancel previous on new click
    this.enrichTrigger$.pipe(
      debounceTime(500),
      switchMap(({ element, category }) => {
        const cacheKey = `${element.componentName}:${category}`;
        if (this.cache.has(cacheKey)) {
          return of({ cached: true, suggestions: this.cache.get(cacheKey)! });
        }

        const editContext = this.visualEditor.editingContext();
        if (!editContext) return EMPTY;

        return this.vittoBridge.pviEnrich({
          componentName: element.componentName,
          category,
          template: editContext.templateSource || '',
          styles: editContext.stylesSource || '',
          manifest: (sdkManifest as any).components || {},
        }).pipe(
          map(result => ({
            cached: false,
            suggestions: result.suggestions.map((s, i) => ({
              id: `pvi-enrich-${element.componentName}-${i}`,
              category,
              label: s.label,
              description: s.description,
              impact: (s.impact || 'medium') as 'high' | 'medium' | 'low',
              applyType: (s.applyType || 'structural') as 'style' | 'structural',
              source: 'llm' as const,
              conflicts: [],
              structuralChange: s.codeHint ? { prompt: s.codeHint, affectedFiles: [] } : undefined,
            }) as Suggestion),
          })),
          catchError(() => of({ cached: false, suggestions: [] as Suggestion[] })),
        );
      }),
      takeUntilDestroyed(),
    ).subscribe(({ suggestions }) => {
      if (suggestions.length > 0) {
        // Merge with existing rule suggestions
        const current = this.pvi.suggestions();
        const merged = mergeSuggestions(current, suggestions);
        this.pvi.suggestions.set(merged);
      }
    });
  }

  /**
   * Request enrichment for the current element + category.
   * Debounced, cached, cancels previous in-flight request.
   */
  requestEnrichment(element: ElementSelectionPayload, category: SuggestionCategory): void {
    this.enrichTrigger$.next({ element, category });
  }

  /** Invalidate cache for a component (after VFS change) */
  invalidateCache(componentName: string): void {
    for (const key of this.cache.keys()) {
      if (key.startsWith(componentName + ':')) {
        this.cache.delete(key);
      }
    }
  }
}

Add missing map import from rxjs/operators.

In pvi.service.ts, inject PviEnrichmentService and trigger enrichment when user selects a category:

selectCategory(category: SuggestionCategory): void {
  const newCat = this.activeCategory() === category ? null : category;
  this.activeCategory.set(newCat);

  // [S263-V2] Trigger LLM enrichment if <3 suggestions for this category
  if (newCat) {
    const current = this.suggestions().filter(s => s.category === newCat);
    if (current.length < 3) {
      const element = this.elementContext();
      if (element) {
        this.enrichment.requestEnrichment(element, newCat);
      }
    }
  }
}

Task 7: PVI backend — model router + endpoints (V2-1, V2-4 backend)

Independent. Backend services for LLM routing.

Files:

import { Injectable } from '@nestjs/common';

interface PviModelRoute {
  model: 'haiku' | 'sonnet' | 'opus';
  maxTokens: number;
  temperature: number;
}

const PVI_ROUTES: Record<string, PviModelRoute> = {
  'pvi:component-scan': { model: 'haiku', maxTokens: 2048, temperature: 0.2 },
  'pvi:enrich-simple': { model: 'haiku', maxTokens: 2048, temperature: 0.3 },
  'pvi:enrich-complex': { model: 'sonnet', maxTokens: 4096, temperature: 0.3 },
  'pvi:structural-apply': { model: 'sonnet', maxTokens: 8192, temperature: 0.1 },
};

@Injectable()
export class PviModelRouterService {
  getRoute(task: string, componentComplexity?: 'simple' | 'complex'): PviModelRoute {
    if (task === 'pvi:enrich' && componentComplexity) {
      return PVI_ROUTES[`pvi:enrich-${componentComplexity}`] || PVI_ROUTES['pvi:enrich-simple'];
    }
    return PVI_ROUTES[task] || PVI_ROUTES['pvi:component-scan'];
  }

  classifyComplexity(componentName: string): 'simple' | 'complex' {
    const complexPatterns = ['table', 'form', 'dashboard', 'shell', 'list', 'grid'];
    return complexPatterns.some(p => componentName.toLowerCase().includes(p)) ? 'complex' : 'simple';
  }
}
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { PviModelRouterService } from './pvi-model-router.service';

@Controller('pvi')
export class PviController {
  constructor(private readonly modelRouter: PviModelRouterService) {}

  @Post('scan')
  @HttpCode(HttpStatus.OK)
  async scan(@Body() dto: {
    components: Array<{ componentName: string; template: string; styles: string; manifest: unknown }>;
  }): Promise<{ components: Array<{ componentName: string; suggestions: Array<unknown> }> }> {
    // TODO: Wire to BaseLLMProvider with PviModelRouterService for actual LLM calls
    // For now, return empty suggestions (rule engine handles V1 suggestions client-side)
    return {
      components: dto.components.map(c => ({
        componentName: c.componentName,
        suggestions: [],
      })),
    };
  }

  @Post('enrich')
  @HttpCode(HttpStatus.OK)
  async enrich(@Body() dto: {
    componentName: string;
    category: string;
    template: string;
    styles: string;
    manifest: unknown;
  }): Promise<{ suggestions: Array<unknown> }> {
    // TODO: Wire to BaseLLMProvider
    return { suggestions: [] };
  }
}
import { Module } from '@nestjs/common';
import { PviController } from './pvi.controller';
import { PviModelRouterService } from './pvi-model-router.service';

@Module({
  controllers: [PviController],
  providers: [PviModelRouterService],
  exports: [PviModelRouterService],
})
export class PviModule {}

Register PviModule in the parent EngineModule imports. Find engine.module.ts and add PviModule to imports.


Task 8: Learning layer — event tracking + weight aggregation (V2-6)

Independent. Backend services for preference learning.

Files:

export interface UserSuggestionEvent {
  userId: string;
  spaceId: string;
  universeId: string;
  componentType: string;
  category: SuggestionCategory;
  suggestionLabel: string;
  action: 'viewed' | 'applied' | 'dismissed' | 'undone';
  source: 'rule' | 'llm' | 'merged';
  timestamp: Date;
}
import { Injectable, Logger } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';

@Injectable()
export class UserSuggestionTrackerService {
  private readonly logger = new Logger('PviTracker');

  constructor(
    @InjectConnection('vitto-mind')
    private readonly connection: Connection,
  ) {}

  async record(event: {
    userId: string;
    spaceId: string;
    universeId: string;
    componentType: string;
    category: string;
    suggestionLabel: string;
    action: 'viewed' | 'applied' | 'dismissed' | 'undone';
    source: 'rule' | 'llm' | 'merged';
  }): Promise<void> {
    try {
      await this.connection.collection('pvi_user_events').insertOne({
        ...event,
        timestamp: new Date(),
      });
    } catch (err) {
      this.logger.warn(`Failed to record PVI event: ${(err as Error).message}`);
    }
  }
}
import { Injectable, Logger } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
import { Cron } from '@nestjs/schedule';

const DEFAULT_WEIGHTS = { style: 0.30, layout: 0.25, component: 0.20, ux: 0.15, data: 0.05, accessibility: 0.05 };

@Injectable()
export class SuggestionWeightService {
  private readonly logger = new Logger('PviWeights');

  constructor(
    @InjectConnection('vitto-mind')
    private readonly connection: Connection,
  ) {}

  async getWeights(userId: string): Promise<Record<string, number>> {
    const doc = await this.connection.collection('pvi_user_preferences').findOne({ userId });
    return doc?.weights || DEFAULT_WEIGHTS;
  }

  /** Daily aggregation — compute weights from event history */
  @Cron('0 7 * * *')
  async aggregateWeights(): Promise<void> {
    try {
      const pipeline = [
        { $match: { action: 'applied' } },
        { $group: { _id: { userId: '$userId', category: '$category' }, count: { $sum: 1 } } },
        { $group: { _id: '$_id.userId', categories: { $push: { category: '$_id.category', count: '$count' } } } },
      ];

      const results = await this.connection.collection('pvi_user_events').aggregate(pipeline).toArray();

      for (const user of results) {
        const total = user.categories.reduce((sum: number, c: any) => sum + c.count, 0);
        if (total < 10) continue; // Not enough data

        const weights = { ...DEFAULT_WEIGHTS };
        for (const c of user.categories) {
          weights[c.category as string] = c.count / total;
        }

        await this.connection.collection('pvi_user_preferences').updateOne(
          { userId: user._id },
          { $set: { weights, updatedAt: new Date() } },
          { upsert: true },
        );
      }

      this.logger.log(`PVI weight aggregation complete for ${results.length} users`);
    } catch (err) {
      this.logger.warn(`PVI weight aggregation failed: ${(err as Error).message}`);
    }
  }
}
@Post('track')
@HttpCode(HttpStatus.OK)
async track(@Body() dto: {
  userId: string;
  spaceId: string;
  universeId: string;
  componentType: string;
  category: string;
  suggestionLabel: string;
  action: 'viewed' | 'applied' | 'dismissed' | 'undone';
  source: 'rule' | 'llm' | 'merged';
}): Promise<{ tracked: boolean }> {
  await this.tracker.record(dto);
  return { tracked: true };
}

@Get('weights/:userId')
async getWeights(@Param('userId') userId: string): Promise<{ weights: Record<string, number> }> {
  const weights = await this.weightService.getWeights(userId);
  return { weights };
}

Add UserSuggestionTrackerService and SuggestionWeightService to PviModule providers and inject into controller.


Task 9: Hetzner snapshot persistence + Timeline UI (V2-7)

Independent. Extends PviSnapshotService with async Hetzner pushes.

Files:

Inject StudioVittoBridgeService (which can call the git service). Add async push after each snapshot:

/**
 * [S263-V2] Async push snapshot to Hetzner git. Non-blocking, fire-and-forget.
 * 3x retry with exponential backoff. Failure is non-blocking.
 */
pushToHetzner(snapshot: VfsSnapshot, repoPath: string): void {
  const files = Object.entries(snapshot.fileContents).map(([path, content]) => ({
    path,
    content,
  }));

  this.vittoBridge.commitToHetzner(repoPath, files, `[PVI] ${snapshot.label}`).pipe(
    retry({ count: 3, delay: (err, retryCount) => timer(1000 * Math.pow(2, retryCount)) }),
  ).subscribe({
    next: (result) => {
      this.updateSnapshotStatus(snapshot.id, 'pushed', result.sha);
    },
    error: () => {
      this.updateSnapshotStatus(snapshot.id, 'failed');
    },
  });
}

private updateSnapshotStatus(snapshotId: string, status: 'pushed' | 'failed', sha?: string): void {
  this.snapshots.update(list =>
    list.map(s => s.id === snapshotId ? { ...s, hetznerPushStatus: status, hetznerCommitSha: sha } : s),
  );
}

Horizontal strip below the preview. Each dot = a snapshot. Current position highlighted. Labels on hover. Click to jump.

@Component({
  selector: 'lib-pvi-timeline',
  standalone: true,
  templateUrl: './pvi-timeline.component.html',
  styleUrl: './pvi-timeline.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PviTimelineComponent {
  readonly snapshots = inject(PviSnapshotService);
  readonly restoreToSnapshot = output<number>();

  onDotClick(index: number): void {
    this.restoreToSnapshot.emit(index);
  }

  formatTime(timestamp: number): string {
    return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  }
}

Template: horizontal flex strip with dots, tooltip on hover, active state highlight.

Add <lib-pvi-timeline (restoreToSnapshot)="onTimelineRestore($event)" /> below the preview area. Handle the restore event by popping/restoring to the target snapshot index.


Summary

Task V2 Deliverable Risk Dependencies
1 Outbound PVI messaging Low None
2 Ambient dots (V2-2) Low Task 1
3 Proactive analysis (V2-1) Medium — touches gen pipeline Tasks 1, 2
4 Chat summary (V2-3) Low Task 3
5 Apply All (V2-5) Low None
6 LLM enrichment (V2-4) Medium — debounce/cache None
7 Backend PVI module (V2-1, V2-4) Low — stub endpoints None
8 Learning layer (V2-6) Low — additive Task 7
9 Timeline + Hetzner (V2-7) Medium — async git None

Tasks 1→2→3→4 form the main chain. Tasks 5, 6, 7, 8, 9 are independent and can be parallelized.

V2-8 (complete .intelligence.ts coverage) is mechanical and not included — it's 37 more component intelligence files following the same pattern as the 10 in sdk-manifest.json.