# Phase 8: Screenshot Profiles - Research

**Researched:** 2026-04-13
**Domain:** Profile persistence, JSON file I/O, MCP tool registration, schema design
**Confidence:** HIGH

## Summary

Phase 8 adds named screenshot profiles -- persistent capture configurations stored in a project-local JSON file. This is fundamentally a CRUD feature with file persistence, integrated into the existing MCP tool registration pattern. No new libraries are needed; the implementation uses Node.js `fs` APIs, the existing zod schema validation pattern, and the established `registerTool` convention in `server.ts`.

The main design challenge is the profile resolution layer in `start_capture` (D-10/D-11/D-12) -- loading a named profile, merging it with inline parameters, and producing a valid `CaptureConfig`. The liveness check for `list_screenshot_profiles` (D-14) reuses `findWindow` and monitor geometry from existing code. The `capture_from_current` feature (D-13) reads config from a completed session via `SessionManager.get()`.

**Primary recommendation:** Create a `ProfileManager` class following the `SessionManager` singleton pattern. Keep all file I/O in this one module. Profile resolution (merge logic) belongs in a pure function that `start_capture` calls before creating the session.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- **D-01:** Profiles stored in single JSON file. Path configurable via `SCREEN_TIMELAPSE_PROFILES_PATH` env var, defaulting to `.screen-timelapse/profiles.json` relative to CWD.
- **D-02:** JSON file stores both screenshot and timing profiles in separate top-level keys: `{ "screenshot": { ... }, "timing": { ... } }`.
- **D-03:** Saving with existing name overwrites silently. No version history.
- **D-04:** Profile schema: name, target, window_title, window_handle, x, y, width, height, description, created_at, updated_at. (Full field list in CONTEXT.md D-04.)
- **D-05:** Window profiles resolve by title at capture time. Handle stored as hint only.
- **D-06:** Names are case-insensitive, trimmed, slugified for storage key. Display name preserved.
- **D-07:** Three new tools: `save_screenshot_profile`, `list_screenshot_profiles`, `delete_screenshot_profile`.
- **D-08:** `save_screenshot_profile` accepts same target params as `start_capture`.
- **D-09:** Validation enforces required fields per target type.
- **D-10:** `start_capture` gains optional `screenshot_profile` param. Loads named profile as base config.
- **D-11:** Inline params override profile params (merge semantics).
- **D-12:** Missing profile returns structured error with available profile names.
- **D-13:** `capture_from_current` mode: pass `source_session` ID to auto-populate from completed session config.
- **D-14:** `list_screenshot_profiles` includes `test` field showing target liveness.

### Claude's Discretion
- Internal file format details (JSON structure, indentation)
- Error message wording
- Whether to create profiles directory automatically on first save
- Profile field ordering in list output

### Deferred Ideas (OUT OF SCOPE)
- Profile import/export between projects
- Profile groups/tags
</user_constraints>

## Standard Stack

No new libraries needed. This phase uses only what is already installed.

### Core (existing)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Node.js `fs/promises` | built-in | Profile JSON file read/write | No external dependency needed for single-file JSON persistence |
| zod | ^3.25.0 | Tool input schemas, profile validation | Already used by all existing MCP tools [VERIFIED: package.json] |
| @modelcontextprotocol/sdk | ^1.29.0 | `registerTool` for new MCP tools | Already used in server.ts [VERIFIED: package.json] |

### Supporting (existing)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| node-screenshots | ^0.2.8 | Window enumeration for liveness check (D-14) | `Window.all()` in list profiles |
| sharp | ^0.34.5 | Not directly needed for this phase | Only if adding thumbnail previews (not in scope) |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Raw fs JSON | conf / lowdb / configstore | Overkill for single-file JSON; adds dependency; project has no existing config lib |
| Slugify by hand | slugify npm package | Simple regex replacement is sufficient for profile names; no need for full Unicode slug library |

## Architecture Patterns

