---
phase: 33-persistent-memory
plan: 03
type: execute
wave: 2
depends_on: ["33-01", "33-02"]
files_modified:
  - server/src/routes/chat.ts
  - server/src/routes/assistant-handoff.ts
  - server/src/app.ts
  - ui/src/api/chat.ts
  - ui/src/pages/PersonalAssistant.tsx
  - server/src/__tests__/33-assistant-handoff.test.ts
autonomous: true
requirements: [ASST-01, ASST-03]

must_haves:
  truths:
    - "Stream endpoint produces real AI responses via puterProxyService instead of echoing input"
    - "Memory facts from previous sessions are prepended as system message before AI call"
    - "After each assistant response, a fact summary is appended to memory"
    - "Memory injection only happens when nexus mode is personal_ai or both"
    - "User clicks Turn this into a project and lands on a PM conversation with context"
    - "SSE data format matches what the client parser expects (type field present)"
  artifacts:
    - path: "server/src/routes/chat.ts"
      provides: "Updated stream endpoint with real AI + memory injection"
    - path: "server/src/routes/assistant-handoff.ts"
      provides: "POST /conversations/:id/assistant-handoff route"
      exports: ["assistantHandoffRoutes"]
    - path: "ui/src/api/chat.ts"
      provides: "Updated with assistantHandoff API method"
    - path: "server/src/__tests__/33-assistant-handoff.test.ts"
      provides: "Unit tests for handoff route"
  key_links:
    - from: "server/src/routes/chat.ts"
      to: "server/src/services/puter-proxy.ts"
      via: "puterProxyService.chatStream"
      pattern: "puterProxy.*chatStream"
    - from: "server/src/routes/chat.ts"
      to: "server/src/services/assistant-memory.ts"
      via: "memory.get + memory.append"
      pattern: "assistantMemoryService"
    - from: "server/src/routes/chat.ts"
      to: "server/src/services/nexus-settings.ts"
      via: "nexusSettingsService.get for mode check"
      pattern: "nexusSettingsService"
    - from: "server/src/routes/assistant-handoff.ts"
      to: "server/src/services/chat.ts"
      via: "chatService for conversation/message operations"
      pattern: "chatService"
    - from: "ui/src/pages/PersonalAssistant.tsx"
      to: "ui/src/api/chat.ts"
      via: "chatApi.assistantHandoff"
      pattern: "assistantHandoff"
---

<objective>
Replace the echo stub with real AI streaming (via puterProxyService) with memory injection, and create the assistant-to-PM handoff flow.

Purpose: ASST-01 requires memory to shape future responses (needs real AI, not echo). ASST-03 requires one-click handoff to PM agent with context transfer.
Output: Working AI streaming with memory, handoff route, and wired UI button.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/33-persistent-memory/33-RESEARCH.md
@.planning/phases/33-persistent-memory/33-01-SUMMARY.md
@.planning/phases/33-persistent-memory/33-02-SUMMARY.md
@server/src/routes/chat.ts
@server/src/services/puter-proxy.ts
@server/src/services/chat.ts
@ui/src/api/chat.ts
@ui/src/hooks/useStreamingChat.ts
</context>

<interfaces>
<!-- From Plan 01 outputs -->
From server/src/services/assistant-memory.ts (created in 33-01):
```typescript
export function assistantMemoryService() {
  return {
    get(companyId: string): Promise<{ facts: string[]; updatedAt: string | null }>,
    append(companyId: string, rawFact: string): Promise<{ facts: string[]; updatedAt: string | null }>,
    clear(companyId: string): Promise<void>,
  };
}
```

From server/src/services/nexus-settings.ts:
```typescript
export function nexusSettingsService() {
  return {
    get(): Promise<{ mode: "personal_ai" | "project_builder" | "both" }>,
    set(patch: Partial<NexusSettings>): Promise<NexusSettings>,
  };
}
```

From server/src/services/puter-proxy.ts:
```typescript
export function puterProxyService(db: Db) {
  return {
    chatStream(
      companyId: string,
      agentId: string | null | undefined,
      messages: unknown[],
      model: string | undefined,
      signal: AbortSignal | undefined,
    ): AsyncGenerator<string>,
    resolveToken(companyId: string): Promise<string>,
  };
}
```

From server/src/services/chat.ts:
```typescript
// chatService(db) returns object with:
// addMessage(conversationId, { role, content, agentId? })
// addSystemMessage(conversationId, { content, messageType, agentId? })
// getConversation(id) -> { id, companyId, agentId, ... }
// listMessages(conversationId, { cursor?, limit? })
// createConversation(companyId, { title?, agentId? })
```

