14 KiB
14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 33-persistent-memory | 03 | execute | 2 |
|
|
true |
|
|
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>