### Recommended Project Structure
```
src/
  profiles/
    profile-manager.ts    # ProfileManager class (file I/O, CRUD)
    profile-types.ts      # Profile interfaces, zod schemas, slug utility
    profile-resolver.ts   # Pure function: merge profile + inline params -> CaptureConfig
  server.ts               # Add 3 new tool registrations + modify start_capture
  types.ts                # No changes needed (CaptureConfig stays as-is)
```

[ASSUMED] This structure follows the existing `capture/` module pattern where related concerns are grouped in a subdirectory.

### Pattern 1: ProfileManager Singleton
**What:** A class managing profile CRUD with lazy file I/O, following the `SessionManager` pattern.
**When to use:** All profile operations go through this single instance.
**Key behaviors:**
- Lazy-loads JSON file on first access (not at server startup -- avoids blocking MCP handshake)
- Writes to disk after every mutation (save/delete) -- profiles are not high-frequency
- Creates directory + file on first save if they don't exist (discretion item)
- Caches in-memory Map for fast reads; file is source of truth on load

```typescript
// Source: Pattern derived from src/capture/session-manager.ts
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { logger } from "../logger.js";

export class ProfileManager {
  private profiles: Map<string, ScreenshotProfile> | null = null;
  private filePath: string;

  constructor(filePath?: string) {
    this.filePath = filePath
      ?? process.env.SCREEN_TIMELAPSE_PROFILES_PATH
      ?? ".screen-timelapse/profiles.json";
  }

  private async load(): Promise<Map<string, ScreenshotProfile>> {
    if (this.profiles !== null) return this.profiles;
    try {
      const raw = await readFile(this.filePath, "utf-8");
      const data = JSON.parse(raw);
      const screenshot = data.screenshot ?? {};
      this.profiles = new Map(Object.entries(screenshot));
    } catch {
      // File doesn't exist yet -- start empty
      this.profiles = new Map();
    }
    return this.profiles;
  }

  private async persist(): Promise<void> {
    const profiles = await this.load();
    const data = {
      screenshot: Object.fromEntries(profiles),
      timing: {}, // Reserved for Phase 9 (D-02)
    };
    await mkdir(dirname(this.filePath), { recursive: true });
    await writeFile(this.filePath, JSON.stringify(data, null, 2), "utf-8");
  }
  // ... save, get, delete, list methods
}
```

### Pattern 2: Profile Resolution (merge semantics)
**What:** A pure function that takes a profile name + inline params and produces merged config fields.
**When to use:** In `start_capture` handler, before creating `CaptureConfig`.

```typescript
// Profile provides base values; inline params override
function resolveProfile(
  profile: ScreenshotProfile,
  inlineParams: Partial<StartCaptureInput>,
): Partial<StartCaptureInput> {
  return {
    target: inlineParams.target ?? profile.target,
    window_title: inlineParams.window_title ?? profile.windowTitle,
    window_handle: inlineParams.window_handle ?? profile.windowHandle,
    x: inlineParams.x ?? profile.x,
    y: inlineParams.y ?? profile.y,
    // ... etc for all target params
  };
}
```

### Pattern 3: Slugification
**What:** Convert display name to storage key.
**When to use:** All profile name operations.

```typescript
function slugify(name: string): string {
  return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
```

[ASSUMED] This simple slug approach is sufficient. No Unicode edge cases expected for profile names.

### Pattern 4: Liveness Check (D-14)
**What:** For each profile in list output, check if the target is currently available.
**When to use:** `list_screenshot_profiles` response.

```typescript
// For window/window_region profiles: check if window exists
function checkProfileLiveness(profile: ScreenshotProfile): boolean {
  if (profile.target === "desktop") return true;
  if (profile.target === "window" || profile.target === "window_region") {
    return findWindow(profile.windowHandle, profile.windowTitle) !== null;
  }
  if (profile.target === "region") {
    // Check if region is within any monitor bounds
    const monitors = Monitor.all();
    // ... bounds check
    return true; // Simplified; region on-screen check
  }
  return false;
}
```

