---
phase: 05-unified-search
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - server/lib/validation.js
  - server/services/search.js
  - server/routes/search.js
  - server/index.js
  - client/src/services/api.js
autonomous: true
requirements:
  - SRC-02
  - SRC-03

must_haves:
  truths:
    - "GET /api/search?q=foo returns categorized results from Brain, Memory, Apps, History, and Health sources"
    - "Each source result includes id, title, snippet, url, and type fields"
    - "Failed source adapters return empty results instead of failing the entire request"
    - "Query param q is validated (min 2 chars, max 200, trimmed)"
    - "Snippets contain ~100 char window around the keyword match"
    - "Sources with zero results are omitted from the response"
  artifacts:
    - path: "server/services/search.js"
      provides: "Fan-out search engine with source adapters"
      exports: ["fanOutSearch"]
    - path: "server/routes/search.js"
      provides: "GET /api/search route handler"
    - path: "server/lib/validation.js"
      provides: "searchQuerySchema export"
      contains: "searchQuerySchema"
    - path: "client/src/services/api.js"
      provides: "search() client API function"
      contains: "search"
  key_links:
    - from: "server/routes/search.js"
      to: "server/services/search.js"
      via: "import fanOutSearch"
      pattern: "fanOutSearch"
    - from: "server/routes/search.js"
      to: "server/lib/validation.js"
      via: "import searchQuerySchema"
      pattern: "searchQuerySchema"
    - from: "server/index.js"
      to: "server/routes/search.js"
      via: "app.use('/api/search', searchRoutes)"
      pattern: "api/search"
    - from: "client/src/services/api.js"
      to: "server/routes/search.js"
      via: "request('/search?q=')"
      pattern: "/search"
---

<objective>
Build the server-side unified search engine and client API function. A single GET /api/search?q= endpoint fans out keyword queries to all PortOS data sources (Brain inbox/people/projects/ideas/links, CoS Memory via BM25, Apps, History, Health metrics) using Promise.allSettled for fault isolation. Each source adapter performs case-insensitive substring matching, returns categorized results with deep-link URLs, and extracts ~100-char snippets around keyword matches. The client API function is added to api.js for consumption by the overlay component (Plan 05-02).

Purpose: SRC-02 (server fan-out search) and SRC-03 (categorized results with icons/snippets) — backend foundation
Output: search.js service, search.js route, searchQuerySchema in validation.js, search() in api.js
</objective>

<execution_context>
@/Users/antic/.claude/get-shit-done/workflows/execute-plan.md
@/Users/antic/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-unified-search/05-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From server/services/brainStorage.js:
```js
export async function getInboxLog(options = {}) // options: { limit, offset, status }
// Returns: { entries: [...], total, hasMore }
// Each entry: { id, text, timestamp, status, ... }

export async function getAll(type) // type: 'people'|'projects'|'ideas'|'links'
// Returns: array of records
// people: { id, name, notes, ... }
// projects: { id, name, description, ... }
// ideas: { id, title, description, ... }
// links: { id, title, url, description, ... }

export const getPeople = (filters) => ...
export const getProjects = (filters) => ...
export const getIdeas = (filters) => ...
export const getLinks = (filters) => ...
```

From server/services/memoryBM25.js:
```js
async function searchBM25(query, options = {}) // options: { limit, threshold }
// Returns: [{ id, score }] — no content, no summary
// Must load summary from memory index for snippet
```

From server/services/memory.js:
```js
export async function getMemory(id) // Returns full memory object with content
export async function getMemories(options = {}) // Returns { memories: [...], total }
// Each memory has: id, content, summary (150 char), category, tags, status, ...
```

From server/services/apps.js:
```js
export async function getAllApps({ includeArchived = true } = {})
// Returns: [{ id, name, description, type, repoPath, ... }]
```

From server/services/history.js:
```js
export async function getHistory(options = {})
// options: { limit, offset, action, target, success }
// Returns: { entries: [...], total }
// Each entry: { id, action, target, targetName, details, timestamp, ... }
```

From server/services/appleHealthQuery.js:
```js
export async function listDayFiles()
// Returns: ['2024-01-15', '2024-01-16', ...] sorted date strings
```

From server/lib/validation.js:
```js
// Uses Zod schemas — add searchQuerySchema to existing exports
// Pattern: export const {name}Schema = z.object({ ... });
```

