docs(33): create phase plan — memory service, personal assistant page, AI streaming + handoff

This commit is contained in:
Nexus Dev 2026-04-03 21:50:35 +00:00
parent 2efed8797e
commit 664392e9eb
4 changed files with 779 additions and 2 deletions

View file

@ -156,7 +156,12 @@ Plans:
2. Pasting an API key or token into chat and then starting a new session results in the assistant having no knowledge of that credential — the sanitization blocklist prevented it from being stored
3. A user clicks "Turn this into a project" in an assistant conversation; a PM agent is created with a system message containing the conversation summary and they land on the project dashboard
4. A user with mode set to "Both" can switch between Personal Assistant chat and the project dashboard without losing context or cross-contaminating assistant memory with project agent messages
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 33-01-PLAN.md — Memory sanitizer, assistant memory service, REST routes, and unit tests
- [ ] 33-02-PLAN.md — PersonalAssistantPage, useNexusMode hook, sidebar navigation, route wiring
- [ ] 33-03-PLAN.md — Real AI streaming with memory injection, assistant-to-PM handoff route and UI
**UI hint**: yes
### Phase 34: Voice
@ -227,6 +232,6 @@ All 21 v1.5 requirements are mapped to exactly one phase. No orphans.
| 30. Hardware Detection + Mode Selection | v1.5 | 2/2 | Complete | 2026-04-03 |
| 31. Puter.js Zero-Config Cloud | v1.5 | 4/4 | Complete | 2026-04-03 |
| 32. Multi-Step Onboarding Wizard | v1.5 | 1/1 | Complete | 2026-04-03 |
| 33. Persistent Memory + Personal Assistant Mode | v1.5 | 0/TBD | Not started | - |
| 33. Persistent Memory + Personal Assistant Mode | v1.5 | 0/3 | Planning complete | - |
| 34. Voice | v1.5 | 0/TBD | Not started | - |
| 35. npx buildthis CLI | v1.5 | 0/TBD | Not started | - |

View file