### Anti-Patterns to Avoid
- **Reading file on every operation:** Load once, cache in memory, write on mutation. File reads are lazy.
- **Storing window handles as primary identifier:** Handles are ephemeral (D-05). Always resolve by title at capture time.
- **Tight coupling profile resolution into start_capture handler:** Extract to pure function for testability.
- **Blocking server startup with file I/O:** Lazy-load on first profile access, not during `createServer()`.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON file atomicity | Custom temp-file-swap | `writeFile` with `{ flush: true }` (Node 20+) or accept non-atomic for this use case | Profile files are small and low-frequency writes; corruption risk is negligible |
| Schema validation | Manual if/else chains | Zod schemas (already in project) | Consistent with existing tool validation pattern |
| File path resolution | Manual string concat | `path.resolve()` / `path.join()` | Cross-platform path handling |

**Key insight:** This is straightforward CRUD. The complexity is in the merge semantics and integration points, not in any individual operation.

## Common Pitfalls

### Pitfall 1: Stale Timing Profiles Key
**What goes wrong:** Phase 9 expects `timing` key to exist in the JSON file. If Phase 8 only writes `screenshot`, Phase 9 has to handle missing key.
**Why it happens:** Forgetting D-02's shared file structure.
**How to avoid:** Always write both keys when persisting: `{ "screenshot": {...}, "timing": {...} }`. Initialize `timing` as empty object.
**Warning signs:** Phase 9 implementation needs special handling for missing key.

### Pitfall 2: Race Condition on File Writes
**What goes wrong:** Two concurrent MCP tool calls both modify profiles and one write overwrites the other.
**Why it happens:** MCP tools are async; two `save_screenshot_profile` calls can interleave.
**How to avoid:** Use an in-memory cache as the source of truth during the process lifetime. Load from file once, then all mutations go through the in-memory Map and persist immediately. Since there is only one MCP server process, the Map serializes access naturally. Use a write queue/mutex if truly needed, but `await persist()` in sequence should suffice.
**Warning signs:** Profile disappears after rapid successive saves.

### Pitfall 3: Profile Name Collision After Slugification
**What goes wrong:** "My Profile!" and "my-profile" produce the same slug, causing silent overwrite.
**Why it happens:** D-03 says overwrite is intentional, but the user might not realize two different display names map to the same key.
**How to avoid:** This is acceptable per D-03 (overwrite is by design). The `save_screenshot_profile` response should include the resolved slug so the agent can see the canonical key.
**Warning signs:** None -- this is expected behavior.

### Pitfall 4: Environment Variable Path with Relative Components
**What goes wrong:** `SCREEN_TIMELAPSE_PROFILES_PATH=../profiles.json` resolves relative to CWD, which may differ between MCP server restarts.
**Why it happens:** Node.js `fs` APIs resolve relative to `process.cwd()`.
**How to avoid:** Resolve the path once at ProfileManager construction using `path.resolve()`. Document that relative paths are relative to the server's CWD.
**Warning signs:** Profile file "disappears" when server is started from different directory.

### Pitfall 5: `capture_from_current` with In-Progress Session
**What goes wrong:** Agent passes a session ID that is still `capturing` state.
**Why it happens:** Agent tries to save profile immediately after starting capture.
**How to avoid:** `source_session` should accept sessions in any state (the config is available from creation). The CaptureConfig is set at session creation time, so it is always available regardless of state.
**Warning signs:** Error message says session not found when it exists but is still running.

## Code Examples

### Tool Registration Pattern (from existing codebase)
```typescript
// Source: src/server.ts lines 38-149 (start_capture registration)
server.registerTool(
  "save_screenshot_profile",
  {
    title: "Save Screenshot Profile",
    description: "Save or update a named screenshot profile...",
    inputSchema: {
      name: z.string().min(1).describe("Profile name"),
      target: z.enum(["desktop", "window", "region", "window_region"]).describe("Capture target type"),
      // ... same target params as start_capture
      description: z.string().optional().describe("Human-readable description"),
      source_session: z.string().uuid().optional().describe("Copy config from this completed session"),
    },
  },
  async (args) => {
    // ... handler
  },
);
```