From server/index.js route registration pattern:
```js
import searchRoutes from './routes/search.js';
app.use('/api/search', searchRoutes);
```

From client/src/services/api.js:
```js
// request(endpoint, options) — internal helper
// Pattern: export const fnName = (params) => request('/path', { ... });
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create search service with source adapters and fan-out engine</name>
  <files>server/services/search.js</files>
  <action>
Create `server/services/search.js` with the following structure:

1. **Import source services:**
   - `brainStorage` (getInboxLog, getAll for people/projects/ideas/links)
   - `memoryBM25` (searchBM25) and `memory` (getMemories for summary lookup)
   - `apps` (getAllApps)
   - `history` (getHistory)
   - `appleHealthQuery` (listDayFiles)

2. **extractSnippet(text, query, maxLen = 100) helper function:**
   - If text is falsy, return empty string
   - Find first case-insensitive occurrence of query in text
   - Extract ~100 char window centered around the match (30 chars before match start)
   - Prepend '...' if excerpt doesn't start at beginning, append '...' if it doesn't reach end
   - If no match found, return first maxLen chars of text

3. **Create source adapter functions** — each returns `{ id, label, icon, results }`:

   **searchBrain(query):**
   - Fan out to getInboxLog({ limit: 200 }), getPeople(), getProjects(), getIdeas(), getLinks() using Promise.allSettled
   - For inbox: filter entries where `text` includes query (case-insensitive), map to `{ id, title: text.substring(0, 60), snippet: extractSnippet(text, query), url: '/brain/inbox', type: 'inbox' }`
   - For people: filter on `name` or `notes`, map to `{ id, title: name, snippet: extractSnippet(notes, query), url: '/brain/inbox', type: 'person' }`
   - For projects: filter on `name` or `description`, map to `{ id, title: name, snippet: extractSnippet(description, query), url: '/brain/inbox', type: 'project' }`
   - For ideas: filter on `title` or `description`, map to `{ id, title, snippet: extractSnippet(description, query), url: '/brain/inbox', type: 'idea' }`
   - For links: filter on `title`, `url`, or `description`, map to `{ id, title: title || url, snippet: extractSnippet(description || url, query), url: '/brain/inbox', type: 'link' }`
   - Combine all matches, sort by most recent, take first 5
   - Return `{ id: 'brain', label: 'Brain', icon: 'Brain', results }`

   **searchMemory(query):**
   - Call searchBM25(query, { limit: 10, threshold: 0.1 })
   - Use getMemories() to load the full index, then map BM25 result IDs to their summary from the index
   - Map to `{ id, title: summary.substring(0, 60), snippet: summary, url: '/cos/memory', type: 'memory' }`
   - Take first 5 results
   - Return `{ id: 'memory', label: 'Memory', icon: 'Cpu', results }`

   **searchApps(query):**
   - Call getAllApps({ includeArchived: false })
   - Filter where `name` or `description` includes query (case-insensitive)
   - Map to `{ id, title: name, snippet: extractSnippet(description, query), url: '/apps', type: 'app' }`
   - Take first 5
   - Return `{ id: 'apps', label: 'Apps', icon: 'Package', results }`

   **searchHistory(query):**
   - Call getHistory({ limit: 500 })
   - Filter entries where `targetName` or `action` includes query (case-insensitive)
   - Map to `{ id, title: targetName || action, snippet: extractSnippet(action + ' ' + (targetName || ''), query), url: '/devtools/history', type: 'history' }`
   - Take first 5
   - Return `{ id: 'history', label: 'History', icon: 'History', results }`

   **searchHealth(query):**
   - Define known metric names: `['step_count', 'heart_rate', 'sleep_analysis', 'hrv', 'blood_pressure', 'body_mass', 'respiratory_rate', 'blood_glucose', 'body_temperature']`
   - Also define display-friendly aliases: `{ step_count: 'Steps', heart_rate: 'Heart Rate', sleep_analysis: 'Sleep', hrv: 'HRV', blood_pressure: 'Blood Pressure', body_mass: 'Body Mass', respiratory_rate: 'Respiratory Rate', blood_glucose: 'Blood Glucose', body_temperature: 'Body Temperature' }`
   - Filter metrics where either the key or the display name includes query (case-insensitive)
   - Map to `{ id: metricKey, title: displayName, snippet: displayName + ' health metric data', url: '/meatspace/health', type: 'health-metric' }`
   - Return `{ id: 'health', label: 'Health', icon: 'HeartPulse', results }`

4. **fanOutSearch(query) export:**
   - Call all 5 adapters via `Promise.allSettled([searchBrain(q), searchMemory(q), searchApps(q), searchHistory(q), searchHealth(q)])`
   - Map settled results: fulfilled => value, rejected => source with empty results array (use a fallback array of empty source objects keyed by index)
   - Filter out sources where `results.length === 0`
   - Return the array of non-empty sources

Use single-line logging per CLAUDE.md: `console.log(\`🔍 Search fan-out for "${query}" across ${adapters.length} sources\`)` at start, and log count of total results at end.

Do NOT use try/catch — per CLAUDE.md, errors bubble to centralized middleware. The Promise.allSettled pattern naturally handles per-adapter failures.
  </action>
  <verify>
    <automated>cd server && node -e "import('./services/search.js').then(m => console.log('✅ search.js loads, exports:', Object.keys(m)))"</automated>
  </verify>
  <done>search.js exports fanOutSearch function; imports compile; all 5 source adapters defined</done>
</task>

<task type="auto">
  <name>Task 2: Create search route, Zod schema, register in index.js, and add client API function</name>
  <files>server/routes/search.js, server/lib/validation.js, server/index.js, client/src/services/api.js</files>
  <action>
**1. Add searchQuerySchema to server/lib/validation.js:**

Add to the existing file (find an appropriate location near other query schemas, or at the end before the closing section):

```js
// =============================================================================
// SEARCH SCHEMAS
// =============================================================================

export const searchQuerySchema = z.object({
  q: z.string().min(2).max(200).trim()
});
```

**2. Create server/routes/search.js:**

```js
import { Router } from 'express';
import { asyncHandler } from '../lib/errorHandler.js';
import { validateRequest, searchQuerySchema } from '../lib/validation.js';
import { fanOutSearch } from '../services/search.js';

const router = Router();

router.get('/', asyncHandler(async (req, res) => {
  const { data } = validateRequest(searchQuerySchema, req.query);
  const sources = await fanOutSearch(data.q);
  res.json({ query: data.q, sources });
}));

export default router;
```

Follow the exact pattern from existing routes (e.g., history.js). Use `asyncHandler` wrapper. Use `validateRequest` for Zod validation. Return `{ query, sources }` response shape.

**3. Register route in server/index.js:**

Add import at top with other route imports:
```js
import searchRoutes from './routes/search.js';
```

Add route registration. Place it alphabetically among the existing `app.use('/api/...')` lines — it should go near `/api/screenshots` and `/api/settings`:
```js
app.use('/api/search', searchRoutes);
```

**4. Add search function to client/src/services/api.js:**

Add near the top of the export functions (or in a logical location with other utility endpoints):

```js
export const search = (q) => request(`/search?q=${encodeURIComponent(q)}`);
```

This follows the exact pattern of existing API functions (e.g., `getAppleHealthRange`).
  </action>
  <verify>
    <automated>cd server && node -e "import('./lib/validation.js').then(m => { if (!m.searchQuerySchema) throw new Error('missing searchQuerySchema'); console.log('✅ searchQuerySchema exists'); })" && node -e "import('./routes/search.js').then(m => console.log('✅ search route loads'))"</automated>
  </verify>
  <done>GET /api/search?q=test is a registered route; searchQuerySchema validates q param; client api.js exports search() function</done>
</task>

</tasks>

<verification>
1. Server starts without errors after changes: `cd server && node -e "import('./index.js')"`
2. Search route responds: `curl "http://localhost:5554/api/search?q=test"` returns JSON with `query` and `sources` fields
3. Validation rejects short queries: `curl "http://localhost:5554/api/search?q=a"` returns 400
4. Empty query rejected: `curl "http://localhost:5554/api/search"` returns 400
5. Failed adapters don't crash the entire request — if one service has no data, the response still returns other sources
</verification>

<success_criteria>
- GET /api/search?q=foo returns `{ query, sources: [...] }` with results from all available data sources
- Each source has { id, label, icon, results } structure
- Each result has { id, title, snippet, url, type } structure
- Sources with zero results are omitted from response
- Query validation enforces min 2 chars, max 200
- client api.js exports search(q) function
- Server starts cleanly with no import errors
</success_criteria>

<output>
After completion, create `.planning/phases/05-unified-search/05-01-SUMMARY.md`
</output>
