nexus/.planning/phases/33-persistent-memory/33-03-PLAN.md

14 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
33-persistent-memory 03 execute 2
33-01
33-02
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
true
ASST-01
ASST-03
truths artifacts key_links
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)
path provides
server/src/routes/chat.ts Updated stream endpoint with real AI + memory injection
path provides exports
server/src/routes/assistant-handoff.ts POST /conversations/:id/assistant-handoff route
assistantHandoffRoutes
path provides
ui/src/api/chat.ts Updated with assistantHandoff API method
path provides
server/src/__tests__/33-assistant-handoff.test.ts Unit tests for handoff route
from to via pattern
server/src/routes/chat.ts server/src/services/puter-proxy.ts puterProxyService.chatStream puterProxy.*chatStream
from to via pattern
server/src/routes/chat.ts server/src/services/assistant-memory.ts memory.get + memory.append assistantMemoryService
from to via pattern
server/src/routes/chat.ts server/src/services/nexus-settings.ts nexusSettingsService.get for mode check nexusSettingsService
from to via pattern
server/src/routes/assistant-handoff.ts server/src/services/chat.ts chatService for conversation/message operations chatService
from to via pattern
ui/src/pages/PersonalAssistant.tsx ui/src/api/chat.ts chatApi.assistantHandoff assistantHandoff
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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, }; } ```

From server/src/services/nexus-settings.ts:

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:

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:

// 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):

// 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):

// Has a disabled "Turn this into a project" button — this plan wires it
Task 1: Replace streamEcho with real AI streaming + memory injection server/src/routes/chat.ts 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.
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 pnpm --filter @paperclipai/server tsc --noEmit - 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 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. Task 2: Create assistant handoff route and wire UI button 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 - 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 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
server/src/routes/chat.ts, server/src/services/chat.ts, ui/src/api/chat.ts, ui/src/pages/PersonalAssistant.tsx, server/src/app.ts 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 - 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 Handoff route creates new PM conversation with context summary. UI button triggers handoff and navigates user. Tests verify summary construction and conversation creation. 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

<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>
After completion, create `.planning/phases/33-persistent-memory/33-03-SUMMARY.md`