### Extending start_capture Schema
```typescript
// Add to existing start_capture inputSchema:
screenshot_profile: z
  .string()
  .optional()
  .describe("Named screenshot profile to use as base config. Inline params override profile values."),
```

### Profile Type Definition
```typescript
export interface ScreenshotProfile {
  slug: string;           // Storage key (lowercase, hyphenated)
  displayName: string;    // Original user-provided name
  target: "desktop" | "window" | "region" | "window_region";
  windowTitle?: string;
  windowHandle?: number;  // Hint only (D-05)
  x?: number;
  y?: number;
  width?: number;
  height?: number;
  regionX?: number;
  regionY?: number;
  regionWidth?: number;
  regionHeight?: number;
  description?: string;
  createdAt: string;      // ISO 8601
  updatedAt: string;      // ISO 8601
}
```

### File Format
```json
{
  "screenshot": {
    "vs-code-editor": {
      "slug": "vs-code-editor",
      "displayName": "VS Code Editor",
      "target": "window",
      "windowTitle": "Visual Studio Code",
      "description": "Main editor window",
      "createdAt": "2026-04-13T10:00:00.000Z",
      "updatedAt": "2026-04-13T10:00:00.000Z"
    }
  },
  "timing": {}
}
```

## State of the Art

No library changes or API evolution relevant to this phase. All components are stable.

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `fs.writeFileSync` | `fs/promises` async | Node 14+ | Use async throughout; never block the event loop in MCP server |

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Simple regex slugification is sufficient for profile names | Architecture Patterns | LOW -- could miss edge cases with non-Latin characters, but agents typically use ASCII names |
| A2 | `src/profiles/` directory structure follows existing `src/capture/` pattern | Architecture Patterns | LOW -- organizational preference, easy to adjust |
| A3 | Non-atomic file writes are acceptable for this use case | Don't Hand-Roll | LOW -- profiles are small, low-frequency writes; corruption extremely unlikely |

## Open Questions

1. **Timing profiles key preservation across phases**
   - What we know: D-02 says shared file with `timing` key. Phase 9 will populate it.
   - What's unclear: Should Phase 8 preserve an existing `timing` key if one somehow exists (e.g., from manual editing)?
   - Recommendation: Yes -- always read full file, only replace `screenshot` key, preserve `timing` key as-is.

2. **Monitor bounds check for region liveness (D-14)**
   - What we know: `Monitor.all()` from node-screenshots returns monitor geometry.
   - What's unclear: Whether checking if region falls within any monitor bounds is worth the complexity for a "quick liveness check."
   - Recommendation: For `region` profiles, always return `test: true` (regions don't go stale the way windows do). For `window`/`window_region`, check `findWindow` return. For `desktop`, always true.

## Project Constraints (from CLAUDE.md)

- **Platform:** Windows 11 primary target
- **Protocol:** MCP server spec (tools + resources over stdio)
- **Performance:** No noticeable slowdown -- liveness checks must be fast (D-14)
- **Logging:** All output to stderr only (logger pattern)
- **Dependencies:** Use existing stack only (no new npm packages)

## Sources

### Primary (HIGH confidence)
- `src/server.ts` -- Existing tool registration pattern, start_capture schema and handler
- `src/types.ts` -- CaptureConfig interface, StartCaptureInputSchema
- `src/capture/session-manager.ts` -- Singleton class pattern, CRUD operations
- `src/capture/targets/window-utils.ts` -- findWindow function for liveness checks
- `08-CONTEXT.md` -- All 14 locked decisions

### Secondary (MEDIUM confidence)
- Node.js `fs/promises` API -- well-established, no version-specific concerns for Node 20 LTS [VERIFIED: tsconfig targets ES2022, package requires Node 20]

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new libraries, all existing dependencies
- Architecture: HIGH -- follows established patterns from existing codebase
- Pitfalls: HIGH -- common file I/O and concurrency patterns well-understood

**Research date:** 2026-04-13
**Valid until:** 2026-05-13 (stable domain, no moving parts)
