# Build Plan: Embedding Service Endpoint for Central Command

## What this is

Groove clients need an embedding endpoint to compute 384-dimensional vectors from session text. The client (`DomainTagger`) already has the code to call it — it just needs the URL. This unlocks semantic domain tagging and `session_embedding` in training envelopes (currently `null` for all sessions).

## Model

**`sentence-transformers/all-MiniLM-L6-v2`** — ONNX format
- 22M parameters, ~80MB on disk
- 384-dimensional output vectors
- Download from Hugging Face: `Xenova/all-MiniLM-L6-v2` (ONNX-optimized)
- Runtime: `onnxruntime-node` (npm package)

## Dependencies

```bash
npm install onnxruntime-node @xenova/transformers
```

`@xenova/transformers` handles tokenization + ONNX inference in one package. If you prefer manual control, use `onnxruntime-node` directly with the tokenizer JSON files from the model repo.

## Endpoint

**`POST /v1/embed`**

### Request body:
```json
{
  "input": "some text to embed",
  "model": "sentence-transformers/all-MiniLM-L6-v2"
}
```

- `input` — string, required, max 512 chars (client already truncates to 512)
- `model` — string, optional (only one model supported, ignore or validate)

### Response body (must match this exactly — client parses `data[0].embedding`):
```json
{
  "data": [
    {
      "embedding": [0.0123, -0.0456, 0.0789, "...384 floats total"],
      "index": 0
    }
  ],
  "model": "sentence-transformers/all-MiniLM-L6-v2"
}
```

This follows the OpenAI embedding response format. The client reads it at:
```javascript
const embedding = data?.data?.[0]?.embedding;  // must be Array<number>, length 384
```

### Error responses:
- `400` — missing `input` field
- `503` — model not loaded yet (startup)

## Health check behavior

On `init()`, the client sends a probe request to verify the service is up:
```json
POST /v1/embed
{ "input": "health check", "model": "sentence-transformers/all-MiniLM-L6-v2" }
```
If this returns `200 OK`, the client switches from `keyword` mode to `http` mode. If it fails or times out (5s), the client silently falls back to keyword matching. So the endpoint must handle tiny inputs gracefully.

## Implementation approach

```
server/
├── embedding.js        ← new file: load model once, expose embed(text) function
├── routes/
│   └── embed.js        ← new file: POST /v1/embed route
└── index.js            ← add: import + mount embed route
```

### `embedding.js` — Singleton model loader:
```javascript
import { pipeline } from '@xenova/transformers';

let embedder = null;
let loading = false;
let loadError = null;

export async function initEmbedding() {
  if (embedder || loading) return;
  loading = true;
  try {
    embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
    console.log('[embedding] model loaded');
  } catch (err) {
    loadError = err;
    console.error('[embedding] failed to load model:', err.message);
  }
  loading = false;
}

export async function embed(text) {
  if (!embedder) throw new Error('Model not loaded');
  const result = await embedder(text, { pooling: 'mean', normalize: true });
  return Array.from(result.data);
}

export function isReady() {
  return embedder !== null;
}
```

### `routes/embed.js`:
```javascript
import { Router } from 'express';
import { embed, isReady } from '../embedding.js';

export function createEmbedRoutes() {
  const router = Router();

  router.post('/v1/embed', async (req, res) => {
    if (!isReady()) {
      return res.status(503).json({ error: 'Model loading' });
    }

    const { input } = req.body;
    if (!input || typeof input !== 'string') {
      return res.status(400).json({ error: 'Missing input field' });
    }

    const text = input.slice(0, 512);

    try {
      const vector = await embed(text);
      res.json({
        data: [{ embedding: vector, index: 0 }],
        model: 'sentence-transformers/all-MiniLM-L6-v2',
      });
    } catch (err) {
      res.status(500).json({ error: err.message });
    }
  });

  return router;
}
```

### `index.js` — add to existing server setup:
```javascript
import { createEmbedRoutes } from './routes/embed.js';
import { initEmbedding } from './embedding.js';

// Mount alongside existing routes
app.use(createEmbedRoutes());

// Load model in background (don't block server startup)
initEmbedding();
```

## Client configuration

Once the endpoint is live, Groove clients connect by setting one env var:

```bash
export EMBEDDING_SERVICE_URL=https://api.groovedev.ai/v1/embed
```

The `DomainTagger` constructor reads `process.env.EMBEDDING_SERVICE_URL` automatically. No client code changes needed.

## What changes on the Groove side

Nothing. The client code already supports this. When `EMBEDDING_SERVICE_URL` is set:
1. `DomainTagger.init()` probes the URL → switches to `http` mode
2. `tag()` uses cosine similarity against domain centroids instead of keywords
3. `embed()` returns `{ model, vector, source_text }` instead of `null`
4. `trajectory-capture.js` writes the vector into `session_embedding` on SESSION_CLOSE

## Performance notes

- Model loads once at startup (~2-5 seconds)
- Inference: ~5-15ms per embedding on CPU, <2ms on GPU
- Each session close triggers ~45 embed calls (40 domain centroids + routing text + session embedding) during `init()`, then 1-2 per session close
- The 40 centroid embeddings are computed once in `_buildCentroids()` during init and cached in memory — not per-request
- Memory footprint: ~200MB for model + runtime

## Verification

After deploying, test with:
```bash
curl -X POST https://api.groovedev.ai/v1/embed \
  -H "Content-Type: application/json" \
  -d '{"input": "React TypeScript frontend development"}' \
  | jq '.data[0].embedding | length'
# Should return: 384
```

Then on any Groove client machine:
```bash
export EMBEDDING_SERVICE_URL=https://api.groovedev.ai/v1/embed
```

Next agent spawn will show `session_embedding` populated in training data instead of `null`, and domain tags will have much higher confidence scores.