From ui/src/api/chat.ts (SSE client parser - current):
```typescript
// Client expects: { type: "token", token: string } and { type: "done", messageId: string, content: string }
// Server currently sends: { token: string } and { done: true, messageId, content } (MISMATCH)
// This plan MUST fix the server to send the format the client expects
```

From ui/src/pages/PersonalAssistant.tsx (created in 33-02):
```typescript
// Has a disabled "Turn this into a project" button — this plan wires it
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Replace streamEcho with real AI streaming + memory injection</name>
  <files>
    server/src/routes/chat.ts
  </files>
  <action>
    Modify the `POST /conversations/:id/stream` handler in `server/src/routes/chat.ts`:

    1. BEFORE `res.flushHeaders()` (this is critical — Pitfall 3 from research), add:
       - Resolve the conversation: `const conversation = await svc.getConversation(req.params.id!);`
       - Get nexus mode: `const settings = await nexusSettingsService().get();`
       - Check if assistant mode: `const isAssistant = settings.mode !== "project_builder";`
       - If isAssistant, load memory: `const memory = await assistantMemoryService().get(conversation.companyId);`
       - Try resolving puter token: wrap `puterProxyService(db).resolveToken(conversation.companyId)` in try/catch — if no token available, fall back to echo stub

    2. Build messages array for AI call:
       - Fetch recent messages: `const recentMsgs = await svc.listMessages(req.params.id!, { limit: 20 });`
       - Build OpenAI-format messages array from recentMsgs.items (reverse to chronological order)
       - If isAssistant AND memory.facts.length > 0, prepend system message:
         ```
         { role: "system", content: `[Memory from previous sessions]\n${memory.facts.map(f => "- " + f).join("\n")}\n\nUse these facts to personalize your responses. Do not mention that you have a memory system unless asked.` }
         ```
       - Add the new user message: `{ role: "user", content }`
       - Cap the injected system prefix at 2000 characters

    3. After `res.flushHeaders()` and `:ok`, replace `svc.streamEcho(content, abort.signal)` with:
       - If puter token available: `puterProxyService(db).chatStream(conversation.companyId, agentId, messagesWithMemory, undefined, abort.signal)`
       - Else: fall back to `svc.streamEcho(content, abort.signal)` (keeps existing behavior for users without puter token)

    4. FIX SSE data format to match client expectations:
       - Token events: `res.write(\`data: ${JSON.stringify({ type: "token", token })}\n\n\`)`  (add `type: "token"`)
       - Done events: `res.write(\`data: ${JSON.stringify({ type: "done", messageId: message.id, content: fullContent.trim() })}\n\n\`)` (change `done: true` to `type: "done"`)
       - Error events: `res.write(\`data: ${JSON.stringify({ type: "error", error: "Stream error" })}\n\n\`)` (add `type: "error"`)

    5. After the done event (after saving the assistant message), if isAssistant:
       - Extract a brief fact from the exchange: use the last user message + first 200 chars of assistant response as a simple fact: `User asked about: ${content.slice(0, 100)}. Assistant topic: ${fullContent.slice(0, 100)}`
       - Call `assistantMemoryService().append(conversation.companyId, fact)` — non-blocking (`.catch(() => {})`)

    6. Move the conversation fetch for push notification to use the already-fetched `conversation` variable (avoid redundant DB call).

    Import at top of file:
    - `import { assistantMemoryService } from "../services/assistant-memory.js";`
    - `import { nexusSettingsService } from "../services/nexus-settings.js";`
    - `import { puterProxyService } from "../services/puter-proxy.js";`
    - The route function already receives `db: Db` as parameter.
  </action>
  <read_first>
    server/src/routes/chat.ts,
    server/src/services/puter-proxy.ts,
    server/src/services/assistant-memory.ts,
    server/src/services/nexus-settings.ts,
    server/src/services/chat.ts,
    ui/src/api/chat.ts
  </read_first>
  <verify>
    <automated>pnpm --filter @paperclipai/server tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "puterProxyService" server/src/routes/chat.ts
    - grep -q "assistantMemoryService" server/src/routes/chat.ts
    - grep -q "nexusSettingsService" server/src/routes/chat.ts
    - grep -q "chatStream" server/src/routes/chat.ts
    - grep -q 'type.*token\|"token"' server/src/routes/chat.ts
    - grep -q 'type.*done\|"done"' server/src/routes/chat.ts
    - grep -q "Memory from previous sessions" server/src/routes/chat.ts
    - grep -q "memory.facts\|memory\.facts" server/src/routes/chat.ts
  </acceptance_criteria>
  <done>
    Stream endpoint calls puterProxyService for real AI responses (falls back to echo when no puter token). Memory facts injected as system message prefix. Facts appended after each assistant turn. SSE format matches client parser. Memory injection skipped for project_builder mode.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create assistant handoff route and wire UI button</name>
  <files>
    server/src/routes/assistant-handoff.ts,
    server/src/app.ts,
    ui/src/api/chat.ts,
    ui/src/pages/PersonalAssistant.tsx,
    server/src/__tests__/33-assistant-handoff.test.ts
  </files>
  <behavior>
    - POST /conversations/:id/assistant-handoff creates a new conversation with handoff_context system message
    - The system message contains a summary of the last N user messages from the source conversation
    - Returns { targetConversationId } in response
    - Returns 404 if conversation not found
    - Auth is enforced (assertBoard)
    - UI button navigates to the new conversation on success
  </behavior>
  <action>
    Create `server/src/routes/assistant-handoff.ts`:
    - Export `assistantHandoffRoutes(db: Db): Router`
    - `POST /api/conversations/:id/assistant-handoff`:
      1. `assertBoard(req)`
      2. Get source conversation: `const conversation = await svc.getConversation(req.params.id!)`
      3. Fetch last 20 messages: `const msgs = await svc.listMessages(req.params.id!, { limit: 20 })`
      4. Build summary from user messages: filter to `role === "user"`, concatenate content with newlines, cap at 1500 chars
      5. Find PM agent: query `agents` table for agent with role "pm" in the same company. If none exists, create a generic project conversation without agent.
      6. Create new conversation: `await svc.createConversation(conversation.companyId, { title: "Project from assistant chat", agentId: pmAgent?.id })`
      7. Insert handoff_context system message: `await svc.addSystemMessage(newConv.id, { content: summary, messageType: "handoff_context" })`
      8. Return `res.json({ targetConversationId: newConv.id })`

    Wire in `server/src/app.ts`:
    - Import and mount: `app.use("/api", assistantHandoffRoutes(db))`

    Update `ui/src/api/chat.ts`:
    - Add method: `assistantHandoff(conversationId: string)` that POSTs to `/conversations/${conversationId}/assistant-handoff` and returns `{ targetConversationId: string }`

    Update `ui/src/pages/PersonalAssistant.tsx`:
    - Wire the "Turn this into a project" button:
      - Remove disabled state
      - On click: call `chatApi.assistantHandoff(currentConversationId)`
      - On success: navigate to `/dashboard` (or `/projects` — the PM conversation will appear in the conversation list)
      - Show toast on success: "Conversation handed off to PM"
      - Show toast on error

    Create `server/src/__tests__/33-assistant-handoff.test.ts`:
    - Test the route handler creates a new conversation with handoff_context system message
    - Test summary is built from user messages only
    - Test summary is capped at 1500 chars
    - Use mock chatService
  </action>
  <read_first>
    server/src/routes/chat.ts,
    server/src/services/chat.ts,
    ui/src/api/chat.ts,
    ui/src/pages/PersonalAssistant.tsx,
    server/src/app.ts
  </read_first>
  <verify>
    <automated>pnpm --filter @paperclipai/server vitest run --reporter=verbose src/__tests__/33-assistant-handoff.test.ts && pnpm --filter @paperclipai/server tsc --noEmit && pnpm --filter @paperclipai/ui tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "assistant-handoff" server/src/routes/assistant-handoff.ts
    - grep -q "handoff_context" server/src/routes/assistant-handoff.ts
    - grep -q "targetConversationId" server/src/routes/assistant-handoff.ts
    - grep -q "assistantHandoffRoutes" server/src/app.ts
    - grep -q "assistantHandoff" ui/src/api/chat.ts
    - grep -q "assistantHandoff\|Turn this into a project" ui/src/pages/PersonalAssistant.tsx
    - grep -q "handoff_context" server/src/__tests__/33-assistant-handoff.test.ts
  </acceptance_criteria>
  <done>
    Handoff route creates new PM conversation with context summary. UI button triggers handoff and navigates user. Tests verify summary construction and conversation creation.
  </done>
</task>

</tasks>

<verification>
pnpm --filter @paperclipai/server vitest run --reporter=verbose src/__tests__/33-*.test.ts
pnpm --filter @paperclipai/server tsc --noEmit
pnpm --filter @paperclipai/ui tsc --noEmit
</verification>

<success_criteria>
- Stream endpoint uses puterProxyService for real AI (with echo fallback)
- Memory facts prepended as system message to AI calls
- Facts appended to memory after each assistant turn
- SSE format fixed to include type field
- Handoff creates PM conversation with context summary
- UI button triggers handoff and navigates
- All tests pass, TypeScript clean
</success_criteria>

<output>
After completion, create `.planning/phases/33-persistent-memory/33-03-SUMMARY.md`
</output>
