240 lines
11 KiB
Markdown
240 lines
11 KiB
Markdown
---
|
|
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>
|