@ -0,0 +1,240 @@
---
phase: 33-persistent-memory
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- server/src/services/assistant-memory.ts
- server/src/services/memory-sanitizer.ts
- server/src/routes/assistant-memory.ts
- server/src/app.ts
- server/src/__tests__/33-assistant-memory.test.ts
- server/src/__tests__/33-memory-sanitization.test.ts
autonomous: true
requirements: [ASST-01, ASST-02]
must_haves:
truths:
- "Memory facts persist to disk and survive server restart"
- "API key patterns (sk-*, ghp_*, AIza*) pasted into chat are scrubbed to [REDACTED] before storage"
- "JWT-shaped values are scrubbed before storage"
- "Key=value patterns with sensitive keys (api_key, token, secret, password, bearer) are scrubbed"
- "Memory is scoped per companyId (separate files)"
- "Memory capped at 50 facts (FIFO eviction)"
artifacts:
- path: "server/src/services/assistant-memory.ts"
provides: "File-backed memory service with get/append/clear"
exports: ["assistantMemoryService"]
- path: "server/src/services/memory-sanitizer.ts"
provides: "Credential scrubbing for plain-text memory facts"
exports: ["sanitizeMemoryFact"]
- path: "server/src/routes/assistant-memory.ts"
provides: "GET/PATCH/DELETE memory endpoints"
exports: ["assistantMemoryRoutes"]
- path: "server/src/__tests__/33-assistant-memory.test.ts"
provides: "Unit tests for memory service CRUD"
- path: "server/src/__tests__/33-memory-sanitization.test.ts"
provides: "Unit tests for credential scrubbing"
key_links:
- from: "server/src/services/assistant-memory.ts"
to: "data/assistant-memory/<companyId>.json"
via: "fs.readFileSync/writeFileSync"
pattern: "readFileSync.*assistant-memory"
- from: "server/src/services/assistant-memory.ts"
to: "server/src/services/memory-sanitizer.ts"
via: "sanitizeMemoryFact import"
pattern: "sanitizeMemoryFact"
- from: "server/src/routes/assistant-memory.ts"
to: "server/src/services/assistant-memory.ts"
via: "service import"
pattern: "assistantMemoryService"
---
<objective>
Create the file-backed assistant memory service with write-time credential sanitization and REST endpoints.
Purpose: ASST-01 requires persistent memory across sessions; ASST-02 requires sanitization at write time so credentials never reach disk.
Output: Memory service, sanitizer, routes, and comprehensive unit tests.
</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
@server/src/services/nexus-settings.ts
@server/src/redaction.ts
@server/src/home-paths.ts
</context>
<interfaces>
<!-- Existing patterns the executor needs -->
From server/src/services/nexus-settings.ts:
```typescript
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
// Pattern: file-backed JSON service
// resolveXxxPath() -> fs.readFileSync -> zod safeParse -> fallback default
// set() -> mkdirSync recursive -> writeFileSync
export function nexusSettingsService() {
async function get(): Promise<NexusSettings> { ... }
async function set(patch: Partial<NexusSettings>): Promise<NexusSettings> { ... }
return { get, set };
}
```
From server/src/redaction.ts:
```typescript
const SECRET_PAYLOAD_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
```
From server/src/routes/nexus-settings.ts (route mounting pattern):
```typescript
// Routes are mounted in server/src/app.ts via:
// app.use("/api", someRoutes(db));
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create memory sanitizer and assistant memory service</name>
<files>
server/src/services/memory-sanitizer.ts,
server/src/services/assistant-memory.ts,
server/src/__tests__/33-memory-sanitization.test.ts,
server/src/__tests__/33-assistant-memory.test.ts
</files>
<behavior>
- sanitizeMemoryFact("My API key is sk-abc123def456ghijklmnopqrst") returns "My API key is [REDACTED]"
- sanitizeMemoryFact("ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJk") returns "[REDACTED]"
- sanitizeMemoryFact("AIzaSyA1234567890abcdefghijklmnopqrstuv") returns "[REDACTED]"
- sanitizeMemoryFact("token=abc123longvalue") returns "[REDACTED]"
- sanitizeMemoryFact("api_key: sk-something") returns "[REDACTED]"
- sanitizeMemoryFact("password = hunter2") returns "[REDACTED]"
- sanitizeMemoryFact("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U") returns "[REDACTED]"
- sanitizeMemoryFact("I prefer TypeScript over JavaScript") returns unchanged
- sanitizeMemoryFact("Use port 3000 for the server") returns unchanged
- assistantMemoryService.get(companyId) returns { facts: [], updatedAt: null } when no file exists
- assistantMemoryService.append(companyId, "I prefer TypeScript") writes fact to file and returns updated memory
- assistantMemoryService.append() with credential text stores sanitized version
- assistantMemoryService.clear(companyId) removes all facts
- After appending 51 facts, only 50 remain (FIFO eviction of oldest)
- Two different companyIds get separate memory files
</behavior>
<action>
Create `server/src/services/memory-sanitizer.ts`:
- Export `sanitizeMemoryFact(raw: string): string`
- Define `CREDENTIAL_INLINE_RE` matching: `sk-` (OpenAI), `ghp_` (GitHub PAT), `AIza` (Google API), and generic long alphanumeric tokens with dots (JWTs)
- Pattern: `/\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36}|AIza[0-9A-Za-z_-]{35}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,})/g`
- Define `SENSITIVE_KEY_VALUE_RE` matching key=value or key: value patterns where key is api_key, token, secret, password, bearer, auth, etc.
- Pattern: `/(?:api[_-]?key|token|secret|password|bearer|auth)\s*[:=]\s*\S+/gi`
- Apply both regexes, replacing matches with `[REDACTED]`
Create `server/src/services/assistant-memory.ts`:
- Export `assistantMemoryService()` returning `{ get, append, clear }`
- Schema: `z.object({ facts: z.array(z.string()).default([]), updatedAt: z.string().nullable().default(null) })`
- `resolveMemoryPath(companyId)`: `path.resolve(resolvePaperclipInstanceRoot(), "data", "assistant-memory", \`${companyId}.json\`)`
- `get(companyId)`: read file, safeParse, return default `{ facts: [], updatedAt: null }` on any error
- `append(companyId, rawFact)`: call `sanitizeMemoryFact(rawFact)`, skip if result is empty or only `[REDACTED]`, read current, push to facts array, cap at 50 (shift oldest), set updatedAt to ISO string, mkdirSync recursive, writeFileSync
- `clear(companyId)`: write `{ facts: [], updatedAt: null }` to file (or delete file)
Create test files using vitest. Use `os.tmpdir()` + random dir for test isolation (mock `resolvePaperclipInstanceRoot` to return temp dir). Test all behaviors listed above.
</action>
<read_first>
server/src/services/nexus-settings.ts,
server/src/redaction.ts,
server/src/home-paths.ts
</read_first>
<verify>
<automated>pnpm --filter @paperclipai/server vitest run --reporter=verbose src/__tests__/33-memory-sanitization.test.ts src/__tests__/33-assistant-memory.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "sanitizeMemoryFact" server/src/services/memory-sanitizer.ts
- grep -q "CREDENTIAL_INLINE_RE" server/src/services/memory-sanitizer.ts
- grep -q "SENSITIVE_KEY_VALUE_RE" server/src/services/memory-sanitizer.ts
- grep -q "assistantMemoryService" server/src/services/assistant-memory.ts
- grep -q "resolveMemoryPath" server/src/services/assistant-memory.ts
- grep -q "sanitizeMemoryFact" server/src/services/assistant-memory.ts
- grep -q "50" server/src/services/assistant-memory.ts
- grep -q "REDACTED" server/src/__tests__/33-memory-sanitization.test.ts
- grep -q "sk-" server/src/__tests__/33-memory-sanitization.test.ts
- grep -q "ghp_" server/src/__tests__/33-memory-sanitization.test.ts
</acceptance_criteria>
<done>
Memory service reads/writes per-company JSON files. Sanitizer scrubs API keys, JWTs, and key=value credential patterns. 50-fact FIFO cap enforced. All tests pass.
</done>
</task>
<task type="auto">
<name>Task 2: Create memory REST routes and wire to app</name>
<files>
server/src/routes/assistant-memory.ts,
server/src/app.ts
</files>
<action>
Create `server/src/routes/assistant-memory.ts`:
- Export `assistantMemoryRoutes(): Router`
- `GET /api/assistant-memory/:companyId` — calls `assertBoard(req)`, `assertCompanyAccess(req, companyId)`, returns `assistantMemoryService().get(companyId)`
- `PATCH /api/assistant-memory/:companyId` — accepts `{ fact: string }` body, calls `assistantMemoryService().append(companyId, fact)`, returns updated memory
- `DELETE /api/assistant-memory/:companyId` — calls `assistantMemoryService().clear(companyId)`, returns 204
- Import `assertBoard`, `assertCompanyAccess` from `./authz.js`
- Import `assistantMemoryService` from `../services/assistant-memory.js`
Wire in `server/src/app.ts`:
- Import `assistantMemoryRoutes` from `./routes/assistant-memory.js`
- Mount: `app.use("/api", assistantMemoryRoutes())`
- Place after existing route mounts (follow existing pattern order)
</action>
<read_first>
server/src/app.ts,
server/src/routes/nexus-settings.ts,
server/src/routes/authz.ts
</read_first>
<verify>
<automated>pnpm --filter @paperclipai/server tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep -q "assistantMemoryRoutes" server/src/routes/assistant-memory.ts
- grep -q "assertBoard" server/src/routes/assistant-memory.ts
- grep -q "assertCompanyAccess" server/src/routes/assistant-memory.ts
- grep -q "assistantMemoryRoutes" server/src/app.ts
- grep -q 'GET.*assistant-memory\|get.*assistant-memory' server/src/routes/assistant-memory.ts
- grep -q 'PATCH\|patch\|post' server/src/routes/assistant-memory.ts
- grep -q 'DELETE\|delete' server/src/routes/assistant-memory.ts
</acceptance_criteria>
<done>
Memory CRUD routes mounted at /api/assistant-memory/:companyId. GET returns facts, PATCH appends a sanitized fact, DELETE clears memory. Auth enforced. TypeScript compiles clean.
</done>
</task>
</tasks>
<verification>
pnpm --filter @paperclipai/server vitest run --reporter=verbose src/__tests__/33-*.test.ts
pnpm --filter @paperclipai/server tsc --noEmit
</verification>
<success_criteria>
- assistantMemoryService reads/writes per-company JSON at data/assistant-memory/<companyId>.json
- sanitizeMemoryFact scrubs sk-*, ghp_*, AIza*, JWTs, and key=value credential patterns
- Memory capped at 50 facts with FIFO eviction
- REST endpoints mounted and auth-gated
- All unit tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/33-persistent-memory/33-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,218 @@
---
phase: 33-persistent-memory
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- ui/src/pages/PersonalAssistant.tsx
- ui/src/api/assistantMemory.ts
- ui/src/hooks/useNexusMode.ts
- ui/src/App.tsx
- ui/src/components/Layout.tsx
autonomous: true
requirements: [ASST-04]
must_haves:
truths:
- "PersonalAssistantPage is visible when mode is personal_ai or both"
- "PersonalAssistantPage is hidden (route redirects) when mode is project_builder"
- "User can navigate to assistant page from sidebar"
- "Assistant page renders the existing ChatPanel component for chat functionality"
artifacts:
- path: "ui/src/pages/PersonalAssistant.tsx"
provides: "Personal assistant page component"
exports: ["PersonalAssistant"]
- path: "ui/src/hooks/useNexusMode.ts"
provides: "React Query hook for nexus settings mode"
exports: ["useNexusMode"]
- path: "ui/src/api/assistantMemory.ts"
provides: "API client for memory endpoints"
exports: ["assistantMemoryApi"]
key_links:
- from: "ui/src/pages/PersonalAssistant.tsx"
to: "ui/src/hooks/useNexusMode.ts"
via: "useNexusMode hook"
pattern: "useNexusMode"
- from: "ui/src/App.tsx"
to: "ui/src/pages/PersonalAssistant.tsx"
via: "Route element"
pattern: "PersonalAssistant"
- from: "ui/src/components/Layout.tsx"
to: "assistant route"
via: "NavLink in sidebar"
pattern: "assistant"
---
<objective>
Create the Personal Assistant page, mode-gating hook, and memory API client. Wire the route and sidebar navigation.
Purpose: ASST-04 requires assistant and project builder modes to work standalone or together. The PersonalAssistantPage is the entry point for assistant mode.
Output: Working assistant page with mode-gated visibility, sidebar link, and memory API client.
</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
@ui/src/App.tsx
@ui/src/components/Layout.tsx
@ui/src/api/hardware.ts
@ui/src/components/ChatPanel.tsx
</context>
<interfaces>
<!-- Existing code the executor needs -->
From ui/src/api/hardware.ts:
```typescript
export type NexusMode = "personal_ai" | "project_builder" | "both";
export interface NexusSettings { mode: NexusMode; }
export function fetchNexusSettings(): Promise<NexusSettings>;
```
From ui/src/App.tsx (route pattern):
```typescript
// Board routes at line 122-180
function boardRoutes() {
return <>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
// ... more routes
</>;
}
```
From ui/src/components/Layout.tsx (sidebar pattern):
```typescript
// ChatPanel rendered at line 463
// Sidebar NavLinks with icons (lucide-react)
```
From ui/src/components/ChatPanel.tsx:
```typescript
// Existing chat panel component — renders conversation list + message list
// Used as slide-over panel in Layout
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create useNexusMode hook, memory API client, and PersonalAssistantPage</name>
<files>
ui/src/hooks/useNexusMode.ts,
ui/src/api/assistantMemory.ts,
ui/src/pages/PersonalAssistant.tsx
</files>
<action>
Create `ui/src/hooks/useNexusMode.ts`:
- Export `useNexusMode()` hook that calls `useQuery` with key `["nexus", "settings"]` fetching `fetchNexusSettings()` from `../api/hardware`
- Return `{ mode, isLoading, isAssistantEnabled }` where `isAssistantEnabled = mode !== "project_builder"`
- Default mode to `"both"` while loading
Create `ui/src/api/assistantMemory.ts`:
- Export `assistantMemoryApi` object with:
- `getMemory(companyId: string)` — GET `/assistant-memory/${companyId}` using `api.get`
- `appendFact(companyId: string, fact: string)` — PATCH `/assistant-memory/${companyId}` with `{ fact }`
- `clearMemory(companyId: string)` — DELETE `/assistant-memory/${companyId}`
- Import `api` from `./client`
Create `ui/src/pages/PersonalAssistant.tsx`:
- Export default `PersonalAssistant` component
- Use `useNexusMode()` — if `!isAssistantEnabled`, render `<Navigate to="/dashboard" replace />`
- Render a full-page chat layout:
- Use `useCompany()` to get `selectedCompany`
- Create or retrieve an assistant conversation (use existing `chatApi.listConversations` with a dedicated agent filter, or create a new one)
- Render `ChatPanel`-style UI: conversation list on left, messages on right
- Include a header with "Personal Assistant" title and a "Turn this into a project" button (wired in Plan 03)
- Styling: Use existing Tailwind patterns from Layout.tsx. Full height (`h-full`), flex layout, clean assistant-focused design.
- The "Turn this into a project" button should be present but disabled with tooltip "Coming soon" — Plan 03 wires the actual handoff.
</action>
<read_first>
ui/src/api/hardware.ts,
ui/src/api/client.ts,
ui/src/components/ChatPanel.tsx,
ui/src/context/CompanyContext.tsx,
ui/src/hooks/useStreamingChat.ts,
ui/src/components/ChatMessageList.tsx
</read_first>
<verify>
<automated>pnpm --filter @paperclipai/ui tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep -q "useNexusMode" ui/src/hooks/useNexusMode.ts
- grep -q "isAssistantEnabled" ui/src/hooks/useNexusMode.ts
- grep -q "fetchNexusSettings" ui/src/hooks/useNexusMode.ts
- grep -q "assistantMemoryApi" ui/src/api/assistantMemory.ts
- grep -q "appendFact" ui/src/api/assistantMemory.ts
- grep -q "PersonalAssistant" ui/src/pages/PersonalAssistant.tsx
- grep -q "useNexusMode" ui/src/pages/PersonalAssistant.tsx
- grep -q "project_builder\|isAssistantEnabled" ui/src/pages/PersonalAssistant.tsx
</acceptance_criteria>
<done>
PersonalAssistant page renders full-page chat when mode allows, redirects to dashboard when project_builder only. Memory API client ready. Mode hook available for any component.
</done>
</task>
<task type="auto">
<name>Task 2: Wire assistant route in App.tsx and sidebar link in Layout.tsx</name>
<files>
ui/src/App.tsx,
ui/src/components/Layout.tsx
</files>
<action>
In `ui/src/App.tsx`:
- Add lazy import: `const PersonalAssistant = React.lazy(() => import("./pages/PersonalAssistant"));` (or direct import if lazy loading is not used in this codebase — check existing import pattern)
- Add route inside `boardRoutes()` function, after the dashboard route: `<Route path="assistant" element={<PersonalAssistant />} />`
- Also add `<Route path="assistant/:conversationId" element={<PersonalAssistant />} />` for direct conversation linking
In `ui/src/components/Layout.tsx`:
- Add a sidebar NavLink for the assistant page
- Use `MessageCircle` or `Bot` icon from lucide-react (check which icons are already imported)
- Place it prominently — after Dashboard in the nav order
- Gate visibility using `useNexusMode()` — only show when `isAssistantEnabled` is true
- NavLink target: `/assistant`
- Label: "Assistant"
</action>
<read_first>
ui/src/App.tsx,
ui/src/components/Layout.tsx
</read_first>
<verify>
<automated>pnpm --filter @paperclipai/ui tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep -q "PersonalAssistant" ui/src/App.tsx
- grep -q "assistant" ui/src/App.tsx
- grep -q "assistant" ui/src/components/Layout.tsx
- grep -q "useNexusMode\|isAssistantEnabled" ui/src/components/Layout.tsx
</acceptance_criteria>
<done>
/assistant route exists and renders PersonalAssistant page. Sidebar shows Assistant link when mode is personal_ai or both, hidden when project_builder. TypeScript compiles clean.
</done>
</task>
</tasks>
<verification>
pnpm --filter @paperclipai/ui tsc --noEmit
</verification>
<success_criteria>
- PersonalAssistantPage renders chat interface when mode allows
- Route /assistant exists in board routes
- Sidebar link visible only when assistant mode enabled
- Mode redirect works (project_builder -> dashboard)
- TypeScript compiles without errors
</success_criteria>
<output>
After completion, create `.planning/phases/33-persistent-memory/33-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,314 @@
---
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>